# WISECP LLC. Developer Docs > Modules, hooks, themes and the WISECP API. # Platform Foundations / Quick Start ## Developing for WISECP https://dev.wisecp.com/en/developing-for-wisecp WISECP is a hosting automation platform you extend rather than fork. Modules add capability, themes own the public surface, and hooks change behaviour without touching a line of the core. ### Overview An installation does three jobs at once. It sells (catalog, cart, invoices, payments), it provisions (servers, domains, licences, add-ons), and it supports (tickets, knowledge base, notifications). Every one of those is a place a developer can plug into. The platform is built so that plugging in never means editing the files an upgrade will replace. That last sentence is the whole design. There is a supported way to do each kind of extension, and a version upgrade brings its own copy of the core. Work that lives in the supported places survives; work that lives in an edited core file does not. ### The Four Ways In - **Module**: A new capability the platform did not have: another control panel to provision on, another payment provider, another registrar, another notification channel. There are sixteen types and each one is a contract you implement. - **Theme**: The entire public website and client area. A theme is not a skin over fixed markup; it owns the markup, and the installation shows whichever theme is active. - **Hook**: A point the platform already announces where you can react, change a value, block an operation or print markup. This is how you change behaviour that already exists. - **API**: The way another system talks to an installation: read and write clients, orders, services and tickets from outside. Most real work uses more than one. An external billing system usually needs a module for the credentials and the screens. It also needs hooks for the moments it has to react, and the API for what the other side pulls. #### What Runs Where Three surfaces share one codebase and one database. The admin panel is fixed and ships with the product. The website and client area are themed. Scheduled work runs from the command line on a timer. Long or repeated jobs belong there rather than in a web request. ### Pitfalls > **Editing a core file is not a shortcut, it is a deadline** > > It works until the next upgrade. Then it silently stops working or, worse, gets restored on top of a database that has moved on. If there is no hook for what you need, ask for one instead of editing around it. > **Read the contract before you implement it** > > Each module type expects specific methods with specific return shapes, and each hook documents its parameters and what your return value does. Both are written down; guessing from a name is where most integration bugs start. ### Related Articles - [Setting Up a Development Environment](https://dev.wisecp.com/en/setting-up-a-development-environment) - [Your First Change](https://dev.wisecp.com/en/your-first-change) - [Architecture Overview](https://dev.wisecp.com/en/architecture-overview) - [The Module System](https://dev.wisecp.com/en/the-module-system) - [Principles of Upgrade-Safe Work](https://dev.wisecp.com/en/principles-of-upgrade-safe-work) ## Setting Up a Development Environment https://dev.wisecp.com/en/setting-up-a-development-environment Get an installation you can break, turn the diagnostics on, and confirm the loop works before you write anything that matters. ### Overview Development happens against a real installation, not a mock. There is no separate developer build: the same code that serves customers is the code you extend. The only difference is which diagnostics are switched on, and whether you are allowed to break it. So the first thing to set up is not a tool, it is a boundary. Use an installation nobody depends on, with its own database and its own domain. Every instruction below assumes that. ### Prerequisites - PHP 8.1 or newer with the extensions a normal web installation needs, and access to the same PHP from a shell. - MySQL or MariaDB, with a database that is yours alone. - A web server that can serve the installation directory, and the ability to run a scheduled command. - An installation you are free to break. Never develop against one that has real customers in it. ### Where Things Live - **coremio/**: The application: classes, controllers, models, operations, helpers, modules, hooks, translations and configuration. This is the part an upgrade replaces. - **coremio/modules/**: Modules, one directory per type and one per module inside it. Your own modules live here. - **coremio/configuration/**: Configuration files. They are PHP files that return an array, and they are rewritten by the panel when settings are saved. - **templates/**: Templates: the fixed admin panel, the themed website, the client area and the notification templates. - **templates/website/**: The themes. Your own theme lives here, named after itself. - **coremio/storage/**: Runtime output: cache, logs, temporary files. Writable, and never a place to keep anything you need. ### Walkthrough #### Check the Runtime 1. Run `php -v` in a shell and confirm the version is 8.1 or newer. 2. Confirm the same binary is what the web server uses. A shell running one version while the site runs another is the source of failures that look impossible. 3. Confirm `coremio/storage` is writable by the user the web server runs as. #### Turn the Diagnostics On 1. Open `coremio/configuration/debug.php`. It returns a plain array of switches, and startup turns five of them into constants the rest of the code tests. 2. Set `error` to `true`. That defines `ERROR_DEBUG`, which the error handler reads, so failures surface instead of turning into a generic page. 3. Set `logging` to `true` (it defines `LOG_SAVE`) so the error log records what happened. 4. Leave `query-logging` off unless you are hunting a specific query. It is loud, and it is restricted to the addresses listed next to it. 5. Reload any page. A deliberate mistake in your own code should now be visible instead of silent. #### Verify the Loop 1. Run `php coremio/errlog.php stats`. It prints the totals from the error log, which proves both the shell PHP and the storage directory work. 2. Make a deliberate error somewhere harmless, load the page, and find it with `php coremio/errlog.php list --limit=5`. 3. Remove the deliberate error. You now have a working edit, reload and diagnose cycle. ### Example The switches, the constant each one defines, and the two commands you will use constantly. ```php return [ 'demo' => false, // DEMO_MODE every write is refused 'error' => true, // ERROR_DEBUG surface errors, not a generic page 'developer' => true, // DEVELOPMENT developer affordances in the panel 'logging' => true, // LOG_SAVE write failures to the error log 'logging-module' => true, // LOG_SAVE_MODULE log module traffic as well 'query-logging' => false, // read directly; very loud, only when hunting a query 'query-logging-valid-ips' => ['127.0.0.1', '::1', 'UNKNOWN'], ]; // Read back with the same slash path, at any point after startup: $loud = Config::get('debug/query-logging'); ``` ```bash # Is the installation healthy from the command line? php coremio/errlog.php stats # What failed most recently? php coremio/errlog.php list --limit=5 ``` ### Pitfalls > **Configuration files are compiled PHP** > > The server may keep a compiled copy of a file it has already read. A hand edit can then appear not to take effect. If a change does not show up, rule that out first, before you start doubting the change. > **Leave the diagnostics off where customers can see them** > > Surfaced errors and query logs expose file paths, queries and internal state. They belong on the installation you are allowed to break, and nowhere else. > **Scheduled work does not run itself** > > Renewals, reminders, provisioning retries and cleanup all happen on a timer. If the scheduled command is not running on your installation, those flows never fire. You end up debugging something that was never started. ### Related Articles - [Developing for WISECP](https://dev.wisecp.com/en/developing-for-wisecp) - [Debugging and Logs](https://dev.wisecp.com/en/debugging-and-logs) - [Your First Change](https://dev.wisecp.com/en/your-first-change) - [Bootstrap and Autoloading](https://dev.wisecp.com/en/bootstrap-and-autoloading) ## Coding Conventions https://dev.wisecp.com/en/coding-conventions The codebase is written one way on purpose. Code that follows it reads like the file it sits in, instead of announcing that someone else wrote it. ### Overview Most of these rules exist because the opposite caused a real failure: a value read without a default, a truthiness test that swallowed a legitimate zero, an unescaped input in a query. They apply to anything you write inside an installation: modules, themes, hook listeners and their supporting code. ### Reference #### Input and Output Five helpers stand in front of the builtins you would otherwise reach for. All are static and all have defaults, so read the signature, not the name. - **Filter::init()**: Every value that comes from a request. The superglobals are never read directly. - **Utility::jencode()**: JSON encoding. Three flags are forced on top of yours, so output is stable across call sites. - **Language::gc()**: Anything a human reads. Text is never written into a condition or an exception in one language. - **LinkGenerator::admin()**: Panel addresses. A hardcoded path breaks the moment the admin directory or the route translation changes. - **Utility::strtolower()**: Case conversion, because the language-aware form and the PHP builtin disagree on Turkish. ```php // coremio/classes/Filter.php public static function init($arg = null, $mod = false, $special = false); public static function GET($arg = ''); public static function POST($arg = ''); public static function REQUEST($arg = ''); public static function FILES($arg = ''); public static function SERVER($arg = ''); public static function html_clear($arg = null, $allow = ''); public static function route($arg = '', $special = ''); public static function phone($arg = ''); // coremio/classes/Utility.php public static function jencode($string = '', $flags = 0): string|false; public static function jdecode($string = '', $mode = false); public static function strtolower($str, $encode = 'UTF-8'); public static function strtoupper($str, $encode = 'utf8'); public static function AppAdress($prefix = false); // coremio/classes/Language.php - coremio/classes/LinkGenerator.php public static function g($key = '', $replaces = [], $slang = ''): string|int|array|bool; public static function gc($name = '', $replaces = [], $slang = ''): string|int|array|bool; public static function selected(): string; public static function admin($route = '', $params = [], $lang = '', $wqs = []): string|bool; public static function client($route = '', $params = [], $lang = ''); public static function wQS(null|string|bool $url, string|array $params = []): string; ``` #### Filter::init, Parameter by Parameter The first argument names the source and the key. A prefix of `GET/`, `POST/`, `REQUEST/`, `FILES/` or `SERVER/` selects the array, and further slashes walk into it. Without a prefix the argument is a literal value to clean. ```php $id = Filter::init('POST/id', 'rnumbers'); // $_POST['id'] -> int $name = Filter::init('POST/user/name', 'hclear'); // $_POST['user']['name'] $op = Filter::init('REQUEST/operation', 'route'); // $_REQUEST['operation'] $ip = Filter::init('SERVER/REMOTE_ADDR', 'ip'); // $_SERVER['REMOTE_ADDR'] $safe = Filter::init($someString, 'hclear'); // no prefix: clean a value you already hold ``` The second argument is a closed vocabulary, not a free string. It decides what survives, what type comes back, and what a missing key turns into. The last column matters: the answer is not one value, so one guard does not cover every mode. | $mod | What survives | $special | Missing key returns | | --- | --- | --- | --- | | omitted | the value untouched | used on its own | `false` | | `hclear` | tags stripped, entities decoded first | ignored | `''` | | `dtext` | same as `hclear` | ignored | `''` | | `text` | tags stripped, then quotes turned into entities | ignored | `''` | | `letters` | `a-zA-Z` plus the active language's own letters | added | `''` | | `letters_numbers` | `0-9a-zA-Z` | added | `false` | | `noun` | `0-9a-zA-Z`, comma, period, space | added | `''` | | `numbers` | digits and the minus sign | added | `false` | | `rnumbers` | the above, cast to `int` | added | `0` | | `amount` | digits, minus, period, comma; stays a string | added | `''` | | `rate` | comma becomes a period, extra periods dropped, cast to `float` | added | `0.0` | | `identity` | digits and the minus sign | ignored | `false` | | `ip` | `0-9a-zA-Z`, minus, period, colon | added | `false` | | `email` | `0-9a-zA-Z` and `@ . + _ -` | ignored | `''` | | `domain` | letters, digits, period, minus; lowercased | ignored | `''` | | `folder` | `0-9a-zA-Z` and `/ - _ .` | added | `''` | | `file` | `0-9a-zA-Z`, language letters and `- _ .` | added | `''` | | `route` | `0-9a-zA-Z` and `- _ .`, with `../` removed first | added | `''` | | `password` | nothing removed, the raw value comes back | ignored | `false` | The third argument is not a default value. It is a fragment spliced into the negated character class the mode builds. It *widens* the filter by whatever characters you name. ```php $_POST['name'] = ' Ada Lovelace '; Filter::init('POST/name', 'letters_numbers'); // 'AdaLovelace' Filter::init('POST/name', 'letters_numbers', ' '); // ' Ada Lovelace ' space allowed back in Filter::init('POST/name', 'password'); // ' Ada Lovelace ' untouched $_POST['rate'] = '1.234,50'; Filter::init('POST/rate', 'amount'); // '1.234,50' string Filter::init('POST/rate', 'rate'); // 1234.5 float ``` #### Translations and Links Both helpers take a key that resolves against a file. Both also accept a second array, and it is not the language code. For a translation the array is a straight search and replace. The keys are the placeholders *exactly as they appear in the stored string*, braces included. Nothing is inferred from the name. ```php // Written like this: $text = Language::gc('admin/services/cancel-credit-log-desc', ['{service_id}' => 41]); // 'Cancellation refund for service #41' // Read from this, in coremio/locale/en/cm/admin/services.php: return [ 'cancel-credit-log-desc' => 'Cancellation refund for service #{service_id}', ]; // gc() addresses a component file: // under coremio/locale//cm/. // g() addresses a package file: / under coremio/locale//. $hint = Language::g('needs/password-needs-special', ['{characters}' => '! @ # $']); ``` For a link the array fills the placeholders of a route pattern, positionally. A route with no placeholder ignores the array rather than appending to the path. Query strings never go in that slot; they are the fourth argument. ```php // coremio/locale/en/admin-routes.php ['url pattern', 'controller path'] return ['admin-routes' => [ 'services' => ['services', 'services'], 'users-1' => ['users/(?)', 'users/(1)'], 'financial-2' => ['financial/(?)/(?)', 'financial/(1)/(2)'], ]]; LinkGenerator::admin('services'); // {admin}/services LinkGenerator::admin('services', ['detail', 5]); // {admin}/services arguments dropped, no (?) LinkGenerator::admin('users-1', ['detail']); // {admin}/users/detail LinkGenerator::admin('financial-2', ['coupons', 'add']); // {admin}/financial/coupons/add // Query string is the fourth argument; the third is the language. LinkGenerator::admin('users-1', ['detail'], '', ['id' => 42, 'tab' => 'invoices']); // {admin}/users/detail?id=42&tab=invoices // On a URL you already hold, use wQS(); it picks ? or & for you. LinkGenerator::wQS($url, ['tab' => 'cards']); ``` #### Reading Values Read every array key with a default. This is not defensive habit, it is measured cost. A missing key raises a warning, and the error handler turns each warning into a disk write. ```php // Correct: null-safe, and identical for "0", "1", 0, 1 and null. if ((int) ($data['enabled'] ?? 0) === 1) { } $name = $data['name'] ?? ''; $limit = (int) ($data['limit'] ?? 0); // Wrong: empty("0") is true, so the value "0" disappears. // if (!empty($data['enabled'])) { } // Wrong: verbose, and it says nothing the cast does not. // if (isset($data['enabled']) && (int) $data['enabled'] === 1) { } ``` #### Shape of the Code | Rule | Write | Not | | --- | --- | --- | | Opening tag | `demo(); $id = Filter::init('POST/id', 'rnumbers'); if (!$id) throw new Exception(Language::gc('admin/example/error-id-required')); $name = Filter::init('POST/name', 'hclear'); if ($name === '') throw new Exception(Language::gc('admin/example/error-name-required')); $data = [ 'name' => $name, 'enabled' => Filter::init('POST/enabled', 'rnumbers'), ]; // Retry attempts are stored as failures; there is no success column to update. if (!$this->model->update($id, $data)) throw new Exception(Language::gc('admin/example/error-save-failed', ['{id}' => $id])); return $operation->output([ 'status' => 'successful', 'redirect' => LinkGenerator::admin('users-1', ['detail'], '', ['id' => $id]), ]); } ``` ```php // coremio/locale/en/cm/admin/example.php return [ 'error-id-required' => 'A record must be selected.', 'error-name-required' => 'Name is required.', 'error-save-failed' => 'Record {id} could not be saved.', ]; // coremio/locale/en/admin-routes.php return ['admin-routes' => [ 'users-1' => ['users/(?)', 'users/(1)'], ]]; ``` ### Pitfalls > **The third argument is the language, in both families** > > `LinkGenerator::admin($route, $params, $lang, $wqs)` puts the query string fourth. An array written third silently becomes a language code, and the parameters vanish. The static translation helpers take replacements second and the language third. The instance method behind them reverses the two: `Language::g($key, $replaces, $slang)` against `$lang->get($key, $slang, $replaces)`. Read the signature you are calling, not the one you remember. > **A missing key does not come back as null** > > Depending on the mode it is `false`, `''`, `0` or `0.0`, and never `null`. So `?? $default` after a filter call is dead code, and a presence check written as `!== null` is always true. Compare against the value the mode actually returns. > **A truthiness check is not a presence check** > > The value `"0"` is a real answer for a setting, a quantity or a status, and a truthiness test throws it away. Compare explicitly, or cast and compare. > **Match the file you are in** > > Where a local pattern disagrees with this page, the local pattern usually wins. Consistency inside one file is worth more than consistency with a document. ### Related Articles - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) - [Operations](https://dev.wisecp.com/en/operations) ## Your First Change https://dev.wisecp.com/en/your-first-change Change what the platform does without editing a single file it owns. Register one listener, see it take effect, then remove it as cleanly. ### Overview The smallest useful change is a hook listener. It proves the whole loop in a few minutes. Your file is picked up, your code runs at the point you chose, and your return value is honoured. Nothing you did will be overwritten by an upgrade. This walkthrough blocks a newsletter subscription from throwaway email domains. The rule itself does not matter; what matters is that it is enforced from a file the platform will never replace. ### Prerequisites - An installation you can break, with the diagnostics on. - Write access to the core hooks directory. - A page in the theme that submits the form you are going to block, so you can see the refusal. ### Walkthrough #### Create the Listener File 1. Create a new file under `coremio/hooks` named after what it does, for example `example-rules.php`. 2. Files in that directory are loaded for you; there is no registry to add yourself to. 3. Leave it empty for now and reload any page. Nothing should change, which confirms the file is harmless before it does anything. #### Register a Listener 1. Add a registration for the gate that runs before a subscription is stored, with a priority and a closure. 2. Accept the parameters the hook documents. This one hands you the address and the language. 3. Return an empty string to allow. At this point the listener runs but changes nothing. #### Make It Refuse 1. Read the domain out of the address and compare it against a small list. 2. Return a message when it matches. A non-empty return from a gate stops the operation and the message is what the visitor sees. 3. Submit the form with a matching address; the subscription is refused and your message is displayed. Submit a normal address; it goes through. #### Undo It 1. Delete the file. 2. Submit again with the address that was refused; it now goes through. 3. Nothing else has to be reverted, because nothing else was touched. That is the property worth internalising. ### Example ```php Hook::add('gate:client.newsletter_subscribe', 10, function ($email, $lang) { $throwaway = ['mailinator.com', 'tempmail.com']; $domain = strtolower((string) substr(strrchr($email, '@') ?: '', 1)); if (in_array($domain, $throwaway, true)) return Language::gc('example/permanent-address-required'); return ''; }); ``` Two lines of that example are worth copying into your own work. The parameters match what the hook documents, and the allow path returns an empty string rather than nothing at all. ```php // coremio/classes/Hook.php public static function add($name, $priority, $properties = []): void; public static function run($name, ...$args): array; public static function runRefs($name, &...$args): array; ``` The third argument accepts a closure, as above, or an array naming a class and a method. Use `['class' => 'MyClass', 'method' => 'check']` for an instance call. For a static one, write `['class' => 'MyClass', 'method::static' => 'check']`. A lower priority runs earlier, and a collision is nudged rather than dropped. The other half of the contract is the core side, which decides what your return value means. A gate collects every listener's answer and lets the first non-empty string stop the operation. ```php foreach (Hook::run('gate:client.newsletter_subscribe', $email, $lang) as $veto) if (is_string($veto) && $veto !== '') throw new Exception($veto); $added = $this->model->add_subscriber($email, $lang); ``` Returning nothing works by accident; returning an empty string works on purpose. `null` fails the string test today, and an explicit empty string keeps working if the test ever tightens. ### Pitfalls > **If nothing happens, suspect your listener before the hook** > > An error inside a listener is caught and logged rather than shown. A broken listener is then indistinguishable from a hook that never fired. Check the error log first. > **A gate runs after the checks in front of it** > > Validation, rate limiting and bot protection happen before your listener is reached. A request that fails one of those never gets to you. That is the intended order, and it means a gate is the wrong place to test whether the form works at all. > **The refusal message is read by a person** > > Return it through the translation layer rather than as a literal. Otherwise your installation speaks one language to everyone who sees the refusal. ### Related Articles - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [The Hook Catalog](https://dev.wisecp.com/en/hook-domains) - [Debugging and Logs](https://dev.wisecp.com/en/debugging-and-logs) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) ## Debugging and Logs https://dev.wisecp.com/en/debugging-and-logs Find out what actually happened: where failures are recorded and how to read them from a shell. A silent page is usually a caught error rather than a missing one. ### Overview Failures do not print themselves. They go through one handler that decides what the caller sees. That is an HTML error page, a JSON error body, or nothing at all when the diagnostics are off. In every case the failure is written to the error log, and the log is what you read. The absence of a message on screen tells you almost nothing; the log tells you everything. ### Prerequisites - The diagnostics switched on for the installation you work against. - Shell access to the same PHP the site runs on. The tool answers 404 over HTTP; it is command line only. ### Walkthrough #### Start with the Overview 1. Run `php coremio/errlog.php stats`. 2. Read the fatal and error counts, and the last twenty-four hours. A number that jumped is where to look. 3. If the command prints nothing useful, logging is off or the storage directory is not writable. Fix that first. #### Find the Failure 1. List recent entries with `php coremio/errlog.php list --limit=10`, or narrow with `--level=FATAL`. 2. Each entry carries a signature, a count, a file and line, and when it was last seen. 3. Open one with `php coremio/errlog.php show `: the full record has the request and the stack. #### Follow a Reported Identifier 1. A failure that surfaces to a user or an API caller carries an error identifier. 2. Look it up with `php coremio/errlog.php find `. 3. You get the exact record for that occurrence, which makes a user report actionable. ### Reference One entry point, eight commands. Arguments are positional and options are `--name=value` in any order. An option the command does not read is ignored. | Command | Argument | Options | What it does | | --- | --- | --- | --- | | `help` | none | none | The command list. Also the default, and the answer to `-h`. | | `stats` | none | none | Totals by level and type, plus the last twenty-four hours. | | `list` | none | `--level` `--type` `--limit` `--offset` `--file` | Entries from the manifest, newest sighting first, one signature per row. | | `find` | error identifier | none | Scans every log file for that identifier and prints the record. Exit code 1 when nothing matches. | | `show` | signature | none | Decrypts and dumps one entry in full: request, message, context, stack, POST body. | | `delete` | signature | `--force` | Removes one log file and its manifest row. Prompts unless forced. | | `cleanup` | none | `--days` `--force` | Deletes files older than N days, in both log types. Prompts unless forced. | | `rebuild` | none | none | Re-indexes every log file into a fresh manifest, after files are copied in or removed by hand. | The two positional arguments are not interchangeable. A signature is the 32 character fingerprint of a call site, printed under every `list` row. An error identifier is the short code shown to whoever hit the failure. Both are matched case insensitively, and passing one where the other belongs fails the format check. #### Options - **--level=NAME**: One of `FATAL`, `ERROR`, `WARNING`, `INFO`, `DEBUG`, `DEPRECATED`. Case does not matter. One level per run. - **--type=NAME**: `system` or `database`. The two are stored separately and `stats` counts them separately. - **--limit=N**: How many rows to print. Default 20, floored at 1. - **--offset=N**: How many rows to skip, for paging. Default 0. - **--file=PATTERN**: Case insensitive substring of the recorded path, not a glob. A directory fragment narrows a whole subsystem. - **--days=N**: Retention for `cleanup`, in days. Default 30; below 1 stops the command. - **--force**: Skips the confirmation on `delete` and `cleanup`. #### The Side That Writes Everything the CLI reads was written through these two classes. Both write methods return the error identifier that `find` takes back. ```php // coremio/classes/MioException.php public static function getInstance(): MioException; public function logError(string $message, array $context = [], string $level = 'ERROR', string $type = 'system'): string; public function logDatabaseError(string $query, string $error, array $params = []): string; public function sanitizeData(array $data): array; // coremio/classes/Logger.php public function log(string $level, string $message, array $context = [], string $type = 'system'): string; public static function stats(): array; public static function entries(array $filters = [], int $start = 0, int $limit = 50, array $sort = []): array; public static function entriesCount(array $filters = []): int; public static function cleanup(int $daysToKeep = 30, ?int $cutoffTimestamp = null, string $type = 'system'): int; public static function deleteOne(string $signature): bool; public static function rebuild(): int; ``` The filter array behind `list` accepts more than the CLI exposes. An unknown key is ignored, so a typo silently widens the result instead of narrowing it. | Filter key | Value | Match | | --- | --- | --- | | `levels` | array of level names | exact, uppercased | | `types` | array of `system` or `database` | exact | | `signature` | string | substring | | `file` | string | substring, case insensitive | | `message` | string | substring of the stored preview | | `exception_class` | string | substring | | `request_uri` | string | substring | | `http_method` | `GET`, `POST` and so on | exact, uppercased | | `user_id` | int | exact, ignored when 0 | | `ip` | string | substring | | `min_occurrence` | int | count at or above | | `start_datetime` / `end_datetime` | anything the date parser accepts | window on the last sighting | The sort array is `['field' => 'last_seen', 'order' => 'desc']`. Useful fields: `last_seen`, `first_seen`, `count` and `level`, where sorting is by severity rather than alphabet. #### How a Failure Surfaces | Request | Diagnostics on | Diagnostics off | | --- | --- | --- | | Normal page, fatal | Error page with detail | Generic error page | | Normal page, non-fatal | Notice on the page | Nothing visible | | AJAX or API | JSON error with detail | JSON error with an identifier | | Hook listener | Caught and logged; the flow continues either way | | | Command line | Written to the log; the output buffer may hide the message | | ### Example ```bash # Health first: is anything failing at all? php coremio/errlog.php stats # The five most recent fatals php coremio/errlog.php list --level=FATAL --limit=5 # Only what came out of one subsystem, second page of ten php coremio/errlog.php list --file=modules/Servers --limit=10 --offset=10 # Only database failures php coremio/errlog.php list --type=database --limit=5 # The full record for one signature php coremio/errlog.php show f05bc1f49d2963a58a7c378dc3430c48 # A user reported an error identifier; look up that exact occurrence php coremio/errlog.php find 25EE72D6527 # Drop one signature once it is fixed, and trim the rest to a week php coremio/errlog.php delete f05bc1f49d2963a58a7c378dc3430c48 --force php coremio/errlog.php cleanup --days=7 --force # Re-index after log files were moved or removed by hand php coremio/errlog.php rebuild ``` The same records from PHP. The identifier returned by the write is what the CLI reads back. ```php // Write: returns the identifier you would hand to `errlog.php find`. $id = MioException::getInstance()->logError( 'Provisioning refused by the panel', ['service_id' => 41, 'response' => $body], Logger::LEVEL_ERROR, Logger::TYPE_SYSTEM ); // Read: the same rows `errlog.php list` prints. $rows = Logger::entries( ['levels' => [Logger::LEVEL_FATAL], 'file' => 'modules/Servers'], 0, 10, ['field' => 'count', 'order' => 'desc'] ); foreach ($rows as $row) echo $row['signature'], ' x', (int) ($row['count'] ?? 1), ' ', $row['file'] ?? '-', ':', (int) ($row['line'] ?? 0), ' ', $row['message_preview'] ?? '', PHP_EOL; // Aggregates: total, system, database, fatal, error, warning, info, // last_24h, occurrences, and recent[] broken down by level. $health = Logger::stats(); ``` ### Pitfalls > **A silent command line is not a successful one** > > An output buffer is installed during startup, so a message written to standard output can disappear. Write diagnostics to standard error, and check the log before believing a script did nothing wrong. > **Every warning costs a disk write** > > The handler records non-fatal warnings too, so a missing array key read inside a loop turns into one write per iteration. Read values with a default. > **Without --force the destructive commands wait for an answer** > > `delete` and `cleanup` read a yes or no from the terminal. In a scheduled job or a piped shell there is nobody to answer, so the command blocks or does nothing. Pass the option deliberately. > **A high occurrence count can be a single page load** > > Entries are grouped by signature and counted. When the first and last sighting are the same moment, the line ran many times in one request. That is the fingerprint of a warning inside a loop. ### Related Articles - [Setting Up a Development Environment](https://dev.wisecp.com/en/setting-up-a-development-environment) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Coding Conventions](https://dev.wisecp.com/en/coding-conventions) - [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade) # Platform Foundations / Architecture ## Architecture Overview https://dev.wisecp.com/en/architecture-overview One request enters. One dispatcher decides whether it is a page or a mutation, and the answer comes back as HTML or as JSON. Everything else is a layer serving that sentence. ### Overview WISECP is a custom MVC framework, not a general-purpose one wearing a costume. It has controllers, models and views in the usual sense, plus two pieces of its own. *Operations* are the only way data changes. *Hooks* are how anyone outside the core takes part. Reading a feature usually means reading four files. The controller answers the address and the model fetches the rows. The template turns them into markup, and the operation trait changes them. Once that shape is familiar the codebase stops being large and starts being repetitive, which is the point. ### Structure #### The Request Flow ```bash index.php └─ bootstrap.php constants, autoloader, configuration, session, language └─ Bootstrap::run() address -> route -> controller file + params └─ {controller}->index(), or ->main() when there is no index() ├─ the request carries `operation` -> operation method -> JSON └─ anything else -> page_{name}() -> HTML ``` The fork in the middle is the thing to remember. The operation name is read from the request, not from the verb, so either verb can carry it. What it decides is that no page will be built. A request that names an operation never produces a page, and a page method never writes. That separation makes permissions, demo mode and error formatting decidable in one place instead of in every screen. #### The Layers - **Router**: Turns an address into a controller and its parameters. Routes are translated per language, so the same page has a different path in each one. - **Controllers**: The base every controller extends. It loads the matching model, exposes the view, collects the data a template will receive and dispatches operations. - **Models**: The base every model extends. Database access lives here and nowhere above it. - **View**: Picks the template directory and builds the output from a template and the collected data. - **Operation**: The object handed to an operation method. It carries the demo guard, the hook helper and the JSON response. - **Hook**: The extension surface. Modules, themes and installation-level rules all attach here. #### Three Surfaces, One Application The admin panel, the public website with the client area, and the scheduled command line all run the same core. They differ in which controllers answer, which templates are used and which session is in play. A helper written for one is available to the others. That is why business rules belong in helpers rather than in a controller. ### Pitfalls > **The admin and client sides are separate code paths** > > They often do the same thing through different files. A change made on one side is not automatically true on the other. When you fix a behaviour, check whether its twin exists elsewhere. > **Queries belong in the model** > > A query written in a controller or a template works. Later it becomes the reason a page cannot be reused, cached or called from the command line. Put it in the model even when it is one line. ### Related Articles - [Bootstrap and Autoloading](https://dev.wisecp.com/en/bootstrap-and-autoloading) - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) - [The Model Layer](https://dev.wisecp.com/en/the-model-layer) - [Operations](https://dev.wisecp.com/en/operations) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Bootstrap and Autoloading https://dev.wisecp.com/en/bootstrap-and-autoloading How a class you never require becomes available, and why a misplaced file is a class that does not exist. ### Overview Application code uses no Composer autoloader. Startup registers one loader that maps a class name to a directory, so the rule is positional. A class is found by where its file sits and what it is called. A new file in the right place needs no registration, and the same file one directory over is invisible. A wrong mapping gives a class-not-found error that looks like a typo and is a location. ### Reference #### Namespaced Classes A class under the root namespace resolves through a fixed map. The prefix picks the directory, the class name picks the file. Two file names are tried: as written, then lower case. | Namespace | Directory | | --- | --- | | `WISECP\Components\{Name}` | `coremio/components/{Name}.php` | | `WISECP\Operations\{Name}` | `coremio/operations/{Name}.php` | | `WISECP\AdminComponents\{Name}` | `templates/admin/components/{Name}.php` | | `WISECP\CronJobs\{Name}` | `coremio/cronjobs/{Name}.php` | | `WISECP\Api\{Sub}\{Name}` | `coremio/api/{Sub}/{Name}.php` | | `WISECP\Modules\{Type}\{Name}` | `coremio/modules/{Type}/{Name}/{Name}.php` | The module branch is not a fixed list. Startup registers a namespace for every type directory it finds, so a new module type autoloads as soon as its directory exists. The API branch is the one exception to the flat map: it follows sub-namespaces into sub-directories. #### The Startup Object The startup class registers the loader, and the root bootstrap file constructs it. What follows is one object with four public methods. ```php public static ?self $init = null; // the running instance public string $target; // the requested address, normalised public array $route; // its segments public array $routes; // the active route table public \Controllers $controller; // the controller object, once built public \Router $router; // the Router instance public static ?\Language $lang = null; // the Language instance public function __construct(); // registers the autoloader, config, session, errors public function initRouter(): void; // builds the route table; done for you on the web public function getAddress(bool $prefix = false): string; // the site address, optionally language-prefixed public function run(): bool; // resolves the address and runs the controller ``` - **run()**: The whole request: route table, admin directory detection, maintenance and access gates, controller include, entry method. Called once by `index.php`. - **initRouter()**: Needed before anything that generates a link. The root bootstrap calls it when the interpreter is the shell. - **getAddress()**: The value behind `APP_URI`. Pass true for the address with the active language prefix. #### Classes Without a Namespace Core classes and helpers carry no namespace. Three directories are searched in order and the first hit wins. A module has its own namespace, so it reaches them with a leading backslash. - **coremio/classes/**: The framework itself: input filtering, configuration, routing, views, database, hooks, errors, module base classes. - **coremio/helpers/**: Business logic shared everywhere: services, orders, invoices, money, users, cron. Domain rules belong here. - **coremio/components/**: Reusable interface pieces controllers build and templates display. #### Controllers and Models Are Not Autoloaded Their lowercase namespaces mirror their directory but are absent from the map above. Routing includes the controller file by path, and the controller's constructor includes the model file by name. On the web the difference is invisible. In a command line script a model has to be required before it can be constructed. ```php // \WISECP\controllers\admin\{name} -> coremio/controllers/admin/{name}.php // \WISECP\models\admin\{name} -> coremio/models/admin/{name}.php // Neither is in the autoload map, so class_exists() alone answers false here. require_once CORE_DIR . 'models' . DS . 'website' . DS . 'products.php'; $model = new \WISECP\models\website\products(); ``` #### Constants You Will See | Constant | Value | Declared in | | --- | --- | --- | | `ROOT_DIR` | The installation directory, with a trailing separator | `bootstrap.php` | | `CORE_DIR` | `ROOT_DIR . "coremio" . DS` | `bootstrap.php` | | `CLASS_DIR`, `HELPER_DIR`, `COMPONENTS_DIR`, `OPERATIONS_DIR` | `CORE_DIR` plus classes, helpers, components, operations | `bootstrap.php` | | `MODULE_DIR`, `CONTROLLER_DIR`, `MODEL_DIR`, `LANG_DIR` | `CORE_DIR` plus modules, controllers, models, locale | `bootstrap.php` | | `CONFIG_DIR`, `STORAGE_DIR`, `CACHE_DIR`, `LOG_DIR` | Configuration, and storage with cache and logs beneath it | `bootstrap.php` | | `TEMPLATE_DIR` | `ROOT_DIR . "templates" . DS` | `bootstrap.php` | | `DS` | `DIRECTORY_SEPARATOR`. Join paths with it, not with a literal slash | `bootstrap.php` | | `CRON` | `true`, defined when the interpreter is the command line | `bootstrap.php` | | `APP_URI` | The site address, taken from the startup object | `bootstrap.php` | | `ADMINISTRATOR` | The resolved admin directory **name**, a string, not a boolean. Defined only while the panel is served, so shared code tests it with `defined()` | `coremio/init.php` | | `DEMO_MODE`, `DEVELOPMENT`, `ERROR_DEBUG` | The debug settings: demo, developer, error | `coremio/init.php` | | `LOG_SAVE`, `LOG_SAVE_MODULE` | The logging settings: general and per module | `coremio/init.php` | ### Example A command line script starts the same way a request does. ```php // The command line is detected during startup; nothing has to be declared first. require __DIR__ . '/bootstrap.php'; // Namespaced: resolved through the map above. $table = new \WISECP\Components\Table("widgetList"); // No namespace: resolved from the class, helper and component directories. $rate = Money::exChange(100, 'USD', 'EUR'); // From inside a module, the same core classes need a leading backslash // because the module's own namespace would be searched first. $module = \Modules::getInstance('Currency', 'AcmeRates'); ``` #### Keeping a Script Off the Web A script inside the installation directory is also a URL: anyone who guesses the file name can trigger it. A script meant for a shell refuses everything else before it loads the application. ```php // Answer 404 in any web context, and do it before the application is loaded. if (PHP_SAPI !== 'cli' || !defined('STDIN') || isset($_SERVER['REQUEST_METHOD']) || isset($_SERVER['HTTP_HOST']) || isset($_SERVER['REMOTE_ADDR']) || isset($_SERVER['SERVER_SOFTWARE'])) { if (!headers_sent()) { @http_response_code(404); @header('Content-Type: text/plain; charset=utf-8'); @header('Cache-Control: no-store'); } exit; } require __DIR__ . '/bootstrap.php'; ``` Each part of the guard is deliberate. Interpreter mode alone is not trusted: a misconfigured server can run a file through a command line binary. Standard input must exist, and any web-server variable refuses the run. The answer is **404**, not 403, so a probe cannot confirm the file exists. The guard sits above the require, so a refused request loads nothing. A file that is only included needs a different guard: refuse when the application's constants are absent, as templates do. Neither guard fits a script that must stay reachable over the web. A cron entry point triggered by URL is one. It needs a secret in the address and a check on the other side. ### Pitfalls > **The file name is part of the contract** > > Directory, file and class have to agree; the loader tries the lower-case file name as a fallback. A mismatch can work on a case-insensitive filesystem and fail on the server. > **Command line scripts require the root bootstrap** > > Starting only the initialiser gives a partly built application with no helpers. Require the root bootstrap file, which runs the whole startup. > **The command line is detected for you** > > Booting from a shell sets the non-request flag, so a script declares nothing. The flag sends a startup failure to standard error and exits non-zero. It also keeps the theme out of a generated view and short-circuits the consent layer. Those answers suit a script and not a page. Older scripts that declare the flag themselves are unaffected. ### Related Articles - [Architecture Overview](https://dev.wisecp.com/en/architecture-overview) - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Setting Up a Development Environment](https://dev.wisecp.com/en/setting-up-a-development-environment) ## Controllers and Routing https://dev.wisecp.com/en/controllers-and-routing A controller answers one address family, decides whether the request is a page or a mutation, and gathers what a template needs. ### Overview Every controller extends one base class that does what no controller should repeat. It loads the matching model, exposes the view, collects the template data and routes an operation to its method. A controller file holds two kinds of method: page methods that build a page, operation methods that change data. Anything else belongs in a helper. ### Structure - **Controller**: `coremio/controllers/{surface}/{name}.php` — surface is `admin`, `website` or `system`. - **Model**: `coremio/models/{surface}/{name}.php`, reached as `$this->model`. Never construct it yourself. - **Templates**: `templates/{surface}/{name}/`, one file per page served. - **Operations**: Traits under `coremio/operations/`; the controller uses them and their methods become its own. Startup includes the file by path, resolves the class name in three steps and calls one entry method. A name matching none of the three shapes gives the not-found page, not a class-not-found error. ```php // 1. the file is included by path, never autoloaded include CONTROLLER_DIR . $surface . DS . $controller . '.php'; // 2. the class name is tried in this order (separators removed, case-insensitive) // \WISECP\controllers\{surface}\{name} -> {Name}Controller -> Controller // 3. the name and the route key are published before construction Controllers::$cname = $controller; // "widgets" Controllers::$rkey = $route_key; // the route entry that matched // 4. params are assigned AFTER the constructor, unless the constructor declares one $this->controller = new $className(); $this->controller->params = $params; // 5. entry point: index() wins, main() is the fallback $content = method_exists($this->controller, 'index') ? $this->controller->index() : $this->controller->main(); ``` ### Reference #### The Two Kinds of Method The base class declares neither kind; both are dispatcher conventions. A page method is named after the address segment and returns markup, or an empty string so the caller builds the template. An operation method is named after its operation, takes one argument and produces no page. ```php // page_{segment}[_{subsegment}[_{subsegment}]] - dashes and dots become underscores public function page_list(&$links, &$meta, &$breadcrumbs): string; // one operation method per registered operation name public function save_widget(Operation $operation): bool; ``` #### What the Three Page Arguments Carry By-reference outputs. The caller passes them to `set_predefined_data()`, which publishes them under the names below. The breadcrumb key loses its plural. - **$links → $links**: `['controller' => LinkGenerator::admin("widgets")]`. The base address the page posts back to; templates and the table component read it. - **$meta → $meta**: `['title' => 'Widgets']`. Must be an array: a locale key shaped as `content` plus `variables` makes `Language::gc()` return a string, and the type declaration throws. - **$breadcrumbs → $breadcrumb**: `[['link' => string|null, 'title' => string], ...]`. A null link appears as the current, unlinked step. #### What the Base Class Gives You ```php public Models $model; // constructed for you, by name public View $view; public array $params = []; // URL segments after the controller public array $data = []; // what the template receives public array $operations = []; // the registration table public bool $loggedIn = false; public bool $loginControl = false; public static string|int $cname = ''; // controller name public static string|int $rkey = ''; // matched route key public static array $rparams = []; // the matched route's params, slug INCLUDED public function addData($k = '', $v = ''): void; public function getData($key); public function takeDatas($which = []): void; public function set_predefined_data(string $type = 'client', array $meta = [], array $breadcrumbs = [], array $links = []): void; public function operation(string $name = '', array $operations = []): bool; public function checkLogin(bool $redirect = true): bool; public function client_gate(bool $requireComplete = true): ?string; public function page_404($type = 'admin'): string; public function page_denied(string $scope = ''): string; public function CRLink($r_key = '', $params = [], $slang = ''): string|bool; public function AdminCRLink($r_key = '', $params = [], $slang = ''): string|bool; public function RouteURI($params = [], $key = null, $slang = '', $admin = false): string; public function ControllerURI(): string; protected function localize_lang_links(string $routeKey, array $slugByLang): void; ``` - **addData()**: Adds one named value to `$this->data`. The view extracts the array, so templates read it as a variable. - **getData()**: Reads back a collected value, or `null`. - **set_predefined_data()**: Three surfaces. `"admin"` gives the panel chrome, `"client"` the client-area context plus `filter:client.predefined_data`. Anything else publishes only links, meta, breadcrumb and locale. Call it before building the page. - **takeDatas()**: The blocks `set_predefined_data()` is built from, by keyword: `admin-sign-all`, `admin_info`, `lang_list`, `account_info`, `website_logos`. Use it for one block outside a full surface. - **operation()**: The dispatcher. Call it from your entry method with the requested name; it answers JSON and returns false. - **checkLogin()**: Admin session gate, called in the constructor. It sets `loginControl`, which makes startup skip the entry method when no session exists. - **client_gate()**: Client-area gate. `null` means proceed; anything else has already redirected or answered, and you return its value. - **page_404()**: Not-found page, for an address that resolves to no record. Pass the surface, not a message. - **page_denied()**: Refusal page for a client acting on an account that never granted this area. The scope maps to `website/denied/scope-*` phrases. #### The Registration Table Registration maps an operation name a request may send to a small array of properties. Two keys are read, both optional. ```php public function __construct() { parent::__construct(); $this->operations = array_merge($this->operations, [ // 'privileges' => string[] privilege keys, ALL of which the session must hold // 'method' => string the method to call, when it is not the operation name 'save_widget' => ['privileges' => ['WIDGETS_OPERATION']], 'delete' => ['privileges' => ['WIDGETS_OPERATION'], 'method' => 'delete_widget'], 'view_detail' => ['privileges' => ['WIDGETS_LOOK', 'WIDGETS_OPERATION']], ]); } // coremio/classes/Controllers.php reads exactly those two keys: private function run_operation(string $name = '', array $properties = []): bool; // $method = $properties["method"] ?? $name; // $privileges = $properties["privileges"] ?? []; // if ($privileges && !Admin::isPrivilege($privileges)) throw new Exception(...); ``` #### Routing Addresses are not hardcoded; links come from a route key. Each language has its own route table, so one controller answers a different path per language. A route entry is `'key' => ['pattern', 'controller']`, where `(?)` is one segment: `'services' => ['services/(?)', 'services']`. ```php // Router: instance methods, reached through Router::getInstance() public static function getInstance(array $routes = []): self; public function get(string $url): ?array; // ['key' => string, 'controller' => string|callable, 'params' => array] or null public function add(string $name, string $pattern, string|array|callable $handler): void; public function generate(string $name, array $params = []): ?string; public function matchController(array $params): string; // controller name, or '' when the slug resolves to nothing public function findRoute($controller, $params): bool; // memoized slug lookup in the *_lang tables public function setRoutes(array $routes): self; public function setLang($lang): self; public function getRoutes(): array; // LinkGenerator: static public static function admin($route = '', $params = [], $lang = '', $wqs = []): string|bool; public static function client($route = '', $params = [], $lang = ''); public static function wQS(string|bool|null $url, array|string $params = []): string; public static function convert_to_link(string $arg): string; ``` - **Router::get()**: Resolves an address. Exact patterns first, then `filter:routing.prematch`, dynamic patterns, constants, `filter:routing.match`. - **Router::findRoute()**: Not an address resolver: it asks the database whether a slug exists in the current language, for entity routes only. Hits and misses are memoized per language. - **Router::matchController()**: Wraps the lookup above; empty string when the slug does not exist. - **Router::add()**: Registers a route from a module's `router.php`; startup includes one per module directory. The handler may be a callable. - **LinkGenerator::admin()**: Panel address from a route key. Language is third, query string fourth: `admin("services", ["detail", 5], "", ["tab" => "invoices"])`. - **LinkGenerator::client()**: The same for a website or client-area address. No query-string argument; add one with the helper below. - **LinkGenerator::wQS()**: Appends a query string, choosing the separator itself. Never concatenate one by hand. ### Example ```php namespace WISECP\controllers\admin; use Controllers; use Filter; use Language; use LinkGenerator; use WISECP\Operations\AdminWidgets; class widgets extends Controllers { use AdminWidgets; // the operation methods live in this trait public function __construct() { parent::__construct(); $this->checkLogin(); $this->operations = array_merge($this->operations, [ 'save_widget' => ['privileges' => ['WIDGETS_OPERATION']], ]); } public function main() { if ($operation = Filter::init("REQUEST/operation", "route")) return $this->operation($operation); $page = $this->params[0] ?? 'list'; $links = ['controller' => LinkGenerator::admin("widgets")]; $meta = ['title' => Language::gc("admin/widgets/page-list")]; $breadcrumbs = [ ['link' => LinkGenerator::admin("dashboard"), 'title' => Language::gc("admin/index/breadcrumb-name")], ['link' => null, 'title' => Language::gc("admin/widgets/breadcrumb-list")], ]; $method = "page_" . str_replace(["-", "."], "_", $page); if (method_exists($this, $method) && ($out = $this->$method($links, $meta, $breadcrumbs))) return $out; // Mind the order: links is the LAST argument here, the FIRST one above. $this->set_predefined_data("admin", $meta, $breadcrumbs, $links); return $this->view->chose("admin")->render("widgets/" . $page, $this->data, true); } public function page_list(&$links, &$meta, &$breadcrumbs): string { $this->addData("rows", $this->model->list(false, [], ['id' => 'DESC'], 0, 25)); $this->addData("clink", $links["controller"]); return ''; // main() renders templates/admin/widgets/list.php } } ``` ```php
``` ### Pitfalls > **An unregistered method is still reachable, and unprotected** > > The dispatcher accepts a request when the name is registered *or* when a method of that name exists. An unregistered method runs with an empty property array — no privilege check. Registration does not make a method callable; it gives it a privilege. > **The argument order flips between the two calls** > > A page method receives `(&$links, &$meta, &$breadcrumbs)`; `set_predefined_data()` takes `($type, $meta, $breadcrumbs, $links)`. Passing them straight through puts the links where the meta belongs, and the page shows an empty title instead of failing. > **Never write an address as a literal** > > Routes are translated and the panel directory is configurable. Generate links from a route key; a hardcoded path breaks in another language or installation. > **A page method must not write** > > Anything that changes data belongs in an operation, where the privilege check, demo guard and JSON error format apply. A write hidden in a page method has none of those. ### Related Articles - [Operations](https://dev.wisecp.com/en/operations) - [The Model Layer](https://dev.wisecp.com/en/the-model-layer) - [Views and Templates](https://dev.wisecp.com/en/views-and-templates) - [Building Links and Routes](https://dev.wisecp.com/en/building-links-and-routes) ## The Model Layer https://dev.wisecp.com/en/the-model-layer Models are where the database is allowed to be touched. They are also where a row stops being a row and becomes something the rest of the application can use. ### Overview A model belongs to a controller, is found by the same name, and is constructed for it. It exposes a small, predictable set of methods, and the controller that owns it does not know what a query looks like. The reason for the boundary is not purity. It is that the same data is needed by the panel, the client area, the API and the scheduled command line. A query written into a page can only ever serve that page. ### Structure - **Location**: `coremio/models/{surface}/{name}.php`, matching the controller name exactly. Loaded by path during construction, not by the autoloader. - **Class name**: Tried in this order: `\WISECP\Models\{Surface}\{Name}`, then `{Name}Model`, then a bare `Model`. No match leaves you with the plain base class and no methods of your own. - **Connection**: `$this->db`, resolved on first read through `__get()`. A page that never queries never opens a connection. ### Reference #### The Base Class ```php public string $pfx; // table prefix, for raw SQL fragments public ?Database $connection = null; public static ?self $init = null; // the last model constructed - WDB reads its connection public function __get($name); // 'db' returns the Database; anything else null protected function lang_routes(string $langTable, int $ownerId): array; // ['en' => 'slug', 'tr' => 'slug'] public function menu_list($type, $lang, $list = [], $parent = 0); public function link_detector($link): string; ``` The connection behind `$this->db` is held in a function-static, so every model in the process shares one `Database` object. `WDB` is a static facade over that same object. Two entry points, one builder and one unfinished query state. #### The Usual Methods These are conventions rather than an interface, and following them means a reader who has seen one model has seen them all. The signatures below are the shape the newer models use. ```php public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int; public function get(int $id): array|false; public function add(array $data = []): int; public function update(int $id = 0, array $data = []): bool; public function delete(int $id): bool; ``` - **$rCount**: `true` returns the row count as an int and skips the columns, ordering and paging. The table component calls the same method twice, once each way. - **$filters**: Named conditions, read with `??` so every key is optional. `word` is the shared one, the free-text search. The rest are the model's own: services accepts `status`, `user_id`, `product_id`, `server_id`, `cycle`, `duedate` plus `duedate_op`, and so on. - **$orders**: `['id' => 'DESC']`: bare column name to direction. The model prefixes the table alias itself, so do not pass one. An empty array falls back to the model's default order. - **$start, $end**: Offset and row count, passed straight to `limit()`. `$end = -1` means no limit at all, which is the default, so ask for a page explicitly. - **Missing record**: `get()` returns `false`, never an empty array, so the caller can tell "no such record" from "a record with nothing in it". #### The Query Builder Every builder method returns the connection, so calls chain and `build()` or `save()` closes the chain. The static facade forwards to the same object with one difference: its `where()` stops at four arguments, the instance takes a fifth. ```php public static function select($arg = '*'); public static function from($arg = ''); public static function join($type, $table, $where); // $type: "LEFT", "INNER", ... public static function where($column, $mark = '', $value = '', $logical = ''); public static function group_by($arg = ''); public static function order_by($arg); public static function limit($arg1, $arg2 = null); public static function build($isthis = false); // runs a read, returns falsy on failure public static function fetch_assoc($statement = false); // all rows public static function getAssoc($statement = false); // one row public static function getObject($statement = false); public static function rowCounter($statement = false); // affected/returned row count public static function insert($table, $data); // row count; the id comes from lastID() public static function update($table = '', $data = []); // chain where(), then save() public static function delete($arg = '', $arg2 = ''); public static function save($isthis = false); public static function lastID(); // The instance carries one extra argument on where(): public function where($column = '', $mark = '', $value = '', $logical = '', $filter = '?'): self; ``` - **select()**: Opens a read with the column list as one string. Followed by the table, the joins, the conditions and the ordering. - **from()**: The table, alias included: `"knowledgebase AS t"`. The installation's table prefix is added for you. - **join()**: Type, table and the join condition as a literal string. Values inside that string are not bound, so nothing user-supplied belongs there. - **where()**: Column, comparison, value, and the operator that joins this condition to the *next* one. The value is bound. Omitting the fourth argument leaves an `AND` behind, which is added for you. - **build()**: Executes the read. It is falsy on failure, which is why the house pattern is `build() ? fetch : fallback` rather than an unconditional fetch. - **fetch_assoc()**: Every row as an associative array, or an empty array when there is no connection. - **getAssoc()**: One row. Pair it with a `limit(1)` so the statement fetches what you intend to read. - **insert()**: Writes a new row from an associative array and returns the affected row count. The new identifier comes from the call below, not from this one. - **update()**: Starts a change and returns the connection; the conditions follow and `save()` commits it. An update without a condition rewrites the table. - **lastID()**: The identifier produced by the last insert on this connection, as an int. #### Translated Rows Records shown to people keep their translations in a companion table named after the record's own. It holds one row per language, keyed by `owner_id` and `lang`. A read joins it for the active language and falls back to the record's own value. A partially translated installation still shows a value. The same table carries the per-language slug, which is what `lang_routes()` returns for locale switchers and alternate links. ### Example ```php namespace WISECP\Models\Admin; use Language; use Models; class Widgets extends Models { public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int { $lang = Language::selected(); $search = $filters["word"] ?? ''; if (!$orders || empty(array_key_first($orders))) $orders = ['id' => 'DESC']; $stmt = $this->db->select($rCount ? "t.id" : "t.id, COALESCE(tl.name, t.name) AS name, t.status") ->from("widgets AS t") ->join("LEFT", "widgets_lang AS tl", "tl.owner_id = t.id AND tl.lang = '" . $lang . "'"); // Fourth argument: the operator that joins THIS condition to the next one. if ($search) $stmt->where("COALESCE(tl.name, t.name)", "LIKE", "%" . $search . "%", "&&"); $stmt->where("t.status", "!=", "deleted"); if ($rCount) return $stmt->build() ? $stmt->rowCounter() : 0; $order = array_key_first($orders); $stmt->order_by("t." . $order . " " . strtoupper($orders[$order])); if ($end != -1) $stmt->limit($start, $end); return $stmt->build() ? $stmt->fetch_assoc() : []; } public function get(int $id): array|false { if (!$id) return false; return $this->db->select("t.*, COALESCE(tl.name, t.name) AS name") ->from("widgets AS t") ->join("LEFT", "widgets_lang AS tl", "tl.owner_id = t.id AND tl.lang = '" . Language::selected() . "'") ->where("t.id", "=", $id) ->limit(1) ->build() ? $this->db->getAssoc() : false; } public function add(array $data = []): int { return $this->db->insert("widgets", $data) ? $this->db->lastID() : 0; } public function update(int $id = 0, array $data = []): bool { return (bool) $this->db->update("widgets", $data)->where("id", "=", $id)->save(); } } ``` The caller never sees a query, and the two shapes above are exactly what it branches on. ```php $total = $this->model->list(true, ['word' => $search]); // int $rows = $this->model->list(false, ['word' => $search], ['id' => 'DESC'], 0, 25); $widget = $this->model->get($id); if ($widget === false) return $this->page_404(); // false means "no such record" ``` ### Pitfalls > **Bind values, never concatenate them** > > Conditions take their values as arguments so the driver binds them. A value pasted into the condition string, or into a join clause, is an injection waiting for the first unusual input. > **The fourth argument of where() looks forward** > > It is not the operator that attaches this condition to the previous one. It attaches it to the next. Writing the operator on the wrong condition of an alternation quietly changes which rows come back, and nothing fails. > **Select the columns you need** > > A getter that selects everything hands its callers whatever the table happens to contain. That includes columns added later that were never meant to leave the database. If such a value can reach a response, filter it at the boundary where it is written out. > **Business rules are not model methods** > > A model answers what is stored. What should happen because of it belongs in a helper. The panel, the client area, the API and the scheduler can all reach it there. ### Related Articles - [Querying with WDB](https://dev.wisecp.com/en/querying-with-wdb) - [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) ## Operations https://dev.wisecp.com/en/operations Every change to data goes through an operation. That is why permissions, demo mode, hooks and error formatting are decided once instead of in every screen. ### Overview An operation is a method that a request can name. When a request carries an operation name, the controller builds no page. It checks the privilege attached to that operation, calls the method, and turns whatever happens into a JSON response. The method itself is written in a trait, so a controller gains a family of related operations by using one. The controller declares which operations it accepts and what each one requires. ### Structure - **coremio/operations/**: One trait per family. The controller uses the trait, and its methods become the controller's own. - **Registration**: The controller lists the operations it accepts, each with the privileges it needs. Registration is what attaches a privilege; it is not what makes a method reachable. - **The operation object**: Constructed by the dispatcher and handed to your method. It carries the operation name, the registered properties, the demo guard, the hook helper and the response. #### What the Dispatcher Does For You ```php // operation(): accepted when the name is REGISTERED or simply EXISTS as a method $isOperation = isset($operations[$name]); $isMethod = method_exists($this, $name); $properties = $operations[$name] ?? []; if ($isOperation || $isMethod) return $this->run_operation($name, $properties); // Neither: modules get the last word, and an array answer becomes the response. foreach (Hook::run("register:admin.operations", Controllers::$cname, $name) as $hfn) if ($hfn && is_array($hfn)) { echo Utility::jencode($hfn); return true; } // Still nothing: {"status":"error","message":"Undefined operation: "} // run_operation(), in order: // 1. privileges from the registration -> Admin::isPrivilege(), else throw // 2. method_exists($this, $method), else throw // 3. $this->$method(new Operation($name, $properties)); // 4. catch (Exception $e) -> {"status":"error","message":$e->getMessage()} ``` The last step is the one that changes how you write. You do not build error responses; you throw, and the message you throw is what the caller reads. ### Reference #### The Operation Object ```php public ?string $name = ''; // this operation's registered name public static ?string $last = ''; // the last operation constructed in this process public function __construct($name = '', $properties = []); public function demo(): void; // throws in demo mode public function hook(string $name = '', array $vars = []): array; // ['overwrite' => array] | ['output' => mixed] | [] public static function output($response): bool; // always returns true public static function name(): ?string; // reads $last, NOT $this->name ``` - **demo()**: Throws when the installation runs in demo mode, so the dispatcher answers with the standard refusal. The first line of every operation that writes. - **output()**: An array is sent as JSON with a content type header and pretty, unescaped encoding; a non-array is echoed as is. A *falsy* response writes nothing at all and still returns true, so `output([])` answers with an empty body the caller cannot parse. Static, but called through the instance by convention, and returned so the method stops there. - **hook()**: Fires an extension point and hands back what listeners returned. See the contract below for the accepted names and the shape of the answer. - **name()**: Static, and it reads the process-wide `$last`, not the instance. Inside an operation the two agree; from a listener that runs later, prefer the name the hook already passes you. #### The Hook Contract Two names are shorthand for the shared extension points, and anything else is used verbatim. Listeners always receive three arguments: the controller name, the operation name and the variables you passed. ```php // Inside the operation. "before" => filter:admin.operation.before // "after" => filter:admin.operation.after $hook = $operation->hook('before', get_defined_vars()); if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]); // listener changed your locals if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]); // In a module's hooks.php. The listener decides by returning ONE of three shapes: Hook::add('filter:admin.operation.before', 1, function ($controller, $operation, $vars) { if ($controller !== 'widgets' || $operation !== 'save_widget') return null; // 1. refuse: hook() throws with this message, run_operation turns it into the error JSON if (!$vars["name"]) return ['status' => "error", 'message' => "Name is required"]; // 2. rewrite: these become local variables in the operation, via extract() if ($vars["name"] === "x") return ['overwrite_vars' => ['name' => "X"]]; // 3. answer: the operation returns this instead of doing the work return ['output' => ['status' => "successful", 'id' => 0]]; }); ``` - **filter:admin.operation.before**: Fired after input is read and validated, before anything is written. The usual place to refuse or to rewrite the values. - **filter:admin.operation.after**: Fired once the work is done and the response array is built, so a listener can replace the response the caller sees. - **register:admin.operations**: The fallback for a name that is neither registered nor a method. A listener returning an array answers the request itself, which is how a module adds an operation to a core screen. - **filter:api.response**: Fired by `output()` on every array response, by reference, with the operation name as the second argument. The last chance to add or strip a field. #### The Response Success and failure share one field, so a caller can branch on `status` alone. The error shape is produced for you by the dispatcher. The success shape is whatever you pass; by convention it carries the same field plus what the screen needs. ```json { "status": "successful", "id": 42, "message": "Changes saved" } { "status": "error", "message": "Name is required" } ``` ### Example ```php namespace WISECP\Operations; use Exception; use Filter; use Language; use Operation; trait AdminWidgets { public function save_widget(Operation $operation): bool { // Demo mode refuses every write; this is always the first line. $operation->demo(); $id = (int) Filter::init("POST/id", "rnumbers"); $name = Filter::init("POST/name", "hclear"); if (!$name) throw new Exception(Language::gc("widgets/error-name-required")); $hook = $operation->hook('before', get_defined_vars()); if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]); if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]); $saved = $id ? $this->model->update($id, ['name' => $name]) : $this->model->add(['name' => $name]); if (!$saved) throw new Exception(Language::gc("widgets/error-save-failed")); $response = ['status' => "successful", 'id' => (int) ($id ?: $saved)]; $hook = $operation->hook('after', get_defined_vars()); if ($hook && $hook["overwrite"] ?? []) extract($hook["overwrite"]); if ($hook && $hook["output"] ?? false) return $operation->output($hook["output"]); return $operation->output($response); } } ``` The other side is an ordinary form post to the controller's own address, with the operation name as a field. ```bash curl -X POST "$PANEL/widgets" \ -H "X-Requested-With: XMLHttpRequest" \ -d "operation=save_widget&id=42&name=Sidebar" # {"status":"successful","id":42} # {"status":"error","message":"You don't have privileges to access this operation."} ``` ### Pitfalls > **The demo guard is not optional** > > Without it, a demo installation performs the write and only looks like it refused. It goes first, before any reading or validation, so nothing can slip past it. > **A method with no registration has no privilege** > > The dispatcher runs a method that merely exists, with an empty property array. An empty privilege list means the check is skipped entirely. Every public method a controller gains from a trait is an entry point. Register it, or do not put it on the controller. > **Throw translated messages** > > The message you throw is shown to whoever made the request. A literal string means the installation answers everyone in one language. > **Return the output, do not only call it** > > `output()` writes the response and returns true; it does not stop the method. Anything after an unreturned call still runs and can print a second body after the JSON. ### Related Articles - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Adding a Custom Operation](https://dev.wisecp.com/en/adding-a-custom-operation) # Platform Foundations / Core APIs ## Filtering User Input https://dev.wisecp.com/en/filtering-user-input Every value that arrives from a browser is read through `Filter`. It cuts the value down to the characters the receiving code expects, before anything else sees it. ### Overview WISECP never reads `$_POST`, `$_GET` or `$_REQUEST` directly. One class takes a path into the superglobal plus a filter name. It answers the value already reduced to what that filter allows. Filtering is half of input safety. Validation is the other half: filtering removes characters, validation decides whether what is left is usable. A filtered but meaningless value still has to be rejected by your own check. ### Reference #### Signatures ```php public static function init($arg = null, $mod = false, $special = false); public static function GET($arg = ''); public static function POST($arg = ''); public static function REQUEST($arg = ''); public static function FILES($arg = ''); public static function SERVER($arg = ''); public static function html_clear($arg = null, $allow = ''); public static function phone($arg = ''); public static function route($arg = '', $special = ''); public static function permalink($str, $options = []); ``` Everything here is static. Most names in the table below are also methods of their own with the same two arguments. `Filter::rnumbers($value)` does what `init` does after resolving the path. Three are different. There is no `hclear` method, because that name calls `html_clear`. `false` is the absence of a filter, and `domain` takes the value alone. #### The Three Arguments of init - **$arg**: A source path such as `POST/user/name`, or a plain value. A string that starts with none of the five source names is filtered as it stands, so `Filter::init($text, "hclear")` is legal. An empty first argument answers `false`. - **$mod**: The filter name, one of the nineteen in the table. Default `false` returns the value untouched, and so does an unrecognised name. - **$special**: Extra characters merged into the filter's allow list, as regular expression syntax. `a-z` is a range, and a literal hyphen or slash needs escaping. With `$mod` left at `false` it becomes the whole allow list, applied after markup is stripped. #### Source Paths The first path segment names the superglobal, the rest walks into it. `POST/user/name` reads the `name` key inside the `user` array of the request body. The same five accessors are public and return the raw value, unfiltered. With no argument they return the whole superglobal; with a missing key they return `false`, never `null`. - **Filter::GET($arg)**: Query string. Path prefix `GET/`. - **Filter::POST($arg)**: Request body. Path prefix `POST/`. - **Filter::REQUEST($arg)**: Either source. Path prefix `REQUEST/`, and how an operation name is read. - **Filter::FILES($arg)**: Upload entry for a field name, as PHP built it. Path prefix `FILES/`. - **Filter::SERVER($arg)**: Server variable such as `REMOTE_ADDR`. Path prefix `SERVER/`. #### Filter Types The last column is what you get when the key is not in the request. That answer differs by filter. | Filter | Keeps | Returns | Key absent | | --- | --- | --- | --- | | `false` | The value untouched, markup included | mixed | `false` | | `hclear` | Tags stripped after entities are decoded | string | empty string | | `text` | Same, then both quote characters become numeric entities | string | empty string | | `dtext` | Same as `hclear`, quotes left alone | string | empty string | | `letters_numbers` | Latin letters and digits | string | `false` | | `letters` | Latin letters plus the active language's letters | string | empty string | | `numbers` | Digits and hyphen | string | `false` | | `rnumbers` | Digits and hyphen, then cast to a whole number | int | `0` | | `amount` | Digits, hyphen, dot, comma; separators kept as typed | string | empty string | | `rate` | Same, comma read as the decimal point, thousands dots dropped | float | `0.0` | | `ip` | Letters, digits, hyphen, dot, colon, so v6 survives | string | `false` | | `domain` | Letters, digits, dot, hyphen, then lowercased | string | empty string | | `email` | Letters, digits and the characters `@ . + _` | string | empty string | | `route` | Letters, digits, hyphen, underscore, dot; `../` removed first | string | empty string | | `folder` | Letters, digits, slash, hyphen, underscore, dot | string | empty string | | `file` | Letters, language letters, digits, hyphen, underscore, dot | string | empty string | | `noun` | Letters, digits, comma, dot, space | string | empty string | | `identity` | Digits and hyphen, same rule as `numbers` | string | `false` | | `password` | Everything, unchanged | mixed | `false` | `password` is deliberately a pass-through. A strong password is made of exactly the characters the other filters remove. Stripping them silently changes what the visitor typed. `hclear`, `text`, `dtext`, `email`, `domain`, `identity` and `password` ignore the third argument. #### Helpers You Call Directly - **html_clear($arg, $allow)**: Decodes entities, then strips tags. The second argument is the allow list in PHP's own format, for example `''`. This is the only way to keep tags: `hclear` never passes one. - **phone($arg)**: Digits only: a country prefix, spaces and brackets disappear. One argument, no allow list. - **route($arg, $special)**: Removes `../`, then keeps letters, digits, hyphen, underscore and dot. Add the escaped slash through the second argument when a route segment carries one. - **permalink($str, $options)**: The slug maker. Options, with their defaults: `delimiter` (a hyphen), `limit` (none), `lowercase` (true), `replacements` (patterns applied first, empty) and `transliterate` (true). ### Example Field names are the contract between the two sides. What the form posts is what the path spells. ```html ``` ```php public function update_client(Operation $operation): bool { $operation->demo(); $id = (int) Filter::init("POST/id", "rnumbers"); $name = Filter::init("POST/user/name", "hclear"); $email = Filter::init("POST/email", "email"); $pass = Filter::init("POST/password", "password"); $slug = Filter::init("POST/slug", "route", "\/"); // Filtering is not validation: these are clean, not necessarily usable. if (!$id) throw new Exception(Language::gc("error/id-required")); if ($name === '') throw new Exception(Language::gc("error/name-required")); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(Language::gc("error/email-invalid")); $bio = Filter::html_clear(Filter::POST("bio"), ''); return $operation->output(['status' => "successful"]); } ``` ### Pitfalls > **A misspelled filter name returns the raw value** > > The name is matched against a list and anything unmatched falls through to the untouched value, markup and all. `Filter::init("POST/v", "number")` is not a numeric read, it is no read at all. Writing the allow list into the second slot instead of the third does the same: `Filter::init("POST/v", "a-z")` filters nothing. > **An absent key does not answer the same way twice** > > The five accessors always answer `false`, so a presence test is written `!== false`. Through `init` the answer belongs to the filter, as the table's last column shows. A test written as `!== ''` or `!== null` is true on every request for some of them. > **The third argument is regular expression syntax** > > It is pasted into a character class exactly as written. It can widen the filter far past what you meant, and a stray bracket breaks the pattern. Escape what must be literal, and never build it from user input. > **Array values do not survive a scalar filter** > > Only `password` and the unfiltered read pass an array through. The rest answer `false`, an empty string or zero depending on the filter. A grouped field is read key by key rather than as one value. ### Related Articles - [Operations](https://dev.wisecp.com/en/operations) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Coding Conventions](https://dev.wisecp.com/en/coding-conventions) ## Utility Helpers https://dev.wisecp.com/en/utility-helpers Small static functions the whole codebase leans on. Several exist because the PHP builtin they replace gives the wrong answer here. ### Overview Most of these wrap a PHP builtin that is wrong here. The raw function ignores the configured encoding, breaks on Turkish, or produces JSON the rest of the system cannot read back. Everything below is static, and a failure is a falsy return. Only the request helper says why, in `Utility::$error`. ### Reference #### Calling a Remote Service ```php public static function HttpRequest($url = '', $params = [], $retry = 0); ``` ```php // Preferred: one options array as the FIRST argument. $body = Utility::HttpRequest([ 'url' => 'https://api.example.com/v1/ping', 'type' => 'POST', 'data' => ['a' => 1], 'header' => ['Accept: application/json'], ]); // Legacy: address first, a smaller options array second. $body = Utility::HttpRequest('https://api.example.com/v1/ping', ['post' => ['a' => 1]]); ``` Returns the response body as a string, or `false` when the transfer failed. The third argument is the internal redirect counter, never passed by a caller. Options: | Key | Type | Default | What it does | | --- | --- | --- | --- | | `url` | string | required | Target address. A query string belongs here, not in `data`. | | `type` | string | `GET` | Method, uppercased. Anything but GET goes as a custom request. | | `data` | array or string | none | Request body. An array is form encoded; a string goes verbatim, which is how JSON is sent. Ignored on GET. | | `header` | array | empty | Whole header lines, for example `'Authorization: Bearer ...'`. | | `timeout` | int | `30` | Seconds allowed for the whole transfer. | | `connect_timeout` | int | `10` | Seconds allowed for the connection phase alone. | | `ssl_verify` | bool | `true` | Verifies the peer and the host name. Turn off only against a service you control. | | `allow_ipv6` | bool | `false` | While false, resolution is forced to v4; a host without v6 routing blocks until the system timeout. | - **HttpRequest()**: The legacy shape reads five keys only: `post`, `timeout` (default 5), `connect_timeout`, `ssl_verify` (default false) and `allow_ipv6`. It sends no headers and switches to POST as soon as `post` is present. - **Utility::$error**: Public and static. After a `false` it carries the transport message, a name resolution failure for example. This method is its only writer, so a stale value survives every other failure. Read it before the next call overwrites it. - **filter:http.request**: Fires with both shapes already normalised. A listener can rewrite the address, method, body, headers, timeouts and verification of every outbound call. The options change by reference; the return is not used. #### JSON ```php public static function jencode($string = '', $flags = 0): string|false; public static function jdecode($string = '', $mode = false); ``` - **jencode()**: Your flags join three that are always on: invalid byte sequences dropped, unicode unescaped, slashes unescaped. Answers `false` for a value that cannot be encoded. - **jdecode()**: Second argument true gives an associative array, false an object tree. An array handed in while true comes back untouched, so a value decoded upstream survives the call. An empty string answers `null`. #### Text ```php public static function strtoupper($str, $encode = 'utf8'); public static function strtolower($str, $encode = 'UTF-8'); public static function ucfirst($string, $encoding = 'utf8'); public static function strlen($str, $charset = 'utf-8'); public static function substr($str = '', $start = 0, $end = -1, $charset = 'utf-8'); public static function short_text($text = '', $start = false, $end = false, $points = false, $charset = 'UTF-8'); ``` - **strtoupper()**: Multibyte uppercase. The dotted capital appears only while the active language is Turkish. - **strtolower()**: The mirror image, mapping both capitals back under the same condition. - **ucfirst()**: First character uppercased, rest untouched. - **strlen()**: Characters, not bytes. Null counts as zero instead of raising a warning. - **substr()**: The third argument is a length, not an end offset; its default of minus one drops the final character. Pass `null` to read to the end. - **short_text()**: Offset plus length; the fourth argument appends three dots when the original was longer. With no length it returns the whole string. #### Addresses ```php public static function AppAdress($prefix = false); public static function RequestURI(); public static function redirect($url = '', $time = 0): void; public static function getReferer($internal_only = false); public static function seo_url(string $url, array $allowed = ['page']): string; public static function image_link_determiner($arg = '', $prefix = '', $rseparator = true); ``` - **AppAdress()**: The installation's base address, resolved once per request. Pass true to append the routing and language prefix, which a link inside a page needs. - **RequestURI()**: The current path with the installation subfolder removed and a leading slash guaranteed. - **redirect()**: Sends the location header, or a refresh header when the second argument is above zero. It does not stop execution. - **getReferer()**: The referring address with markup stripped, or `null`. Pass true to reject anything not from this installation. - **seo_url()**: Not a slug maker. It rebuilds an address keeping only the query keys you allow. A canonical or og tag cannot then inherit what a visitor appended. A value with no host comes back unchanged. - **image_link_determiner()**: Prepends the folder and the base address to a stored file path. A value that already carries a scheme is untouched. #### The Visitor's Address ```php public static function resolve_client_ip(array $headers = []): string; public static function trusted_proxies(): array; public static function forwarded_header_ignored(): bool; public static function ip_in_ranges(string $ip, array $ranges): bool; public static function cidr_match(string $ip, string $cidr): bool; ``` - **resolve_client_ip()**: The address to record, block or rate-limit. A forwarded header is believed only when the request arrived from a listed proxy range; otherwise the TCP peer wins. No usable peer answers an empty string, so keep your own fallback. - **trusted_proxies()**: The ranges whose forwarded headers may be believed. With no proxy configured, nothing matches and no header is read. - **forwarded_header_ignored()**: True when a header claimed a different address and was refused. Two causes: an unlisted proxy, or something sending the header for no reason. - **ip_in_ranges()**: Exact match or CIDR membership against a list of ranges. - **cidr_match()**: Binary-safe CIDR test for IPv4 and IPv6. A version mismatch answers false, it does not raise. #### Data and Files ```php public static function array_export($array = [], $options = []); public static function xdecode($xml_string = '', $returnArray = false); public static function array_to_xml(array $data, string $root = 'data'): string; public static function generate_hash($length = 9, $add_dashes = false, $available_sets = 'luds'); public static function sizeFormat(int $bytes, string $emptyPlaceholder = "\u{2014}"): string; ``` - **array_export()**: Writes an array as PHP source. One option key, `pwith`: true wraps the output as a complete file that returns the array, which is how configuration is saved. The result is evaluated first, and a stricter export replaces it if it does not come back as an array. - **xdecode()**: XML to an object tree, or an array when the second argument is true. Invalid input answers `false`. The root element becomes the single outer key. - **array_to_xml()**: The other direction, indented and escaped. Numeric keys become item elements; an unusable key is rewritten into a legal element name. - **generate_hash()**: The third argument selects character sets by letter: `l` lowercase, `u` uppercase, `d` digits, `s` symbols; look-alike characters are excluded. The second groups the result with hyphens, which lengthens it. - **sizeFormat()**: A byte count as a person reads it, one decimal above the byte unit. Zero or less answers the placeholder, a dash unless you pass one. ### Example The call and the read back, together. ```php public function create_account(string $domain, string $plan): array { $body = Utility::jencode(['domain' => $domain, 'plan' => $plan]); $response = Utility::HttpRequest([ 'url' => 'https://api.example.com/v1/accounts', 'type' => 'POST', 'data' => $body, // a string is sent verbatim 'header' => [ 'Authorization: Bearer ' . $this->config['token'], 'Content-Type: application/json', ], 'timeout' => 20, ]); // Transport failure: nothing was parsed, the reason is on the class. if ($response === false) throw new Exception('Provider unreachable: ' . Utility::$error); $result = Utility::jdecode((string) $response, true); // Protocol failure: we got an answer, it is not the one we need. if (!is_array($result) || !isset($result['id'])) throw new Exception('Unexpected response from the provider.'); return [ 'id' => $result['id'], 'quota' => Utility::sizeFormat((int) ($result['quota_bytes'] ?? 0)), ]; } ``` ### Pitfalls > **The two request shapes do not share defaults** > > The legacy form turns certificate verification off and drops the transfer timeout from thirty seconds to five. Writing the address as the first argument out of habit disables verification silently. Use the options array. > **A redirect retry keeps only the address** > > When a location line appears in the body the call re-issues itself with that address and the legacy defaults. Method, body and headers are not carried over, so an authenticated POST comes back as an anonymous GET. Follow such a response yourself. > **Case conversion follows the session, not the value** > > The Turkish letter mapping applies only while the active language is Turkish, so the same input uppercases differently in two sessions. Never build a stored key, a comparison key or a file name that way. Use it for what a person will read. > **The XML reader loses attributes and changes shape** > > An element with text inside it drops its attributes entirely, and repeated siblings collapse into one key. A response with one item is a string; the same response with two is a list. Parse a provider whose answer depends on either yourself. ### Related Articles - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Reading and Writing Configuration](https://dev.wisecp.com/en/reading-and-writing-configuration) - [Building Links and Routes](https://dev.wisecp.com/en/building-links-and-routes) - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) ## Reading and Writing Configuration https://dev.wisecp.com/en/reading-and-writing-configuration Settings live in two places with different rules. PHP files are loaded on demand, and database rows hold values that do not belong in a file. ### Overview A configuration file is a PHP file that returns an array. Reading one is a path expression rather than a file operation. You name the file, then walk into the array with slashes. The file is loaded the first time something asks for it. The second store is a table, for values that are per-installation state rather than configuration a developer ships. Both are reached through the same class with different method names, and choosing the wrong one is the mistake worth avoiding. ### Reference #### File-Based Settings ```php public static function get($arg = null); public static function set($key, $values, $merge = false): array|false; public static function save($name = '', $data = []): bool; public static function merge($arg = []): bool; ``` - **get($arg)**: The first path segment is the file, the rest walks into the array it returns. Any segment that is missing answers `false`, so a stored false and an unset key look the same. With no argument you get every file loaded so far, which is not every file that exists. - **set($key, $values, $merge)**: Changes the loaded copy for this request and writes nothing. First argument is the file name, second the values. Answers `[$key => $merged]`, or `false` when the values are empty or the file does not exist. - **save($name, $data)**: Writes the array back to its file and refreshes the loaded copy. A later read in the same request sees the new value. With no second argument it saves whatever is currently in memory for that file. Answers whether the write succeeded. - **merge($arg)**: Folds whole entries into the loaded set for this request, so its keys are file names, not settings. Useful for a virtual file that has no counterpart on disk. - **coremio/configuration**: Where the files live, one per name: `general`, `options`, `theme`, `crypt`, `sms` and the rest. #### What save Expects and Writes A file is a returned array and may be wrapped in its own name or not. Both shapes are accepted. If the file on disk is wrapped and your data is not, it is wrapped for you before writing. ```php 'on', 'rich-url' => 'on', ]; ``` ```php Config::save("general", ['cache' => 'on', 'rich-url' => 'on']); Config::save("general", ['general' => ['cache' => 'on', 'rich-url' => 'on']]); ``` The source is generated by the array exporter and written through the file manager. That manager invalidates the compiled copy of the file it replaced. A raw write has to do that itself. #### Database-Backed Settings ```php public static function getd($name = ''); public static function setd($name = '', $content = ''); public static function deld($name = ''); ``` - **getd($name)**: Reads one named row. A stored value that is valid JSON comes back decoded. An array is an array again, and a stored number is a number. That holds only while the decoded value is truthy. A stored zero, an empty array and an empty object come back as the written text: `0`, `[]` and `{}`. A row that does not exist answers `null`. - **setd($name, $content)**: Inserts or updates by name. An array is encoded for you; anything else is stored as given. A new row is stamped with a created and an updated time; an update touches the updated one only. - **deld($name)**: Removes the row. There is no soft delete, and a later read answers `null`. #### Choosing a Store | Value | Store | Why | | --- | --- | --- | | A switch an operator sets once | File | Read on almost every request; a file read is cheaper than a query | | Credentials for a service | File, in the module's own file | Travels with the module and is not scattered across tables | | State the application updates itself | Database | Written often, and a file write is not the right tool for that | | Something one operator prefers | Neither | Per-person preference belongs to that person, not to the installation | ### Example Read the file, change the value, write it, and read it back in the same request. ```php public function save_general(Operation $operation): bool { $operation->demo(); $data = Config::get("general"); // the whole file $data['cache'] = Filter::init("POST/cache", "route") === 'on' ? 'on' : 'off'; if (!Config::save("general", $data)) throw new Exception(Language::gc("error/settings-not-written")); // Same request, and the value is already the new one. $now = Config::get("general/cache"); // State the application owns goes to the row store instead. Config::setd("last_settings_change", ['at' => date('c'), 'cache' => $now]); return $operation->output(['status' => "successful", 'cache' => $now]); } ``` ```php $state = Config::getd("last_settings_change"); // array, decoded for you if ($state === null) { // Never written, or removed. Not the same as "written as empty". } ``` ### Pitfalls > **The third argument of set does the opposite of its name** > > Left at its default it replaces recursively, so siblings inside a nested key survive. Passing true merges at the top level only, which replaces a nested array whole and drops the keys you did not mention. Changing one key under a group is the default behaviour, not the merging one. > **Changing a value in memory does not persist it** > > The setter and the saver are separate on purpose, so a request can override a setting for itself without rewriting the file. If a change has to survive the request, save it. Saving with no data argument commits whatever the setter left in memory. > **A configuration file is compiled PHP** > > The server can keep a compiled copy and go on serving it after the file changes. A save then looks successful, and the old value comes back on the next page. Write through the file manager, which invalidates that copy; a raw write has to do it itself. > **Configuration is installation-wide** > > There is one value for everyone. Storing a personal preference here means the first operator who changes it changes it for every other operator too. That kind of value belongs in a cookie or on the user record. ### Related Articles - [Utility Helpers](https://dev.wisecp.com/en/utility-helpers) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Caching](https://dev.wisecp.com/en/caching) - [Setting Up a Development Environment](https://dev.wisecp.com/en/setting-up-a-development-environment) ## Building Links and Routes https://dev.wisecp.com/en/building-links-and-routes Addresses are generated from a route key, never written out, because every route is translated and the panel directory is not fixed. ### Overview A route key is a name; the address it produces depends on the language and on the installation. A literal path works in one language and answers 404 in the others. A panel link that hardcodes the admin directory breaks on any installation that renamed it. Four static methods cover every case, all of them in `coremio/classes/LinkGenerator.php`. ### Reference ```php public static function admin($route = '', $params = [], $lang = '', $wqs = []): string|bool; public static function client($route = '', $params = [], $lang = ''); public static function wQS(null|string|bool $url, string|array $params = []): string; public static function convert_to_link(string $arg): string; ``` - **admin()**: A panel address. The only one with a fourth argument. That makes it the only one that can append a query string on its own. - **client()**: A website or client-area address. Three arguments, no query string; wrap the result in `wQS()` when you need one. - **wQS()**: Appends a query string to an address that already exists, choosing between `?` and `&` itself. The second argument must be an array in practice. - **convert_to_link()**: Resolves a stored reference such as `pages/12` into a full address. This is what makes a menu entry or an editor link portable. Results are memoized per request and language. #### The Four Arguments | Position | Argument | What it must be | | --- | --- | --- | | 1 | `$route` | A key from the route table, not a path. An unknown key is not rejected, it is pasted into the address as is. | | 2 | `$params` | Positional path segments. Each value fills the next `(?)` in the key's pattern, in order; leftovers are dropped silently. A non-array value is wrapped into a one element list. | | 3 | `$lang` | `''` uses the language being served. A code such as `tr` forces that language, and the literal `none` produces an address with no language segment at all. | | 4 | `$wqs` | Query string as an associative array, applied only when it is non-empty. Present on `admin()` only. | #### A Route Key Carries Its Segment Count The router fills one `(?)` per parameter and stops. A key whose pattern has no placeholder ignores every parameter you pass. That is why the list and the detail address are two different keys rather than one key with arguments. ```php // coremio/locale/en/admin-routes.php - key => [address pattern, controller route] return ['admin-routes' => [ 'services' => ['services', 'services'], 'services-1' => ['services/(?)', 'services/(1)'], 'services-2' => ['services/(?)/(?)', 'services/(1)/(2)'], ]]; // LinkGenerator::admin("services", ["detail", 5]) -> /services both values dropped // LinkGenerator::admin("services-1", ["detail"]) -> /services/detail // LinkGenerator::admin("services-2", ["detail", 5]) -> /services/detail/5 ``` - **locale/{lang}/admin-routes.php**: Panel keys. One file per language, so the same key produces a different path in each. - **locale/{lang}/website-routes.php**: Website and client-area keys, same shape. #### What convert_to_link Accepts | Form | Example | Resolves to | | --- | --- | --- | | Site page key | `home`, `contact`, `basket` | That website route in the active language. | | Client-area key | `ca-invoices`, `ca-domains`, `ca-tickets` | That client-area route. | | Record reference | `pages/12`, `products/48`, `kbase-page/7` | The record's detail address, looked up from its translated slug. | | Category reference | `category/25` | The catalogue, article, reference or knowledge base listing, chosen from the category's own type. | | Product group | `product-group/hosting` | That group's catalogue page. | | Anything else | `whatever` | The string `#`, so a broken reference appears as a dead link instead of an error. | ### Example ```php // A list page: no parameters, no query string. $list = LinkGenerator::admin("services"); // A detail page. The record id travels as a query string, so the fourth // argument is used and the third stays empty. $detail = LinkGenerator::admin("services-1", ["detail"], '', ['id' => $serviceId]); // The same page opened on a tab. $tab = LinkGenerator::admin("services-1", ["detail"], '', ['id' => $serviceId, 'tab' => "invoices"]); // A website address forced into one language, for an alternate-language tag. $signIn = LinkGenerator::client("sign-in", [], "tr"); // client() has no query string argument, so wrap it. $paged = LinkGenerator::wQS(LinkGenerator::client("services"), ['page' => 2]); // A stored reference from a menu row, resolved at render time. $href = LinkGenerator::convert_to_link($row["page"]); // "pages/12" -> /about-us ``` ```php // The second element of the entry is the controller route the address maps back to, // which is why the key, not the path, is the thing code is allowed to know: // // 'services-1' => ['services/(?)', 'services/(1)'] // ^ written by ^ read by the router, which dispatches // LinkGenerator services::page_detail() class Services extends Controllers { public function page_detail(): void { $id = (int) Filter::init("GET/id", "rnumbers"); $this->addData("back", LinkGenerator::admin("services")); } } ``` ### Pitfalls > **The query string is the fourth argument, the third is the language** > > Writing `LinkGenerator::admin("services-1", ["detail"], ['id' => 5])` puts the array where the language code belongs. Nothing is thrown. You get an `Array to string conversion` warning and the address `/Array/{admin}/services/detail`. The path segments in it still resolve, and the id you meant to send is nowhere. Pass `''` for the language and keep the query string in position four. > **An unknown key becomes the address** > > The router returns the key itself when it is not in the table. A typo then produces a plausible looking address that answers 404, not an error at the point of the mistake. The same happens when a key exists but has fewer placeholders than the parameters you passed. The extra values vanish there. > **wQS takes an array, whatever the type says** > > The declared type of the second argument allows a string. The value is still handed to `http_build_query()`, which rejects anything that is not an array or object with a TypeError. Never concatenate a question mark by hand either: the generated address may already carry parameters, and you would produce a second one. ### Related Articles - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) - [Menus and Navigation](https://dev.wisecp.com/en/menus-and-navigation) - [Utility Helpers](https://dev.wisecp.com/en/utility-helpers) ## Translations and Language Files https://dev.wisecp.com/en/translations-and-language-files Nothing a person reads is written in code. Text lives in language files, is looked up by key, and a missing key answers false rather than the text you expected. ### Overview An installation serves more than one language at once, chosen per visitor. A literal string in a condition, an exception or a template is not a shortcut. It is text one group of users will never see in their own language, and that no translator can reach. Two lookups cover everything: one for the root language files, one for the per-screen files under `cm/`. Both walk a slash separated path into nested arrays. ### Reference ```php public static function g($key = '', $replaces = [], $slang = ''): string|int|array|bool; public static function gc($name = '', $replaces = [], $slang = ''): string|int|array|bool; public static function selected(): string; // Instance, reached through the singleton: Language::$init->rank_list() public function rank_list($status = 'active'): array; // The instance methods the two static helpers delegate to. Note the argument // order: get() puts the language second, get_cm() puts the replacements second. public function get($arg = null, $slang = '', $replaces = []): string|int|array|bool; public function get_cm($arg = null, $replaces = [], $slang = ''): string|int|array|bool; ``` - **Language::g()**: A key from a root language file. Returns the text, or `false` when any segment of the path is missing. - **Language::gc()**: A key from a per-screen file under `cm/`. Use this in helpers, modules and exceptions, since it names its file rather than relying on what the current screen loaded. - **Language::selected()**: The language code being served, such as `en`. Falls back to the installation's own language when the language layer is not up yet. That makes it safe on the command line. - **Language::$init->rank_list()**: The installation's languages in display order. An instance method: there is no static `Language::rank_list()`. #### Which File Each Lookup Reads | Call | Reads | Path shape | | --- | --- | --- | | `Language::g("needs/button-save")` | Root language files: `needs`, `date`, `errors`, `actions`, `package` | `{file}/{key}/{subkey}` | | `Language::gc("admin/services/page-list")` | Per-screen files under `cm/`, grouped by area | `{area}/{file}/{key}`, area being `admin`, `website` or `system` | | `Language::gc("theme/hero-title")` | The active theme's own language file | `theme/{key}` | Every slash is one array level, not part of a name. A flat key written as `'job-title/daily'` is never found; it has to be a nested array. #### Placeholders and the Replacement Map The second argument maps the **literal placeholder text**, braces or colon included, to its replacement. It is a plain string replacement, so the keys must match the file byte for byte. ```php // coremio/locale/en/cm/admin/departments.php return [ 'error1' => 'Name is required for {lang}.', // The content/variables shape declares which placeholders exist. Lookups // return the content string, never the wrapper. 'welcome' => [ 'content' => 'Hello {name}, you have {count} messages.', 'variables' => '{name},{count}', ], ]; // The call. Keys carry their own braces; ':name' style keys work the same way. $msg = Language::gc("admin/departments/error1", ['{lang}' => "TR"]); // "Name is required for TR." $hi = Language::gc("admin/departments/welcome", ['{name}' => $name, '{count}' => 3]); // Third argument forces one language instead of the one being served. $tr = Language::gc("admin/departments/error1", ['{lang}' => "TR"], "tr"); ``` #### What the Language List Returns The row shape depends on the argument, so the two cannot be swapped. `active` returns display rows built for a switcher. `all` returns the raw package record of every installed language, including the disabled ones. ```php // Language::$init->rank_list() enabled languages, ordered by rank [ 'rank' => 2, 'local' => 0, 'selected' => true, 'key' => 'en', 'name' => 'English', 'global-name' => 'English', 'link' => 'https://example.test/en/services', // this page in that language 'cc' => '', 'cname' => 'Worldwide', 'pc' => 1, 'flag-img' => 'https://example.test/resources/assets/images/flags/en.svg', ]; // Language::$init->rank_list("all") every installed language, package record // The whole record, in this order. 'key' and 'country-name' are added by the call; // the rest is the package file as it sits on disk. [ 'create-date' => '2018-06-21 10:15:48', 'name' => 'English', 'show-name' => 'English', 'country-id' => 0, 'country-code' => '', 'code' => 'en', 'code-hyphen' => 'en_US', 'scharacters' => '', 'charset-code' => 'UTF-8', 'phone-code' => 1, 'currency' => 1, 'rank' => 2, 'permalink' => true, 'prefix' => 'enabled', 'status' => true, 'local' => false, 'rtl' => false, 'key' => 'en', 'country-name' => 'Worldwide', ]; ``` #### Text That Carries a Number ```php public static function plural(string $key, int|float $n, array $replaces = [], string $slang = ''): string; public static function plural_set(string $key, string $slang = ''): array; public static function plural_category(int|float $n, string $slang = ''): string; ``` - **plural()**: Picks the form for a count. Never choose it yourself with `n === 1` — that holds only for two-form languages. Russian wants three forms, Arabic six. - **plural_set()**: Every form as a map, for a count only the browser knows. The server sends the set and `wcpPlural()` chooses in the page. - **plural_category()**: The category a number falls into: `one`, `few`, `many`, `other` and so on. #### Forms With a Language Strip ```php public static function form_langs(array $defined = []): array; public static function posted_langs(): array; public static function removed_langs(): array; ``` - **form_langs()**: The languages a record's strip opens with: the primary one, plus whatever that record already carries. - **posted_langs()**: The languages the submitted form declared. Walk these when saving, not `rank_list()`. A language the operator never opened still posts empty values, and writing those over a stored translation erases it. - **removed_langs()**: The languages the operator closed and confirmed. The primary language can never be one of them. #### Where Text Lives - **coremio/locale/{lang}/**: Application text, one directory per language, split into packages that mirror the screens. - **Module files**: Each module carries its own `lang/` directory, loaded into `$this->lang` by the constructor. Its text travels with it rather than being added to the application's. - **Theme files**: A theme carries its own wording too, reached through the `theme/` prefix, so two themes can name the same thing differently. - **Translated records**: Content an operator writes lives in the database, one row per language beside the record. ### Example ```php // Inside a screen whose package is already loaded. $title = Language::g("needs/button-save"); // Anywhere else: name the file, then the key. throw new Exception(Language::gc("admin/services/error-not-found")); // Joining a translation table for the language being served. $lang = Language::selected(); $rows = WDB::select("t1.id, COALESCE(t2.name, t1.name) AS name") ->from("products AS t1") ->join("LEFT", "products_lang AS t2", "t2.owner_id=t1.id AND t2.lang='" . $lang . "'") ->build(); // A language switcher. foreach (Language::$init->rank_list() as $l) $items[] = ['label' => $l["name"], 'href' => $l["link"], 'on' => $l["selected"]]; ``` ```php // coremio/locale/en/cm/admin/services.php and the same key in every other language return [ 'page-list' => 'Services', 'error-not-found' => 'Service not found.', // Nested, so the path is admin/services/errors/timeout 'errors' => [ 'timeout' => 'The provider timed out.', ], ]; // Written back from code when a screen edits a language file. Language::save("admin/services", $data, "en"); ``` ### Pitfalls > **Replacements are second, the language is third** > > Both static helpers take the replacement map in position two and the language code in position three. The instance method behind `g()` reverses them. A snippet copied from inside the class puts a language code where a map belongs. Passing the language second to the static call silently produces no substitution and no error. > **A missing key answers false** > > Not an empty string, and not the key itself. Written as `Language::gc("...") ?: "Some text"` the fallback hides the miss forever. The key looks alive while the language file never had it. Check a doubtful key on the command line and expect a string, not a boolean. > **A nested key is not a key with a slash in it** > > The path form walks into nested arrays, one level per slash. A key defined flat as `'meta-detail/title'` is unreachable, and searching for the last segment alone finds the wrong entry or nothing. > **The first language list in a request fixes the shape for the rest** > > Both arguments share one cached list and `all` overwrites it. Once anything in the request has asked for `all`, every later `rank_list()` answers with package records instead of display rows. `link`, `selected`, `global-name` and `flag-img` are absent, and the enabled-only filter is gone with them. A switcher shown after an administrative screen on the same request is where this appears. Each missing key is a logged warning rather than an error. Take the display rows before anything asks for `all`, or keep your own copy of them. > **Add the key to every language, not only yours** > > Adding text is a change to every language file the installation ships. Records in the database behave differently. A row with no translation for the active language shows its own value instead. That is why the two are handled by different code. ### Related Articles - [Coding Conventions](https://dev.wisecp.com/en/coding-conventions) - [Module Language Files](https://dev.wisecp.com/en/module-language-files) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) - [Overriding Text and Templates](https://dev.wisecp.com/en/overriding-text-and-templates) ## Caching https://dev.wisecp.com/en/caching A file-backed cache with one method worth knowing. The producer contract is easy to break silently, and a few things must never go into it. ### Overview Repeated reads that are the same for everyone are cached: a catalogue, a menu tree, a price table. Entries are grouped into stores, one file per store, and reached through a single read-through call. The installation-wide cache switch is honoured inside that call, so the producer runs on every request when caching is off. Do not check the switch yourself before calling it. ### Reference ```php public static function remember(string $name, string $key, int $ttl, callable $producer); public static function getInstance(): self; // $config: a string is the store name, an array accepts 'name', 'path', 'extension'. public function __construct($config = []); public function store($key, $data, $expiration = 86400): bool; public function retrieve($key, $timestamp = false); public function isCached($key): bool; public function erase($key): self; public function eraseExpired(): int; public function eraseAll(): self; public function clear($keys = []): void; ``` - **Cache::remember()**: The one you want. Returns the stored value, or calls the producer, stores its result for `$ttl` seconds and returns that. - **Cache::getInstance()**: The shared instance, for the calls below. Constructing one with `new` also replaces that shared instance, which is accepted behaviour but worth knowing. - **store()**: Writes one entry. The value is serialized, and `$expiration` of `0` means it never expires. Answers `false` when the licence domain does not match the host. - **retrieve()**: Reads one entry, answering `null` when it is absent or unreadable. The second argument asks for the write time instead of the value. That field is stored unserialized and comes back as `false`, so do not build on it. - **erase()**: Removes one entry from the current store. Throws when the store exists and the key does not, so guard it with `isCached()` or catch. - **eraseAll()**: Deletes the current store's file outright, every entry in it. - **clear()**: Empties whole stores. The argument is a list of **store names**, or a comma separated string of them. With no argument it wipes every store file. #### Store, Key, Lifetime | Argument | Names | Ends up as | | --- | --- | --- | | `$name` | The store | One file under the storage cache directory, `.cache`. Lowercased, and anything outside letters, digits, dot, underscore and hyphen is stripped. | | `$key` | One entry inside that store | An array key in that file, holding the serialized value together with its write time and lifetime. | | `$ttl` | The lifetime in seconds | `3600` for an hour, `86400` for a day of static reference data, `0` for never expires. | #### What the Producer Must Return Anything that survives `serialize()` and comes back equal: arrays, strings, numbers, plain objects. Closures, resources and open connections do not. Declare the return type on the closure so a wrong shape fails where it is written rather than where it is read. ```php // Fine: an array, and the type says so. Cache::remember('website', 'tld_requirements', 3600, fn (): array => self::build_requirements()); // Fine: a rendered string. Cache::remember('website', 'sitemap_' . $lang, 3600, fn (): string => $this->build($lang)); // Fine: several captured values, block body. Cache::remember('website', 'plans_' . $categoryId . '_' . $ucid, 3600, function () use ($categoryId, $ucid): array { $rows = self::plans($categoryId); return self::decorate($rows, $ucid); }); // Broken: null is written but reads back as "missing", so the producer runs // on every request forever and the cache silently does nothing. Cache::remember('website', 'maybe_nothing', 3600, fn () => null); ``` #### What Belongs in the Key A cached value is shared by everyone who computes the same key. The key has to name everything the value depends on. | If the value depends on | The key must carry | Typical fragment | | --- | --- | --- | | Money or formatting | The currency being served | `Money::selected()` | | Any text or link | The active language | `Language::selected()` | | A record, category or filter | That identifier | `$categoryId` | | The signed-in visitor | Nothing, because it must not be cached | Not applicable | ### Example ```php static function catalog_plans(int $categoryId, string $type, string $layout = 'grid'): array { $ucid = Money::selected(); // Prices depend on the currency and labels on the language, so the key carries // both, plus everything that selects the rows. $plans = Cache::remember('website', 'plans_' . $categoryId . '_' . $type . '_' . $layout . '_' . $ucid . '_' . Language::selected(), 3600, fn (): array => self::catalog_plans_fresh($categoryId, $type, $layout, $ucid)); // Stock moves with every order, so it is refreshed live over the cached payload // rather than being part of it. if ($plans) { $stocks = self::catalog_stocks(array_map(fn ($p) => (int) $p["id"], $plans)); foreach ($plans as &$plan) { $stock = trim((string) ($stocks[(int) $plan["id"]] ?? '')); $plan["in_stock"] = ($stock === '' || (int) $stock > 0); } unset($plan); } // Hooks run on EVERY request, so they sit outside the producer. The context goes // into a variable first: runRefs takes all of its arguments by reference. $ctx = ['category_id' => $categoryId, 'type' => $type, 'layout' => $layout]; Hook::runRefs('filter:product.catalog_plans', $plans, $ctx); return $plans; } ``` ```php trait AdminManageWebsite { public function save_menu_changes(Operation $operation): bool { $operation->demo(); // ... write the rows ... // Targeted: only the 'menus' store, addressed by store name. Cache::getInstance()->clear(['menus']); return $operation->output(['status' => "successful"]); } } // Removing a single entry is the other method, and it throws on a miss. $cache = new Cache('website'); if ($cache->isCached('plans_5_hosting_grid_1_en')) $cache->erase('plans_5_hosting_grid_1_en'); ``` ### Pitfalls > **clear() names stores, erase() names entries** > > Passing an entry key to `clear()` does not remove that entry. It treats the string as a store name. It then deletes a file that probably does not exist. The stale entry stays exactly where it was, with no error. Use `erase()` for one entry, and remember it throws when the key is not there. > **Never cache anything that depends on who is asking** > > Stock levels, a cart, anything behind a sign-in. A cached per-visitor value is served to the next visitor, which is a data leak rather than a stale page. > **Keep hooks outside the producer** > > A hook inside the producer only fires on a cache miss. A listener appears to work while the entry is cold, then silently stops for the rest of the lifetime. Cache the data, then let listeners act on it. > **Something has to invalidate it** > > If an operator can edit the value from a screen, that screen has to clear the store. Newly cached data whose editing screen does not clear keeps serving the old version for the whole lifetime. The report that reaches you will be about the screen, not about the cache. ### Related Articles - [Reading and Writing Configuration](https://dev.wisecp.com/en/reading-and-writing-configuration) - [Theme Performance and Caching](https://dev.wisecp.com/en/theme-performance-and-caching) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Querying with WDB](https://dev.wisecp.com/en/querying-with-wdb) ## Error Handling https://dev.wisecp.com/en/error-handling One handler catches everything and decides what the caller sees. That is why you throw instead of building error responses, and why a silent failure is almost always a caught one. ### Overview Startup installs handlers for PHP errors, uncaught exceptions and fatal shutdowns. From that point nothing fails on its own terms. The handler records it, then shows it in the shape the request expects, an HTML page or a JSON body. Inside an operation there is a second layer. The dispatcher wraps your method in a try block. An exception you throw becomes the error response, with your message as its text. That is the whole error path for a mutation: throw, and stop writing. ### Reference #### Raising a Failure - **Operations**: Throw an exception whose message comes from the translation layer. The dispatcher turns it into the error response. - **Modules**: Throw, exactly as an operation does. The caller catches it and surfaces the message. An older module that assigns to an `$error` property and returns false is carrying a pattern from the previous major version. The base classes still translate that into a throw, but new code does not write it. - **Helpers**: Return a falsy value and let the caller decide, or throw when the caller is always an operation. #### Recording ```php public static function getInstance(): MioException; public function initialize(): void; public function logError(string $message, array $context = [], string $level = Logger::LEVEL_ERROR, string $type = Logger::TYPE_SYSTEM): string; public function logDatabaseError(string $query, string $error, array $params = []): string; public function sanitizeData(array $data, int $depth = 0): array; // $depth is internal recursion; pass nothing public static function showErrors(): void; ``` ```php public static function getInstance(): Logger; public function log(string $level, string $message, array $context = [], string $type = self::TYPE_SYSTEM): string; // One static per level, all with the same shape. public static function debug(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string; public static function info(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string; public static function warning(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string; public static function error(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string; public static function fatal(string $message, array $context = [], string $type = self::TYPE_SYSTEM): string; public static function database(string $query, string $error, array $params = []): string; // Runs the callback, records anything it throws and returns null instead of rethrowing. public static function safe(callable $callback, array $context = [], string $errorMessage = 'Operation failed'); ``` - **MioException::getInstance()**: The handler. A singleton, installed during startup by `initialize()`. - **logError()**: Records a failure with context and returns the identifier, which is the same identifier the caller is shown. - **logDatabaseError()**: The same for a query. The statement comes first and the message second, and the context it builds is `['query' => ..., 'parameters' => ...]`. - **sanitizeData()**: Returns a copy with sensitive values replaced. Run request data through it before logging anything. - **Logger::error()**: The everyday form, one static per level. Same result as the handler's method, without reaching for the singleton. - **Logger::safe()**: For work that must not take the request down with it, such as a provider call made while building a page. - **Logger::findById()**: The record behind an Error ID, or `null`. That identifier is what a support request arrives with. It takes you from the customer's message to the stored context. #### Levels and Types Both are plain strings, and the constants are the only spelling that is safe to write. Every recording call returns the identifier as a string. It is uppercase hexadecimal padded to at least six characters, followed by three digits, so `4F59C9BC644` rather than a fixed width. Store it as text and do not validate its length. | Constant | Value | Use it for | | --- | --- | --- | | `Logger::LEVEL_DEBUG` | `DEBUG` | Development tracing, not for a shipped path. | | `Logger::LEVEL_INFO` | `INFO` | Something happened that someone may want to confirm later. | | `Logger::LEVEL_WARNING` | `WARNING` | Recovered, degraded, retried. | | `Logger::LEVEL_ERROR` | `ERROR` | The default. The operation did not do what it was asked. | | `Logger::LEVEL_FATAL` | `FATAL` | The request cannot continue. Written by the shutdown handler. | | `Logger::LEVEL_DEPRECATED` | `DEPRECATED` | Engine deprecation notices, kept apart so they can be filtered out. | | `Logger::TYPE_SYSTEM` | `system` | The default type, everything that is not a query. | | `Logger::TYPE_DATABASE` | `database` | Query failures. Kept in a separate log so one noisy query cannot bury the rest. | #### What Redaction Actually Matches A key is redacted when its lowercased name **contains** any of `password`, `pass`, `passwd`, `pwd`, `secret`, `token`, `key`, `auth`, `api_key` or `private_key`. It is a substring test, applied recursively to nested arrays, and it looks at keys only. ```php $in = [ 'email' => 'someone@example.test', 'Password' => 's3cr3t', // matched case insensitively 'api_token' => 'abc123', // contains "token" 'keyword' => 'hosting', // contains "key" - redacted too 'server' => ['auth_user' => 'root', 'port' => 22], ]; $out = MioException::getInstance()->sanitizeData($in); // [ // 'email' => 'someone@example.test', // 'Password' => '***REDACTED***', // 'api_token' => '***REDACTED***', // 'keyword' => '***REDACTED***', // 'server' => ['auth_user' => '***REDACTED***', 'port' => 22], // ] ``` #### What the Caller Sees | Situation | Response | | --- | --- | | Exception thrown in an operation | `{"status":"error","message":""}` | | Error caught by the handler during an AJAX request | `{"status":"error","message":"Error ID: : ","error_id":""}`, plus a `debug` member when diagnostics are on | | Fatal during a page | The error page; detail only when diagnostics are on | | Non-fatal warning | Recorded; visible on the page only when diagnostics are on | | Anything on the command line | Recorded; the message can be swallowed by the output buffer | ### Example ```php // In an operation: throw, and let the dispatcher answer. public function delete_widget(Operation $operation): bool { $operation->demo(); $id = (int) Filter::init("POST/id", "rnumbers"); if (!$id) throw new Exception(Language::gc("widgets/error-id-required")); if (!$this->model->delete($id)) throw new Exception(Language::gc("widgets/error-delete-failed")); return $operation->output(['status' => "successful"]); } // In a module: throw. The caller catches it and surfaces the message. public function create(): array|false { $response = $this->api->create($params); if (!($response['success'] ?? false)) throw new Exception((string) ($response['message'] ?? $this->lang["err-provider"])); return ['status' => 'SUCCESS']; } ``` ```php // Recording something you handled yourself. Redact first; the identifier that // comes back is the one to quote in the message the operator will read. $mio = MioException::getInstance(); $id = $mio->logError("Provider refused the transfer", $mio->sanitizeData($_POST), Logger::LEVEL_WARNING); throw new Exception(Language::gc("services/error-transfer", ['{id}' => $id])); // The browser then receives, from the operation dispatcher: // {"status":"error","message":"Transfer failed. Reference: 4F59C9BC644"} // // Reading it back on the command line: // php coremio/errlog.php list --limit=10 // php coremio/errlog.php show ``` ### Pitfalls > **Three neighbouring calls, three different first arguments** > > `logError()` starts with the message, `logDatabaseError()` starts with the *query* and takes the message second, and `Logger::log()` starts with the level. Swapping the first two in the database call is silent. You get a log entry whose message is a SQL statement, and whose recorded query is an error text. > **Every warning is a disk write** > > The handler records non-fatal warnings too, and each record is a write. A missing array key read inside a loop costs one write per iteration. That is why values are read with a default rather than hopefully. > **Redact before you log, and read the match rule first** > > The log is a file that outlives the request and gets copied around. Redaction is a substring test on key names, so it is generous in one direction and blind in the other. A field named `card` or `answer` passes through untouched. Do not pass raw request data and assume the list covers your field. > **A quiet failure is still recorded** > > Nothing on screen means the handler decided not to show it, not that nothing happened. Read the log before concluding that a code path was never reached. ### Related Articles - [Debugging and Logs](https://dev.wisecp.com/en/debugging-and-logs) - [Operations](https://dev.wisecp.com/en/operations) - [Coding Conventions](https://dev.wisecp.com/en/coding-conventions) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) # Platform Foundations / Data Layer ## Querying with WDB https://dev.wisecp.com/en/querying-with-wdb One query builder, two entry points, and a chain that changes nothing until it is finished. ### Overview Queries are built by chaining calls rather than by writing SQL into a string. The chain reads in the order the statement is written. Every value you pass as an argument is bound instead of pasted. `WDB` is a static facade. Each of its methods forwards to the one `Database` object the model layer holds. The static form and a model's `$this->db` are the same connection and the same builder state. ### Reference #### Entry Points - **WDB::**: The static facade, for helpers, operations, modules and hooks. Every call forwards to the object below. - **$this->db**: The same object inside a model, built once per process by the lazy getter on `Models`. A chain may be started on one form and continued on the other. - **Database**: The builder itself. Methods with no static wrapper (`whereGroup`, `getCount`, `pushState`) are reached off the object a chain returns. #### Reading ```php // Name the columns. "*" hands your callers whatever the table grows into later. public static function select($arg = "*"); // a falsy $arg becomes "*" public static function from($arg = ''); // "users_products" | "users_products AS t" public static function join($type, $table, $where); // $type: "LEFT" | "INNER" | "RIGHT" - pasted before " JOIN " public static function where($column, $mark = '', $value = '', $logical = ''); public static function group_by($arg = ''); // raw expression, not bound public static function order_by($arg); // raw expression, not bound public static function limit($arg1, $arg2 = null); // limit(25) -> LIMIT 0,25 · limit(50, 25) -> LIMIT 50,25 public static function build($isthis = false); // bool: matched rows > 0 · $isthis = true returns the Database object // Fetchers read the statement build() left behind, or the one you hand them. public static function fetch_assoc($statement = false); // list of rows, each column => value; [] when none matched public static function getAssoc($statement = false); // one row as column => value; false when there is no row public static function fetch_object($statement = false);// the same list as stdClass objects public static function getObject($statement = false); // one row as stdClass; false when there is no row public static function rowCounter($statement = false); // int: rows the last statement touched ``` #### What where() Accepts The second argument is written into the statement as-is. It is an SQL operator, not a keyword from a list the builder owns. Three shapes are special-cased. | $mark | $value | Produced SQL | | --- | --- | --- | | `'='` `'!='` | scalar; an empty string is still bound | `col = ?` | | `'>'` `'<'` `'>='` `'<='` | scalar | `col > ?` | | `'LIKE'` `'NOT LIKE'` | scalar carrying its own `%` | `col LIKE ?` | | `'IN'` `'NOT IN'` | **an array**, one placeholder per element | `col IN (?,?,?)` | | `'IS'` `'IS NOT'` | `'NULL'`, pasted rather than bound | `col IS NULL` | | `'IS NOT NULL'` | `''`, the whole test is the operator | `col IS NOT NULL` | | omitted (`''`) | a non-empty scalar | `col = ?` | ```php // $logical accepts exactly 'AND', '&&', 'OR', '||'. It joins THIS condition to the NEXT // one; a condition left without it is given AND automatically once the next one arrives. public static function where($column, $mark = '', $value = '', $logical = ''); // Parentheses: no static wrapper, so it is called on the object the chain returns. // $logical joins the whole group to the condition that FOLLOWS it, exactly like the // fourth argument of where(). Leaving it out is safe: the group is recorded as a // joinable predicate, so the next condition supplies AND by itself. Pass it for OR. public function whereGroup(callable $callback, string $logical = ''): self; ``` #### Writing ```php public static function insert($table, $data); // int affected rows; throws on an SQL error public static function lastID(); // int identifier the insert produced; 0 with no connection public static function update($table = '', $data = []); // a non-empty $data calls set() for you public static function set($data = [], $special = false); // $special = true pastes the value RAW: ['uses' => 'uses+1'] public static function save($isthis = false); // bool: no error - TRUE also when zero rows matched public static function delete($arg = '', $arg2 = ''); // delete("t") | delete("a", "a INNER JOIN b ON ...") public static function run($isthis = false); // bool: deleted rows > 0 // $data for insert() and update()/set() is column => value. A null value is bound as // SQL NULL, an int as an integer, everything else as a string. // limit() applies to save() and run() too, as a row count with no offset: limit(5000). // order_by() picks which rows a limited write takes. Two chains throw instead of // running, because MySQL rejects them: update()->join(), and a multi-table // delete("t", "table t") carrying order_by() or limit(). ``` #### Raw Statements ```php public static function query($statement, $isthis = false); // PDOStatement, or false - the error is swallowed public static function exec($arg = ''); // int affected rows; 0 on error - the error is swallowed public static function hasTable($table = ''); // bool, via SHOW TABLES LIKE - the name is pasted, no prefix public static function getPrefix(): string; // the schema prefix from the database configuration public static function getErrorMessage(); // text of the last error, including the swallowed ones ``` ### Example ```php // A page of rows. WDB::select('t.id, t.name, t.status, u.full_name') ->from('users_products AS t') ->join('LEFT', 'users AS u', 'u.id = t.owner_id') ->where('t.status', 'IN', ['active', 'inprocess']) ->where('t.type', '=', 'hosting') ->order_by('t.id DESC') ->limit(0, 25) ->build(); $rows = WDB::fetch_assoc(); // [] when nothing matched // One row. build() is false when nothing matched, which is the guard to write. $stmt = WDB::select('id, name, status')->from('users_products')->where('id', '=', $id)->limit(1); $row = $stmt->build() ? $stmt->getAssoc() : []; // A bound OR group, isolated from the conditions around it. $search = WDB::select('id')->from('users_products'); $search->where('owner_id', '=', $userId); $search->whereGroup(function ($q) use ($word) { $q->where('name', 'LIKE', '%' . $word . '%', '||'); $q->where('status', 'LIKE', '%' . $word . '%'); }, '&&'); $search->where('type', '=', 'hosting'); $found = $search->build() ? $search->fetch_assoc() : []; ``` ```php // Insert returns the number of affected rows, and the identifier is read afterwards. $affected = WDB::insert('users_products', [ 'owner_id' => $userId, 'product_id' => $productId, 'name' => $name, 'status' => 'waiting', 'notes' => null, // bound as SQL NULL 'cdate' => DateManager::Now(), ]); $newId = $affected ? (int) WDB::lastID() : 0; // Update: the chain does nothing until save(). WDB::update('users_products', ['status' => 'active'])->where('id', '=', $newId)->save(); // The one value that is deliberately not bound: an expression evaluated by the server. WDB::update('coupons')->set(['uses' => 'uses+1'], true)->where('id', '=', $couponId)->save(); // Read the write back, from the other entry point - it is the same connection. $check = WDB::select('status')->from('users_products')->where('id', '=', $newId); $saved = $check->build() ? ($check->getAssoc()['status'] ?? '') : ''; // Delete: run(), not save(). WDB::delete('users_products')->where('id', '=', $newId)->run(); ``` ### Pitfalls > **The value argument is the safety** > > Passing the value as an argument is what makes it bound. Building it into the column expression instead moves it into the statement. The first quote in a user's input then becomes an injection. Three places are unbound on purpose and must never receive user input. They are `order_by` and `group_by`, `set($data, true)`, and the table name given to `hasTable`. > **Two arguments that are read by position** > > `limit(25)` is the first 25 rows, `limit(50, 25)` is 25 rows from offset 50. The count moves to the second slot as soon as there is an offset. The fourth argument of `where()` joins that condition to the **next** one, not to the previous one. It belongs on the condition before the alternative, never on the last one in a group. > **A write is not finished until it is committed, and a commit is not a match** > > An update needs `save()` and a delete needs `run()`; a chain that stops at the conditions changes nothing and reports nothing. Their return values then disagree on purpose. `save()` answers "no error", so it is true even when the condition matched no row. `run()` and `build()` answer "rows > 0". Checking that a record really changed means reading it back, not trusting the boolean. > **The builder throws, the raw statements do not** > > `build()`, `insert()`, `save()` and `run()` turn a failed statement into an exception, which an operation converts into an error response for you. `query()` and `exec()` swallow it instead and return `false` or `0`, so a schema statement can fail in complete silence. When you reach for them, read `getErrorMessage()`. ### Related Articles - [The Model Layer](https://dev.wisecp.com/en/the-model-layer) - [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Caching](https://dev.wisecp.com/en/caching) ## Changing the Database Schema https://dev.wisecp.com/en/changing-the-database-schema Your tables are created and migrated by your own code, at a moment the platform gives you. The same code has to be safe to run again. ### Overview There is no migration directory and no version file to bump. A module that needs storage creates it in its own lifecycle, when it is enabled. It brings an older installation forward in the same place. One method holds both the create path and the upgrade path. Every statement in it needs a condition that makes the second run a no-op. The core's own tables are not yours to change. Adding a column to a table the product ships is a change an upgrade will undo. Worse, it is one an upgrade may collide with. Store what you need in your own table and join. ### Prerequisites - A module, since this is where table ownership lives. A hook listener that needs storage belongs in a module for the same reason. - A table name that cannot collide with the product's. Prefix it with your module's name. ### Walkthrough #### Create When the Module Is Enabled 1. Declare the enable step of your module's lifecycle and call your schema check from it. 2. In the check, ask whether the table exists before creating it. Enabling a module that is already installed must not fail. 3. Enable the module in the panel and confirm the table appears. #### Migrate in the Same Method 1. When the table already exists, use the same method to bring an older shape forward. Ask whether the old column is still there, and change it only if it is. 2. Guard every step by its own condition rather than by a stored version number. An installation that skipped a release then still converges. 3. Run the check twice in a row and confirm the second run does nothing. #### Seed Only an Empty Table 1. If your feature needs starting rows, insert them only when the table is empty. 2. Never seed on every enable; an operator who disabled and re-enabled the module would get duplicates, or would silently lose their edits. 3. Disable and re-enable, then confirm the row count did not change. ### Reference #### What the Platform Calls - **change_addon_status()**: The caller. It receives `'enable'` or `'disable'` from the panel and runs your pair of methods. Only then does it write the new status into the module's configuration. - **Modules::getInstance()**: How anything else reaches your module, including a script that wants to run the schema step twice. Never construct the class directly. ```php // Declared by your module, called by the platform when the operator flips the switch. // On "enable" activate() runs first when it exists, then enable(); on "disable" the // deactivate()/disable() pair does the same. The new status is written ONLY if the last // one returned a truthy value, so a falsy return or a thrown exception rejects the click // rather than leaving the module half-installed. public function enable(): bool; public function disable(): bool; // Your own steps, called from enable(). Both the create path and the upgrade path live // here, so both run on every enable, on every re-enable and after every update. private function check_database(): void; // tables private function check_columns(): void; // columns added by a later version private function seed(): void; // starting rows, once ``` #### The Query Layer's Schema-Facing Part ```php public static function hasTable($table = ''); // bool, via SHOW TABLES LIKE - no prefix is applied public static function exec($arg = ''); // int affected rows; 0 on failure, WITHOUT throwing public static function query($statement, $isthis = false); // PDOStatement, or false on failure - also without throwing public static function getAssoc($statement = false); // one row of that statement; false when there is none public static function getErrorMessage(); // why exec() or query() came back empty public static function getPrefix(): string; // the schema prefix, when the installation configures one // DDL runs through exec()/query() because the chained builder only writes DML. Neither // of those two throws: a failed CREATE or ALTER returns 0 / false and says nothing. ``` #### One Condition per Statement | What you are adding | The condition that makes a second run a no-op | Statement | | --- | --- | --- | | a table | `WDB::hasTable($t)` is false | `CREATE TABLE` | | a column | `SHOW COLUMNS FROM $t LIKE 'col'` returns no row | `ALTER TABLE ... ADD COLUMN` | | a renamed column | the **old** name still returns a row | `ALTER TABLE ... CHANGE` | | a removed column | the name still returns a row | `ALTER TABLE ... DROP COLUMN` | | starting rows | the table's row count is 0 | `INSERT` | ### Example ```php public function enable(): bool { $this->check_database(); $this->check_columns(); return true; } private function check_database(): void { if (\WDB::hasTable(self::TABLE)) { // Already installed: this is the upgrade path, not the create path. Each step // is guarded by its own condition, so an installation that skipped a release // converges too. $old = \WDB::query("SHOW COLUMNS FROM `" . self::TABLE . "` LIKE 'ticket_id'"); if ($old && \WDB::getAssoc($old)) \WDB::exec("ALTER TABLE `" . self::TABLE . "` CHANGE `ticket_id` `owner_id` INT UNSIGNED NOT NULL DEFAULT 0"); return; } $created = \WDB::exec('CREATE TABLE `' . self::TABLE . '` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `owner_id` INT UNSIGNED NOT NULL DEFAULT 0, `status` VARCHAR(32) NOT NULL DEFAULT "", `ctime` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `owner_id` (`owner_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); // exec() swallows the error, so ask for it rather than assuming success. if (!\WDB::hasTable(self::TABLE)) throw new \Exception('Table could not be created: ' . \WDB::getErrorMessage()); $this->seed(); } private function check_columns(): void { // Columns a later version introduced, table => column => definition. $columns = [ self::TABLE => ['provider' => 'VARCHAR(30) NOT NULL DEFAULT ""'], ]; foreach ($columns as $table => $definitions) { if (!\WDB::hasTable($table)) continue; foreach ($definitions as $column => $definition) { $stmt = \WDB::query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'"); if ($stmt && \WDB::getAssoc($stmt)) continue; \WDB::exec("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}"); } } } private function seed(): void { // Starting rows go in ONCE. An operator who disabled and re-enabled the module // must not get duplicates, and must not lose their own edits. $stmt = \WDB::select('COUNT(id) AS total')->from(self::TABLE); $total = $stmt->build() ? (int) (($stmt->getAssoc() ?: [])['total'] ?? 0) : 0; if ($total > 0) return; \WDB::insert(self::TABLE, ['owner_id' => 0, 'status' => 'ready', 'ctime' => \DateManager::Now()]); } ``` ```php // A throwaway script: read the shape, run the step twice, read it again. The claim // "it is safe to run again" is only worth anything once the two readings agree. $module = Modules::getInstance('Addons', 'AcmeScanner'); $shape = static function (): array { $rows = WDB::query('SHOW COLUMNS FROM `Acme_scans`'); $cols = $rows ? WDB::fetch_assoc($rows) : []; $count = WDB::select('COUNT(id) AS total')->from('Acme_scans'); return [ 'columns' => array_column($cols, 'Field'), 'rows' => $count->build() ? (int) (($count->getAssoc() ?: [])['total'] ?? 0) : 0, ]; }; $module->enable(); $first = $shape(); $module->enable(); $second = $shape(); echo $first === $second ? "idempotent\n" : "DIVERGED\n"; ``` ### Pitfalls > **Do not add columns to the product's tables** > > An upgrade owns those tables. Your column may survive, may be dropped, or may collide with one the product adds under the same name. Keep your data in your own table and join to theirs by identifier. > **A failed statement says nothing** > > `exec()` and `query()` do not raise on an SQL error. They return `0` and `false`, and the reason stays in `getErrorMessage()` until something asks for it. A schema step that only calls them reports success on an installation where nothing was created. Read the shape back, or throw when the check finds it missing. > **The step runs more than once** > > Enabling, re-enabling and upgrading all reach it. Every statement needs a condition that makes the second run a no-op. The way to be sure is to run it twice and compare the shape, not to reason about it. > **Removing a module must not remove the operator's data** > > Dropping your table on disable turns an accidental click into data loss. Leave the rows. A re-enable then finds its data where it left it, and an operator who really wants it gone can say so. ### Related Articles - [Querying with WDB](https://dev.wisecp.com/en/querying-with-wdb) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) - [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade) ## Domain Helpers https://dev.wisecp.com/en/domain-helpers The business rules live in helpers. The panel, the client area, the API and the scheduler all call the same rule. That is how they agree on what a service, an order or an amount means. ### Overview A model answers what is stored. A helper answers what should happen. What suspending a service does, what an order becomes when it is paid, how an amount converts between currencies. Because four surfaces need those answers, the rule lives in one place and each surface calls it. They are static classes over `WDB`. A module, a hook listener or a cron handler reaches them the same way a controller does. Doing the work yourself with queries produces something that is right today and diverges the first time the product's own rule changes. ### Reference - **Services**: A customer's provisioned service: its row, its add-ons, its status transitions and the module action each transition carries. - **Money**: Currencies, conversion, display and tax. Every stored amount belongs to a currency, and this is what turns it into the one being served. - **Orders**: The order document: creation, its items, and the status it takes from the services underneath it. - **Products**: The catalogue: products, categories, add-ons, prices, servers and domain extensions, translated for the active language. - **User**: The client record, the free-form information attached to it, and the activity entries other surfaces show. #### Services ```php public static function get(int $id = 0, string $select = '', $noCache = false): array; // -> the users_products row: id, owner_id, order_id, invoice_id, subscription_id, type, // type_id, product_id, name, period, period_time, total_amount, amount, amount_cid, qty, // status, status_msg, suspended_reason, pmethod, auto_pay, cdate, duedate, suspend_date, // cancel_date, terminated_date, server_terminated, renewaldate, process_exemption_date, // module, options, metrics, notes, unread // 'options' and 'metrics' arrive DECODED as arrays; a column that is NULL stays null. // 'module' is always present and falls back to "none" - which is why a missing service // is still a non-empty array. $select narrows the columns, $noCache skips the memo. public static function add(array $data = []): int; // the new id, 0 on failure public static function set(int $id = 0, array $data = []): bool; public static function delete(int $id = 0, bool $cancelModule = false): bool; public static function preload(array $ids = []): void; // one query, fills get()'s memo public static function statuses(): array; // -> status key => ['title' => label, => true]. The keys are waiting, inprocess, // active, completed, suspended, expired, cancelled; the group flag is what code should // branch on, since 'completed' groups as active and 'expired' groups as cancelled. public static function addons(string|int|null $service_id = 0): array; // users_products_addons rows, // plus a computed 'rak' sort column public static function get_addon(int $id = 0, string $select = ''): array; public static function requirements(int $service_id = 0): array; public static function change_status(int $serviceId, string $status, array $options = []): bool; // $options, all optional: // apply_on_module false | true | 'sync' | 'queue' run the module action inline, or queue it // module_completed bool the module already did its part; do not hold the service at inprocess // force_status bool write the status verbatim, skipping that same guard // reason string stored as suspended_reason for suspended and cancelled // user_id int who is doing this, for the history entry // notify bool send the customer the status notification public static function run_module(array|int $service, string $action, array $params = []): mixed; // -> null when the module has no such method, false when it refused, otherwise the module's // own return value. Throws when the instance cannot be built, or when the module threw. public static function add_history(int $user_id = 0, int $service_id = 0, string $name = '', array $data = []): int; ``` #### Money ```php public static function getUCID(): int; // the currency id being served to this visitor public static function selected(): int; // readable alias of getUCID() public static function exChange($amount, $cid1 = 0, $cid2 = 0); // -> the converted amount. Two answers are not conversions: // $cid1 === $cid2 -> $amount comes back untouched, no rate is read // it cannot convert -> 0, which is what an unknown currency, a rate of 0, and an // $amount that is not above zero all produce public static function formatter($amount = 0, $cid = 0, $symbol = false, $exchange = false, $info = false): array|string; // -> "99.90" · with $symbol: "$99.90" · with $info an array instead of a string: // ['currency_id' => 4, 'currency_code' => 'USD', 'prefix' => '$', 'suffix' => '', // 'symbol' => '$', 'amount' => '99.90'] // $exchange converts first: true uses the served currency, an id or code uses that one. public static function formatter_symbol($amount = 0, $currency = 0, $exchange = false, $info = false): string; // The third argument is $exchange here, NOT $symbol - the symbol is always on. public static function deformatter($amount = '', $currency = 0): float; // "1.234,56" -> 1234.56 public static function Currency($identified = 0, $isActive = false): array; // -> the currencies row: id, status, local, hidden, country, countries, code, name, prefix, // suffix, rate, format, modules. [] when there is no such currency. $identified takes an // id OR a code ("USD"); $isActive makes an inactive currency answer [] as well. public static function getCurrencies(int $default = 0): array; public static function currency_code(int $id = 0): string; public static function getSymbol($currency = 0); // -> ['position' => 'LEFT'|'RIGHT', 'prefix' => ..., 'suffix' => ..., 'symbol' => ...] public static function get_tax_amount($amount = 0, $rate = 0); // tax ON TOP of $amount public static function get_inclusive_tax_amount($amount = 0, $rate = 0); // tax already INSIDE $amount public static function get_discount_amount($amount = 0, $rate = 0); // $amount * $rate / 100 ``` #### Orders ```php public static function get(int $id, $select = ''): array; // -> the orders row: id, user_id, invoice_id, affiliate_id, ordernum, cdate, tax_type, // taxes, amount, currency, pmethod, status, ip, notes, discounts, items, details // taxes, discounts, items and details arrive DECODED as arrays. [] when there is none. public static function get_order_by_number(int $num, string $select = ''): array; public static function create(array $data = []): int; // Only these keys are read; anything else in $data is dropped rather than stored. // user_id int REQUIRED // amount float REQUIRED - may be 0.0, may not be absent // currency int REQUIRED - a currency id // status string 'waiting' // pmethod string 'none' // tax_type string 'exclusive' // taxes array encoded to JSON // discounts array encoded to JSON // items array encoded to JSON // details array encoded to JSON // invoice_id int 0 // affiliate_id int 0 // notes string null // ip string the requester's address // Throws when a required key is missing. The order number is generated for you, and a // listener may veto the whole call. public static function set(int $id, array $data): bool; public static function delete(int $id, bool $deleteServices = true): bool; public static function services(int $order_id = 0): array; // -> the order's services, narrowed to id, name, type, status, amount, module, period, period_time public static function statuses(): array; // waiting, inprocess, active, cancelled public static function payment_statuses(): array; // incomplete, complete, unknown public static function change_status(int $order_id, string $status, bool $updateServices = false, string|bool $applyOnModule = false): bool; public static function recalculateStatus(int $order_id, bool $force = false): ?string; public static function generateOrderNumber(): int; public static function add_history($user_id = 0, $order_id = 0, $name = '', $data = []): int; ``` #### Products ```php public static function get(string|int|null $id = 0, string $lang = '', string $select = ''): array; // -> the products row plus the translated 'title' and its alias 'name'. An empty $lang means // the active one. 'options' and 'module_data' arrive DECODED as arrays. [] when there is // no such product. Memoised per (id, lang) for the whole process, so a row edited in // between is not re-read. public static function set($id = 0, $set = []): bool; public static function types(): array; // -> type key => ['title' => ..., 'description' => ..., 'icon' => ...]. The list is not fixed: // a module extends it through the register:product_types hook. public static function groups(): array; public static function cycles(): array; public static function cycle($duration = '', $period = ''): string; // (1, 'month') -> "monthly" public static function get_price($type, $owner, $owner_id, $lang = 'none'): array; public static function get_price_by_criteria(string $owner, int $owner_id, string $cycle = '', string|int $currency = '', int $status = 1, string $type = ''): array; // $owner is a literal that differs per table and is NOT guessable: a product price is // stored under 'products' (plural), a domain extension under 'tld', an add-on under // 'addon'. The wrong form matches nothing and reports no error. public static function addon($id = 0, $lang = '', $select = ''): array; public static function requirement($id = 0, $lang = '', $select = ''); public static function get_category($id, $lang = '', $select = ''): array; public static function get_server($id = 0): array; // the servers row, 'password' already decrypted public static function get_tld($definition = 'com', $select = ''); ``` #### User ```php public static function getData($id = 0, $fields = '*', $fetch = 'object', $noCache = false): object|array; // $fields is accepted and NOT used: the whole users row is always read, and the memo is // keyed by id alone. $fetch picks the shape - 'object' (the default) gives a stdClass, // anything else gives an array. Reading one column therefore has to say so: // $groupId = (int) (User::getData($id, '', 'array')['group_id'] ?? 0); public static function setData($id = 0, $data = []): int|bool; public static function create($data = []); public static function delete(int $id, array $options = []): bool; public static function getInfo($owner_id = 0, $names = [], $noCache = false): array; // -> the names you asked for as name => value, with null for one that was never stored. public static function AddInfo($owner_id = 0, $values = []); // upsert, name => value public static function deleteInfo($owner_id = 0, $name = ''): int|bool; public static function addAction($id = 0, $reason = '', $detail = '', $data = [], $target_id = 0); // $id the client the entry belongs to // $reason a free-form grouping label: 'alteration', 'addition', 'delete', 'module-log' // $detail the TRANSLATION KEY from locale actions.php - the sentence is rendered from it // $data the placeholders that sentence uses ({service_name} and so on); also stored as JSON // $target_id the record the entry is about, when that is not the client itself public static function addNote(int $owner_id, string $content, bool $pinned, int $adminId, string $adminName): array|false; public static function getPrivileges($id = 0, $resultType = 'array'): array|string; public static function parseDealership(string $data): array; ``` #### What "Not Found" Looks Like | Call | Answer for a record that does not exist | The guard to write | | --- | --- | --- | | `Services::get($id)` | `['module' => 'none']` - non-empty, so it passes a truthiness test | `if (!($service['id'] ?? 0))` | | `Services::get_addon($id)` | `[]` | `if (!$addon)` | | `Orders::get($id)` | `[]` | `if (!$order)` | | `Products::get($id)` | `[]` | `if (!$product)` | | `Money::Currency($id)` | `[]` | `if (!$currency)` | | `User::getData($id)` | an empty `stdClass`, or `[]` with `'array'` | `if (!($user->id ?? 0))` | ### Example ```php // The service. This is the one getter whose "not found" answer is still truthy, so the // guard reads the identifier rather than the array. $service = Services::get($id); if (!($service['id'] ?? 0)) throw new Exception('Service not found'); // The amount belongs to the currency stored beside it, and the visitor may be served // another one. Convert first, render second. $served = Money::getUCID(); $amount = Money::exChange((float) $service['amount'], (int) $service['amount_cid'], $served); $displayed = Money::formatter_symbol($amount, $served); // Perform the action through the helper, so the history, the module and the notification // all happen the way the panel would have done them. Services::change_status((int) $service['id'], 'suspended', [ 'apply_on_module' => 'queue', 'reason' => 'Payment overdue', 'user_id' => $adminId, 'notify' => true, ]); // Record it on the client's activity. The third argument is the key in locale actions.php; // the fourth fills that sentence's placeholders. User::addAction((int) $service['owner_id'], 'alteration', 'acme-service-suspended', [ 'service_id' => (int) $service['id'], 'service_name' => $service['name'] ?? '', 'amount' => $displayed, ], (int) $service['id']); ``` ```php // The entry keeps the key, the sentence rendered in the installation's own language, and // the placeholders as JSON. A key with no translation shows up here as the key itself. $stmt = WDB::select('reason, detail, locale_detail, data, ctime') ->from('users_actions') ->where('owner_id', '=', (int) $service['owner_id']) ->where('detail', '=', 'acme-service-suspended') ->order_by('id DESC') ->limit(1); $entry = $stmt->build() ? $stmt->getAssoc() : []; $vars = $entry ? Utility::jdecode($entry['data'] ?? '', true) : []; // And the status the helper actually wrote, read without the memo the first call filled. $after = Services::get((int) $service['id'], '', true)['status'] ?? ''; ``` ### Pitfalls > **Writing a row is not the same as performing the action** > > Creating or suspending a service through the helper also does the things around it. The history, the related records, the module action, the events other code listens for. An insert or an update of your own does none of that. It leaves an installation that looks right and behaves oddly later. > **A conversion that cannot be done answers zero, not the input** > > Every stored amount belongs to a currency, and the visitor may be served another. Convert before you compare, sum or display. Put the served currency in the key of anything you cache. When the conversion cannot be done, `exChange()` returns `0`. An unknown currency, a rate of zero and an amount that is not above zero all land there. A credit or refund passed through it as a negative number becomes zero without a word. > **Two getters that answer something other than what they seem to** > > `Services::get()` always carries a `module` key, so a missing service is a truthy array and `if (!$service)` never fires. `User::getData()` ignores its column list entirely and hands back a `stdClass` unless you ask for an array. Casting its result while expecting one column silently yields the wrong number. On `Money::formatter_symbol()` the third argument is `$exchange`, not `$symbol`. Its fourth argument makes the underlying call return an array against a declared string return type. > **An activity entry needs its translation key** > > The sentence comes from the third argument, not from anything you pass as text. The second is only a grouping label. A key that exists in one language file and not the others shows up as the bare key. Everyone else sees it that way in the client's history. Add it to every language file when you add the call. ### Related Articles - [The Model Layer](https://dev.wisecp.com/en/the-model-layer) - [Querying with WDB](https://dev.wisecp.com/en/querying-with-wdb) - [Changing Billing Behaviour](https://dev.wisecp.com/en/changing-billing-behaviour) - [Common Hook Recipes](https://dev.wisecp.com/en/common-hook-scenarios) # Platform Foundations / Interface Layer ## Views and Templates https://dev.wisecp.com/en/views-and-templates A controller collects named values and hands them to the view with a template name. The template reads those names as variables and prints the page. ### Overview Producing a page takes two calls. `chose()` picks the template directory; `render()` runs a template inside it with the data collected so far. The data arrives in the template as ordinary variables named after the keys, so a template never fetches anything for itself. The admin panel's templates are plain PHP and ship with the product. The website's are a theme's, and the same call selects the active theme instead of a fixed directory. ### Reference #### Signatures All five are instance methods. Inside a controller the instance is `$this->view`; anywhere else it is the singleton `View::$init`. ```php public function chose($dir, $noTemplate = false): self; public function render($_name = null, $data = [], $return_output = false, $source = false): mixed; public function get_template_dir($type = 'website'): string; // TEMPLATE_DIR . $type . DS public function get_template_url($type = 'website'): string; // APP_URI . "/templates/{$type}/" public function get_resources_url($str = '', $l_slash = true, $r_slash = false): string; ``` #### The Four Parameters of `render()` - **$_name**: Template path relative to the chosen directory, **without** the extension: `'widgets/list'` resolves to `templates/admin/widgets/list.php`. A missing file returns an empty string rather than raising. - **$data**: Key/value map extracted into variables before the template runs. Pass the controller's collected data, `$this->data`. Anything that is not an array is treated as empty. - **$return_output**: `false` prints the markup and returns an empty string; `true` returns it instead. A page method returns its page, so it passes `true`. - **$source**: `true` includes the file and hands back **the value the file returns**, printing nothing. This is how a template that is really a data file (a returned array) is read. `false` when the file is missing. #### What chose() Accepts | Argument | Resolves to | When | | --- | --- | --- | | `'admin'` | `templates/admin/`, plain PHP | Every admin screen | | `'website'` | The active theme's directory and engine | A visitor request. In an admin or cron context this falls back to `templates/website/` as plain PHP | | `'system'` | `templates/system/`, plain PHP | Fatal error, maintenance, invoice document | | any path with `$noTemplate = true` | The path exactly as given | A module using its own template directory | #### How Data Reaches a Template Values added by the controller are extracted into variables before the template runs. A value added under the name `rows` is read as `$rows`. On top of those, the view adds five of its own to every call: - **$template_dir**: Filesystem path of the chosen directory, with a trailing separator. This is what lets one template include another without knowing where it lives. - **$ui_lang**: The language resolved for this request, e.g. `'en'`. Only set when the controller did not already supply it. - **$ui_dir**: Writing direction for that language, `'ltr'` or `'rtl'`. Print it on the root element; never hard-code a direction. - **$badress, $tadress, $sadress**: Base address of the installation, of the chosen template directory, and of the shared resources directory. Use them for asset links instead of composing URLs by hand. - **$setting**: Themed website pages only: the active theme's merged settings, schema defaults included. Absent in admin and system templates. ### Example The two halves together: the controller names the values, the template reads those exact names back. ```php public function page_list(&$links, &$meta, &$breadcrumbs): string { $this->addData('rows', $this->model->list()); $this->addData('total', $this->model->list(true)); // Third argument true: return the markup instead of printing it, because the // page method's job is to RETURN the rendered page. return $this->view->chose('admin')->render('widgets/list', $this->data, true); } ``` ```php

()

``` Reading a template that returns data instead of printing it, and producing a theme view from a background job: ```php // $source = true: the file's own return value comes back, nothing is printed. $columns = View::$init->chose('admin')->render('tables/widgetList', [], false, true); // Outside a visitor request (cron, mail, PDF) the theme is NOT resolved by chose('website'). // Ask the theme itself, which resolves engine and directory regardless of context. $html = Theme::active()->render('account/invoice-pdf', ['invoice' => $invoice]); ``` ### Pitfalls > **Escape everything a person typed** > > An admin template is plain PHP with no automatic escaping. A value that came from a form, an API or a remote provider appears exactly as it arrived. Escaping it is your job. > **A background job cannot select the theme with chose('website')** > > Theme resolution is skipped outside a visitor request, so the call falls back to plain PHP. It looks for a `.php` file the theme does not have and returns an **empty string with no error**. Produce the view through the theme object instead. > **A template refuses to be entered directly** > > Every template starts by exiting when the application's constants are absent. Without that line the file is a URL that runs part of a page with no session, no permissions and no data. > **Read every key with a default** > > A missing key inside a loop raises one warning per row, and each warning is a disk write. In a list template that is the difference between a fast page and a slow one. ### Related Articles - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) - [Interface Components](https://dev.wisecp.com/en/interface-components) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Error Handling](https://dev.wisecp.com/en/error-handling) ## Interface Components https://dev.wisecp.com/en/interface-components Four building blocks the panel is assembled from. A screen you add looks and behaves like the ones that shipped with it. ### Overview A component is built in PHP and printed into the template: the controller describes what it wants, the component produces the markup. Sorting, filtering, paging and tab memory come with it. All four live in `WISECP\Components` and are constructed with `new`. Every one takes its content through an **options array**, not positional arguments. ### Reference #### Table ```php public function __construct(string $name, array $options = []); // $name = preset file name public function setColumns(array $columns): self; public function setColumn(string $key, array $data): self; public function deleteColumn(string $key): self; public function setRows(array $rows): self; public function setRowRender(callable $renderRow): self; public function setFilters(array $filters): self; public function setOptions(array $options): self; public function getOptions(): array; public function isRequestAjax(): bool; public function ajaxControl(array $options = []): string; public function allDataAjaxResponse(): string; public function ajaxResponse(int $totalEntries = 0, int $totalSearchEntries = 0): string; public function build(string $format = ''): string; // '' = whole table, 'justBody' = rows only public function buildList(string $format = ''): string; // card rows instead of a grid public function container(string $content, array $attributes = []): string; public function exportButton(array $options = []): string; // ['label', 'formats', 'class'] // Public properties. The two models are closures the component calls itself. public int $ajax_transition_limit = 1000; public mixed $totalModel; // fn (string $search = '', array $filters = []): int public mixed $dataModel; // fn (string $search = '', string $order = '', string $direction = '', int $start = 0, int $end = -1): array ``` Constructor options, all optional. The constructor **includes the preset file itself**, before the controller has assigned anything else. A preset reads options with `getOptions()` and never sees rows. - **preset**: Load a preset file other than the table's own name, e.g. `'ticketList'` for a widget reusing the main list's columns. - **lazy**: `true` defers the first query until the tab is opened. Not for a detail tab; for expensive sources (a filesystem scan, a remote catalogue). - **perPage · perPageOptions · search · info · pagination**: Toolbar switches. `false` drops the control; `[10, 25, 50, 100, -1]` sets the page sizes, `-1` meaning all. - **renderer**: `'list'` switches to the card layout, the one the client area uses. Anything else gives a grid. - **export · exportPrivilege · exportName**: Download is on by default. `false` removes it, a privilege key narrows it, the name becomes the file name. Set these in the controller: the export request exits before the template runs. - **containerExtraAttributes**: Attribute map written on the wrapper, e.g. `['data-url-sync' => 'true']` to mirror filters into the browser address. `ajaxControl()` takes its own array. It returns a non-empty string exactly when it has already answered the request, so the caller returns it untouched. - **baseLink**: Address the data request is built from. For a subpage this must be the **full subpage address**: the controller root never reaches the page method, so the list comes back empty. - **mode**: `'partial'` (default) pages on the server; `'allData'` hands the whole set over in one answer. - **ajaxExtraParams**: Extra query values carried on every data request, e.g. `['type' => 'hosting']`. - **force**: Overrides the row-count threshold. Legitimate only when the filters shape the query itself; write the reason next to it. The row your `setRowRender()` callback receives, and gives back, has three parts: ```php $row = [ // The record as the model returned it. Read-only input. 'model' => ['id' => 5, 'name' => 'example.com', 'status' => 'active'], // One entry per column key. 'value' is the cell markup, 'attributes' land on the . // 'data-value' is what sorting and client-side filtering compare, so it is the RAW value. 'data' => [ 'id' => ['value' => '
#5', 'attributes' => ['data-value' => 5]], 'status' => ['value' => 'Active', 'attributes' => ['data-value' => 'active']], ], // Attributes for the . Row-level filter values are read from here. 'attributes' => ['class' => 'table-tr-bg-info', 'data-filter-status' => 'active'], ]; ``` #### Tab and Accordion ```php // Tab. $type is 'horizontal' or 'vertical'. add() takes TWO arguments: // the panel key, then an options array. The panel body is $options['content']. public function __construct($name = '', $type = 'horizontal'); public function add($name, $options = []): self; public function set($name, $options = []): self; // same as add() public function get($name = ''); public function remove($name = ''): self; public function noUrl(bool $noUrl = true): self; public function render(array $options = [], string $header = '', string $contents = ''): string; public function header($options = []): string; public function contents(array $options = []): string; // Accordion. Same idea, typed, and no vertical/horizontal choice. public function __construct(string $name = ''); public function add(string $name, array $options = []): self; public function set(string $name, array $options = []): self; public function get(string $name = ''); public function remove(string $name): self; public function noUrl(): self; public function render(array $options = []): string; ``` - **content**: The panel's markup. Both accept it here and only here; an empty panel stays empty, so supply your own empty state. - **title**: Label on the tab or accordion header. Falls back to the key when absent, so an untranslated panel shows its key. - **icon**: Icon class, e.g. `'bi bi-gear'`. On a horizontal tab the spacing class comes from `iconClass` and defaults to `'me-2'`. - **badge · badgeClass**: Tab only: a count printed inside the tab after its label. Any non-empty value prints, including `'0'`. - **subtitle · hideContentTitle**: Tab only, vertical layout: the second line in the rail, and a switch that suppresses the heading the pane otherwise repeats. - **show**: Accordion only: `true` opens this section on load. Sections share a parent, so opening one closes the rest. - **headerTag · titleClass · buttonClass · bodyClass**: Accordion only: heading element (default `'h2'`) and the classes on header, toggle (default `'bg-light'`, pass `''` to clear) and body. Both remember the open panel in the address, under the component's own name: a tab set named `settings` reads `?settings=general`. Two sets on one screen need different names. #### Modal ```php // Everything is given at construction; the setters exist to change it afterwards // and they return void, so they do NOT chain. public function __construct(array $params); public function setTitle(string $title): void; public function setBody(string $body): void; public function setFooter(string $footer): void; public function setForm(?array $formAttributes): void; // attribute map, or null to unwrap public function setHeaderClasses(array $classes): void; public function render(): string; ``` - **id**: DOM id, and the target a trigger points at. Defaults to `'SampleModal'`, so two dialogs without an id collide. - **title · body · bodyClass · footer**: Heading, content markup, extra classes on the body, and the footer. An **empty footer is not rendered at all**. - **form**: Attribute map that wraps header, body and footer in a `
`: `['action' => $url, 'method' => 'POST']`. Every pair is written as an attribute, so a footer submit button posts the dialog. Omit it for a read-only dialog. - **headerClasses**: Picks the **tone only**: a value containing `danger` gives a red title, anything else the primary title. The shell is fixed, so this cannot paint a coloured bar. - **modalDialogExtraClass · scrollable · centered**: Width class such as `'modal-lg'`, plus the two layout switches that both default to `true`. - **attributes**: Raw attributes merged over the defaults on the outer element. The only supported way to lock a backdrop. ### Example A list takes three files: the controller wires the data, the preset declares the columns and builds each row out of the model, the page template prints it. ```php $table = new \WISECP\Components\Table('widgetList', ['exportName' => 'widgets']); // Values the operator chose. setFilters carries them on the data request; // the model only receives them while answering one. $dynamic = []; if ($status = Filter::init("REQUEST/filter/status", "route")) $dynamic['status'] = $status; $table->setFilters($dynamic); $filter = $table->isRequestAjax() ? $dynamic : []; $table->totalModel = fn ($search = '', $filters = []) => $this->model->list(true, array_merge($filter, $filters, ['word' => $search])); $table->dataModel = fn ($search = '', $order = '', $direction = '', $start = 0, $end = -1) => $this->model->list(false, array_merge($filter, ['word' => $search]), [$order => $direction], $start, $end); // Non-empty means the request was already answered here. if ($response = $table->ajaxControl(['baseLink' => $links["controller"]])) return $response; $this->addData('table', $table); ``` ```php /** @var \WISECP\Components\Table $table */ if (!isset($table)) return; $table->setColumns([ 'id' => ['title' => 'ID', 'attributes' => ['class' => 'text-center'], 'sortable' => true], 'name' => ['title' => Language::gc("admin/widgets/th-name"), 'sortable' => true], 'status' => ['title' => Language::gc("admin/widgets/th-status"), 'sortable' => "asc"], 'email' => ['title' => Language::gc("admin/widgets/th-email"), 'exportOnly' => true], ]); $table->setRowRender(function ($row) { $id = (int) $row["model"]["id"]; $row["data"]["id"]["value"] = '#' . $id . ''; $row["data"]["id"]["attributes"]["data-value"] = $id; $row["data"]["name"]["value"] = htmlspecialchars($row["model"]["name"] ?? ''); // The raw value is what the filter compares; the cell shows the badge. $row["data"]["status"]["value"] = \WISECP\AdminComponents\Statuses::getInstance()->service($row["model"]["status"]); $row["data"]["status"]["attributes"]["data-value"] = $row["model"]["status"]; // Not drawn on screen, present in the download. $row["data"]["email"]["value"] = $row["model"]["email"] ?? ''; $row["attributes"]["data-filter-status"] = $row["model"]["status"]; return $row; }); ``` ```php // Nothing appears until the page template prints it. The preset already ran, inside // the constructor, so this call only renders what the controller and the preset built. if (isset($table)) { $table->setOptions(['containerExtraClass' => 'mt-3']); echo $table->build(); } ``` A tab set and a dialog, both fed by their options array: ```php // footer.php prints whatever is in $modals, so the template declares it once up here // and every dialog on the page appends to it. $modals = ''; $tab = new \WISECP\Components\Tab('widget-detail'); $tab->add('overview', [ 'title' => Language::g('widgets-overview'), 'icon' => 'bi bi-grid-1x2', 'content' => $overviewHtml, ]); // The condition lives HERE, once. A reader of the panel then knows the tab is // missing because of this rule and not because something failed. if ((int) Config::get('options/reselling/status') === 1) $tab->add('reselling', [ 'title' => Language::g('widgets-reselling'), 'badge' => $pendingCount, 'content' => $resellingHtml, ]); echo $tab->render(); $modal = new \WISECP\Components\Modal([ 'id' => 'widgetDeleteModal', 'title' => Language::g('needs/delete'), 'body' => '

' . Language::gc('admin/widgets/delete-confirm') . '

' . '', 'footer' => '', 'headerClasses' => ['bg-danger'], 'modalDialogExtraClass' => 'modal-lg', 'form' => ['action' => $links["controller"], 'method' => 'POST'], ]); $modals .= $modal->render(); ``` ### Pitfalls > **Tab and Accordion take a key and an options array, never a label and a body** > > Writing `add('overview', 'Overview', $html)` passes a string where the options array is expected, plus a third argument that does not exist. The panel appears with its key as the title and **no content at all**, without an error. The body is `$options['content']`. > **The dialog's setters return nothing, so they cannot be chained** > > Every setter is typed `void`. Chaining one onto another is a fatal call on null. Pass everything in the constructor array; use the setters only to change a value you already set. > **Let the row count decide where paging happens** > > The component switches to server-side paging by itself once the total passes its threshold. Forcing that decision at the call site either puts fifty thousand rows into one document or pays an extra round trip for twenty. > **A nested tab set should stay out of the address** > > Two sets both writing to the address fight over it, and the operator returns to the wrong pair. The inner one calls `noUrl()`. ### Related Articles - [Views and Templates](https://dev.wisecp.com/en/views-and-templates) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) - [Admin JavaScript Library](https://dev.wisecp.com/en/admin-javascript-library) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) ## The Admin Form Builder https://dev.wisecp.com/en/the-admin-form-builder Declare a form field by field in PHP. The builder produces the markup, the layout and the field names the operation reads back. ### Overview You describe the fields; the builder writes the markup. The name you declare is the name that arrives in the request. The form and the code that receives it cannot drift apart the way hand-written markup does. Field methods return the builder, so calls chain. Every one of them ends with an options array, and that array is where most of the useful behaviour lives. ### Reference #### Creating the Form ```php public function __construct(?string $formId, ?string $action = '', ?array $options = []); // $formId the form's DOM id, used by JavaScript that targets this form // $action where it posts; usually the controller address // $options ['disableStickySubmit' => true] to drop the pinned submit bar ``` #### Field Signatures Read the parameter order carefully. Several fields take more arguments than they appear to, and the extra ones sit in the middle rather than at the end. ```php public function addText($name, $label = '', $value = '', $options = []): self; public function addEmail($name, $label, $value = '', $options = []): self; public function addPassword($name, $label = '', $value = '', $options = []): self; public function addNumber($name, $label = '', $value = '', $options = []): self; public function addAmount($name, $label = '', $value = '', $options = []): self; public function addDate($name, $label = '', $value = '', $options = []): self; public function addColor($name, $label = '', $value = '', $options = []): self; public function addFile($name, $label = '', $value = '', $options = []): self; public function addArea(string $name, string $label, string $value = '', array $options = []): self; public function addHidden(string $name, string $value = '', array $options = []): self; // $options here is the CHOICE MAP (value => label), $fieldOptions is the field's settings. public function addSelect(string $name, string $label, array $options = [], $selected = '', array $fieldOptions = []): self|string; // SEVEN parameters. $value is what gets submitted when ticked; $checked is the current state. public function addCheckbox(string $name, $groupLabel = '', $labelDesc = '', $value = '', $checked = false, array $rowOptions = [], array $options = []): self; public function addSwitch(string $name, $groupLabel = '', $labelDesc = '', $value = '', $checked = false, array $rowOptions = [], array $options = []): self; // One field per installed language. public function addMultiLang(string $type, string $name, string $label, array $langList, array $values, string $currentLang = '', array $options = []): self; // These take a single descriptor array rather than positional arguments. public function addRadioGroup(array $options = []): self; public function addCheckboxGroup(array $options = []): self; // Insert relative to a field that already exists, which is how a module extends a form it does not own. public function addFieldBefore(string $targetElementId, string $type, array $options = []): array; public function addFieldAfter(string $targetElementId, string $type, array $options = []): array; public function render(array $options = []): string; ``` #### What the Options Array Accepts - **placeholder**: Hint text inside the input. A hint, not a label; the field still needs its label. - **description**: Help text under the field. This is where a rule or a consequence belongs. - **attributes**: A map of raw HTML attributes written onto the input: `['dir' => 'ltr', 'autocomplete' => 'off', 'required' => 'required']`. - **id**: A fixed DOM id, for JavaScript that has to find this exact field. #### The Shape of Each Value | Field | What you pass | What arrives in the request | | --- | --- | --- | | text, area, email, password | the current string | the string, always present | | number, amount | the current number | a string you cast | | select | `['live' => 'Live', 'test' => 'Test']` plus the selected key | the chosen key | | checkbox, switch | the submit value, then the current state as a boolean | the submit value, or **nothing at all** when unticked | | multi-language | the language list and a `lang => value` map | `name[en]`, `name[tr]`, one per language | | file | the current path, for display | an upload entry, read from the files source | ### Example A module settings form, then the operation that reads it back. The two halves are shown together on purpose. The names have to match, and the reading side is where the shapes above matter. ```php $form = new AdminFormBuilder('acmeSettingsForm', $actionUrl, ['disableStickySubmit' => true]); // Carried through the form so the dispatcher knows which operation to run. $form->addHidden('operation', 'save_acme_settings'); $form->addText('api_endpoint', $lang['api-endpoint'], $config['api_endpoint'] ?? '', [ 'placeholder' => 'https://api.example.com', 'description' => $lang['api-endpoint-desc'], 'attributes' => ['dir' => 'ltr', 'autocomplete' => 'off', 'spellcheck' => 'false'], ]); $form->addPassword('api_key', $lang['api-key'], $config['api_key'] ?? '', [ 'description' => $lang['api-key-desc'], ]); // Third argument is the CHOICE MAP, fourth is the selected key. $form->addSelect('mode', $lang['mode'], ['test' => $lang['test'], 'live' => $lang['live']], $config['mode'] ?? 'test', [ 'description' => $lang['mode-desc'], ]); // Seven parameters: name, group label, the label beside the box, the SUBMITTED value, // the current state, row options, field options. $form->addSwitch('log_requests', $lang['logging'], $lang['logging-enable'], '1', (int) ($config['log_requests'] ?? 0) === 1); echo $form->render(); ``` ```php public function save_acme_settings(Operation $operation): bool { $operation->demo(); $endpoint = Filter::init("POST/api_endpoint", "hclear"); if (!$endpoint) throw new Exception(Language::gc("acme/error-endpoint-required")); $data = [ 'api_endpoint' => $endpoint, // Pass-through: every other filter removes the characters that make a key strong. 'api_key' => Filter::init("POST/api_key", "password"), 'mode' => Filter::init("POST/mode", "letters"), // An unticked switch sends NOTHING, so absence is the value "off". 'log_requests' => Filter::init("POST/log_requests", "rnumbers") ? 1 : 0, ]; if (!Modules::getInstance('Addons', 'Acme')->save_config($data)) throw new Exception(Language::gc("acme/error-save-failed")); return $operation->output(['status' => "successful"]); } ``` ### Pitfalls > **A checkbox takes seven arguments, and the middle ones are easy to confuse** > > The third is the label beside the box. The fourth is the value that gets submitted, and the fifth is whether it is currently ticked. Passing the current state third silently turns it into a label, and the box then never reflects the saved setting. > **An unticked box sends nothing** > > It is absent, not zero. Code that only writes what it receives will keep the previous setting forever, so read the absence explicitly as off. > **Read a secret with the pass-through filter** > > Any text filter strips exactly the characters that make a key or a password strong. The saved value is then quietly different from what was typed. This is the one field where filtering is the bug. ### Related Articles - [Interface Components](https://dev.wisecp.com/en/interface-components) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Operations](https://dev.wisecp.com/en/operations) ## Admin JavaScript Library https://dev.wisecp.com/en/admin-javascript-library The panel ships one request helper, one modal library, one notification stack and one table controller for every screen you add. ### Overview Every admin page loads the same four scripts, and everything in them is global: plain functions plus one class for tables. The contract with the server is a single JSON envelope, the one every operation returns. The helper reads it, so a call site usually needs no success or error branch. ### Prerequisites The admin footer prints all of this: include `inc/head.php` and `inc/footer.php`, with an operator logged in. Without the footer you get none of the library or the constants. On the other side of every call sits an operation, a method on the controller. The `operation` field dispatches it and the controller wraps it in error handling. Bootstrap 5 is loaded and used directly (modals, dropdowns, toasts, tooltips). jQuery is present for older code but unused here. ### Structure #### Where Each Piece Lives - **js/default.js**: The request helper, the enhanced select wrapper, the automatic form submit, and the small helpers (escaping, money, query strings, cookies). The largest of the four and the one you call most. - **js/modal.js**: Every dialog: the generic opener plus the three confirmation dialogs and their closers. - **js/alert.js**: The overlay notification (one at a time, queued) and the stacked toasts in the bottom right corner. - **js/table.js**: The controller behind every list: paging, search, sorting, filters, reload and row removal. - **inc/footer.php**: Prints the scripts, the translated constants and whatever the page put in `$page_scripts` and `$modals`. This file is the load order. #### Load Order and Its Trap The footer prints them in this order. Constants block, table controller, enhanced select library, `default.js`, the Bootstrap bundle, **your page script**, then `alert.js` and `modal.js`. So a page script runs **before** the alert and modal libraries exist. Declare functions freely, but do the work in a handler. #### Adding Your Own Script Four template variables are read by the layout, two by the head and two by the footer. A tab file further down the page can still append to the footer pair. - **$plugins**: Array of optional libraries to load, e.g. `['datepicker', 'apexcharts', 'pdf-export']`. The head loads the styles, the footer the scripts. Anything not listed is not on the page. - **$page_scripts**: Raw markup printed near the end of the footer. This is where a page's own script goes, and where server values are handed to it. - **$page_styles**: The same idea for the head. Assign it before including the head or it never appears. - **$modals**: Dialog markup, printed at the end of the body. Append to it, never overwrite: several dialogs on one page all share this one variable. - **ui:admin.head.js**: Return markup and it appears at the very end of the footer. This is how a module that owns no admin template puts a script on every page. - **ui:admin.body.end**: The last output of the document, after the one above. Use it for markup a script needs to find, such as a dialog shell. ```php $clink = $links["controller"] ?? ''; $plugins = ['datepicker']; // Text that JavaScript will assign to textContent or to an input value must arrive as // JSON, not as HTML entities. See the pitfall at the end of this article. $L = Utility::jencode([ 'records' => Language::gc("admin/widgets/records"), 'confirm' => Language::g("needs/confirm-action-ok"), ]); $page_scripts = << const clink = '{$clink}'; const L = {$L}; HTML; include $template_dir . "inc" . DS . "head.php"; ``` ### Reference #### WcpRequest The one call every operation goes through. It returns the promise chain, so you can await it, but the useful hooks are the callbacks. ```js function WcpRequest(url, preferences) // returns a Promise WcpRequest(clink, { method: 'POST', data: { operation: 'update_widget', id: 5, name: 'Example' }, button: btn, afterDone: (response) => WCPTable.get('widgetList')?.reload(), }); ``` The helper builds the body itself: a nested object becomes `name[key]`, an array becomes `name[0]`, and a `File` value is appended whole. Every request also carries `X-Requested-With: XMLHttpRequest`. The dispatcher demands it and answers 403 without it. A plain `?operation=` link or a hand-rolled `fetch` cannot run an operation. #### Options Object Keys - **method**: HTTP method, default `'GET'`. Anything else sends the built form data as the body; GET turns the same set into a query string on the address. - **data**: A **plain object** of values to send. Not a ready `FormData`: see the pitfall below. Nested objects, arrays and `File` values are all handled. - **options**: Native fetch init merged over the method, for headers, credentials, an abort signal. Your own `options.headers` are kept; `X-Requested-With` is filled in only when you did not set it. A non-empty `options.body` makes `data` ignored entirely. - **button**: The element to disable and put a loader on for the length of the request. Also the double-click guard, which is the reason to pass it even when you do not want a spinner. The loader is taken from the element's own `data-loader` attribute first, then `buttonLoader`, then the bare spinner. A button with a direct icon child **and** a label keeps that label when the loader itself carries no text: only the icon is swapped. Otherwise the whole content is replaced, and the original markup is restored **between** `afterDone` and `finally`. Inside a row menu the button also keeps that menu open, and the menu closes two seconds after the answer lands. - **buttonLoader**: Loader markup. Use one of the translated constants (`saving_loader` and friends), not hand-written spinner markup. - **responseType**: `'json'` (default), `'text'` or `'blob'`. A JSON content type from the server wins over this, so an operation's answer is always parsed. - **followRedirect**: Default true. When the server itself redirected the request, the browser is sent to the final address and nothing else runs. `false` reads the redirected response as a normal answer. - **redirectTarget**: `'_self'` (default) or `'_blank'`. With `'_blank'`, a `redirect` in the answer opens a tab instead of navigating, and only `done` runs afterwards. - **successToast · errorToast · alert_toast**: Report through the small corner toast instead of the overlay notice. `alert_toast` sets both at once. The server can override the success side by returning its own `successToast`. - **beforeDone**: Runs with the raw `Response` before the body is read. For headers and status, not for data. - **done**: Receives the parsed answer and **replaces the automatic handling entirely**: no notice, no redirect, no error alert. Only for an answer you build yourself. - **afterDone**: Receives the parsed answer **after** the automatic handling. This is the normal place for extra work: reload a table, remove a row, close a dialog. - **fail**: Receives the `Error` and replaces the automatic error alert. Both a network failure and an answer whose status is not `successful` arrive here. - **afterFail**: Runs after the error has been reported, whether by the automatic alert or by your own `fail`. - **finally**: Runs on every path and takes **no arguments**. It is called after the button's original markup is restored, which makes it the only correct place to repaint that button. #### What the Answer May Contain These are the keys the automatic handling reads. Anything else reaches `afterDone` untouched, which is how a call site gets data back. | Key | Value | What the helper does with it | | --- | --- | --- | | `status` | `"successful"` or anything else | The switch. Anything other than `"successful"` turns `message` into a thrown error. The failure path is the same for a refused operation and a dead network. | | `message` | text | On success, shown as an overlay notice (or a toast with `successToast`). On failure, shown as the error. Treated as markup, so escape anything a person typed. | | `redirect` | address, `"reload"` or `"script"` | Navigates, reloads the page, or evaluates `script`. **`"script"` is only honoured when a `message` is present as well**. On its own it is treated as an address, and the page navigates to a page named "script". | | `redirect_delay` | milliseconds | How long the notice stays before the redirect fires. Default 5000 with a message, 1 without one. | | `script` | JavaScript source | Evaluated when there is no message and no redirect. A last resort; prefer returning data and acting on it in `afterDone`. | | `successToast` | boolean | Lets the operation choose the small toast over the overlay, overriding the call site. | #### Modals Never write dialog markup by hand. Four openers cover every case and all build the same shell. Its header close button means a footer never carries a Close or Cancel button. ```js // Generic dialog. First argument is a DOM id or an element; an unknown id creates one. function open_modal(modal_id, options) function close_modal(modal_id) // id or element function destroy_modal(modal_id) // disposes the instance and removes the element // Confirmation. Built, shown, and removed from the document when it hides. function confirmModal(options = {}) function confirmModalClose() function confirmDeleteModal(options = {}) function confirmDeleteModalClose() function actionModal(options = {}) function actionModalClose() // ^ the only one that returns a handle: { modal, modalElement, confirmBtn } // The other two return nothing; use their closers. // The header class for a tone. Anything containing "danger" gives a red title, // everything else the primary one. Use it if you rewrite a header at runtime. function wcpModalHeaderClass(tone) ``` #### The Three Simpler Dialogs - **title · body · footer**: `open_modal`: heading, content markup and footer markup. An **empty footer renders no footer element at all**, which is what a read-only dialog wants. - **bodyClass · modalDialogExtraClass · modalFooterExtraClass · width**: Body padding (default `'p-4'`), a width class such as `'modal-lg'`, extra footer classes, and an explicit maximum width like `'600px'`. - **bgClass · textClass · backdrop · onClose**: `bgClass` only picks the header tone now, it no longer paints anything; `textClass` is still accepted and **never rendered**. `backdrop` takes `'true'`, `'false'` or `'static'`; leave it alone unless work would be lost by closing. `onClose` fires once, after the dialog has hidden. - **attributes on the element itself**: `open_modal` reads `data-bs-modal-title` (or `data-izimodal-title`), `data-bs-modal-bg-class`, `data-bs-modal-text-class` and `data-bs-modal-width` off the element **after** merging your options. An attribute left on the markup quietly wins over the value you passed. - **modalId**: The three built dialogs default to a random id and delete any element already carrying it before building. Pass your own only when something else has to find the dialog: the closers already track the last one opened. `open_modal` takes its id as the first argument instead. - **confirmButtonClick · confirmButtonOnClick**: `confirmModal` only. A function is bound to the click. A **string** is handed to `eval` immediately, while the dialog is being built, not on the click. `confirmButtonOnClick` is written into the button's inline `onclick` instead. Give **neither** and the button only closes the dialog, which is the only case where closing is automatic. - **title · message · confirmButtonText · confirmButtonClass · headerClass · confirmButtonExtraAttributes**: `confirmModal` only. The three texts default to the translated constants `confirmActionTitle`, `confirmActionMsg` and `confirmActionOk`, so a plain confirmation needs no strings at all. The two classes default to `'bg-danger'` and `'btn-danger'`. The last one is a raw attribute string appended to the confirm button. - **message · description · content**: `confirmDeleteModal` takes three. The name of the thing being deleted, a line of consequence under it, and any extra markup appended below the centred block. - **icon · iconColor · buttonText · buttonClass · buttonIcon · width**: `confirmDeleteModal`: the large icon and its tone, the confirm button's label (default `confirmActionOk`), class and icon, and the dialog width (default `'420px'`). ? `buttonClass` doubles as the header tone. The shell reads it to choose between a red title and the primary one. - **onConfirm · buttonLoader**: `confirmDeleteModal`: the callback receives the **confirm button**, ready to hand to `WcpRequest`. `buttonLoader` is written onto that button as its `data-loader`. Supplying `onConfirm` means **you** close the dialog. #### actionModal The dialog for anything that touches several records or cannot be undone, a single delete included. - **action · title · headerClass · confirmText · confirmButtonClass · confirmButtonIcon**: The heading, the header tone and the confirm button's label, class and icon. ? `action` (`'delete'`, `'active'`, `'suspended'`, `'cancelled'`) is stored on the dialog but **never read**. It is the call site's own switch, so the tone still has to arrive through `headerClass`. `headerTextClass` and `cancelText` are accepted and never shown either. - **showIcon · icon · iconColor · confirmTitle · confirmSubtitle**: The large icon disc and the two lines under it. The disc is **on by default**. The whole centred block is dropped only when you pass `showIcon: false` and leave both lines empty. - **showServiceInfo · serviceInfoRows · serviceLabel · serviceName · clientLabel · clientName**: A details panel above the consequences. `serviceInfoRows` is a list of `{ label, value, valueClass }`. When given, it replaces the built-in pair. Leave it empty and the panel prints those two rows from the four label and name options instead. - **showBulkInfo · selectedLabel · selectedCountText · selectedCount · countClass**: The "how many are selected" panel, on by default. ? The number printed is `selectedCountText`; `selectedCount` is accepted but **never rendered**, so passing only the count leaves the panel blank. - **info1Title · info1Desc · info1Icon · info1IconColor (and the same four for info2)**: Two consequence boxes side by side. Fill one and it spans the full width; fill neither and the divider and the row are dropped. - **showApiSwitch · apiSwitchLabel · apiSwitchDesc · apiSwitchChecked**: The "apply on the provider as well" switch. It is shown by default and starts **unticked** unless `apiSwitchChecked` says otherwise. Turn it off for records with no module behind them, otherwise the operator is offered a choice that does nothing. - **showPassword · passwordHtml**: A password check before the action runs. Pass the shared `confirmActionRequirePassword` constant as the markup; the field inside it carries the id the dialog reads, `#confirmPassword`. While that field is empty the confirm button stays disabled. Markup without it silently gets neither the guard nor a value in the callback. - **onConfirm · onCancel · data · processingText**: The confirm callback. A callback that fires only when the dialog is closed **without** confirming. An arbitrary payload handed back to the callback, and the label shown while the button is loading. #### The actionModal Callback Context `onConfirm` receives **one object**, not a button and a modal as two arguments. ```js onConfirm: (ctx) => { ctx.button; // the confirm button element (pass it to WcpRequest as `button`) ctx.modal; // the Bootstrap Modal instance ctx.modalElement; // the dialog's root element ctx.applyApi; // boolean: state of the provider switch, false when it is hidden ctx.password; // string: what was typed in the password field, '' when hidden ctx.data; // exactly the object passed in as `data` ctx.setLoading(true); // disable + spinner + processingText ctx.setLoading(true, 'Deleting'); // ...with your own label ctx.setLoading(false); // restore icon + confirmText ctx.close(); // hide the dialog; nothing closes it for you } ``` Use `ctx.setLoading` or `button: ctx.button`, not both: they both own the button's contents and the second write wins. #### Tables Every list is driven by a controller instance you can reach by name. The component, its presets and its filters have their own article. ```js const table = WCPTable.get('widgetList'); // null when the list is not on the page const same = document.querySelector('.wcp-table[data-name="widgetList"]').wcp; table.reload(url) // refetch; the optional address replaces the data address table.activate() // first load of a deferred list; a no-op on a normal one table.resetFilters() // clear every bound control, drop the filters from the address, reload table.hasActiveCustomFilters() // boolean table.addRow(rowHtml, prepend = false) // markup or a element table.exportUrl('csv') // download address carrying the current filters, search and sort // removeRow has three shapes. All three update the counts and repaint at once, // without a round trip. A server-paged list refetches once afterwards to backfill. table.removeRow('id', '5') // column key + the cell's exact text table.removeRow((tr) => tr.dataset.uid === u) // a predicate over the row element table.removeRow('example.com') // any cell whose text matches // Useful state table.name · table.ajax · table.isPartial · table.currentPage table.perPage · table.searchQuery · table.totalEntries · table.totalFilter ``` Two events are raised, each on two targets: the instance itself and a twin on the document. | Event | Where to listen | What `detail` carries | | --- | --- | --- | | `ajax-load-after` | the instance | `table`, `name`, `isPartial`, `totalEntries`, `filteredData` | | `wcp-table-ajax-load-after` | `document` | the same object | | `table-update-after` | the instance | `table`, `name`, `currentPage`, `filteredData`, `animated` | | `wcp-table-update-after` | `document` | the same object | The twin lets a listener bind before the list exists. Binding to the wrapper element silently never fires; bind to `WCPTable.get(name)` or to the document and filter on `detail.name`. #### Alerts and Toasts ```js // Overlay notice: one at a time, further calls queue behind it. // Both take the same options object, and the same defaults. alert_success(message, options = {}) // options: { timer: 3000, onClose: null } alert_error(message, options = {}) // error does NOT default to a longer timer alert_error(message, { timer: 0 }) // 0 = no timer, stays until it is dismissed // The small corner toast, ready-made. alert_success_toast(message) // 4 seconds alert_error_toast(message) // 6 seconds // The toast in full. createStackedToast({ message, // markup, so escape anything a person typed type: 'info', // success | error | info | warning; anything else falls back to info icon: 'bi bi-info-circle', // only used by the titled form autoHide: 10000, // milliseconds; 0 keeps it until it is closed animation: true, title: '', // empty = compact one-line form subtitle: '', // a timestamp here also gets a relative-time tooltip }) ``` The compact form is dismissed by clicking anywhere on it; the titled form closes only from its own button. The stack sits bottom right and holds four toasts; a fifth closes the oldest. There is no position option. Pass no `title` or `subtitle` unless you have a translated one. The defaults are empty so an untranslated heading is never printed. #### Global Constants Printed by the footer before any script runs, so they are available everywhere. They are `const` bindings, not window properties. The loaders are also mirrored onto `window`, so they resolve by name. - **APP_URI · dashboard_link · admin_id · is_logged · is_rtl · lang_code**: The base address, the address the panel polls, and the signed-in operator. Whether anyone is signed in at all, the writing direction and the active language code. - **themeDarkMode · themePrimaryColor · themeSecondaryColor · developerMode · resources_url**: Theme state and brand seeds, whether debugging output is on, and the address of the shared assets directory. - **currency_formats · currency_ids · currency_rates · currency_default · currencies**: A sample formatted amount per code, the identifier and rate per code, the installation's own currency, and the list in one array. The money helpers read these; without them they cannot work. - **addText · actionsLabel · noResultsFound · copyText · copiedText · select_search_text**: Translated labels for markup built in JavaScript, so a generated control is not the one English word on a translated page. - **confirmActionTitle · confirmActionMsg · confirmActionOk · confirmDeletionTitle · confirmActionRequirePassword**: The defaults behind the confirmation dialogs, plus the ready markup for a password check. Pass that last one as `passwordHtml` rather than writing a field yourself. - **saving_loader · adding_loader · creating_loader · updating_loader · deleting_loader · removing_loader …**: A spinner plus a translated verb. Also `enabling_`, `disabling_`, `sending_`, `checking_`, `approving_`, `activating_`, `cancelling_`, `suspending_`, `downloading_`, `applying_`, `redirecting_` and `formSubmit_loader`. - **spinner_loader · label_loader**: The bare spinner, and a template whose `{label}` you replace: `label_loader.replace('{label}', importingText)`. - **dateTimeFormat · timeAgoLabels · passwordChars · passwordMinLength**: The operator's date format and the relative-time words. Also this installation's password policy, so a generated password passes the check the form will run. #### Helper Functions - **escHtml(str) · html_entities(str) · strip_tags(html)**: Escaping for markup you build in JavaScript. Run every value that came from a person through `escHtml` before it reaches `innerHTML` or an attribute. - **money_formatter(amount, currency, symbol) · money_deformatter(amount) · money_exChange(amount, from, to)**: Format an amount for a currency code (`symbol` defaults to true). Read a typed amount back into a number whatever the separators, and convert between codes at the loaded rates. - **_GET(name, url) · set_GET(param, value, url) · remove_GET(param, url)**: Read one query value: `null` when absent, `''` when present and empty. Return a **new address** with a value set or removed. None of them navigate. - **watchRequired(btnSelector)**: Keeps a submit button disabled until every watched field has a value. ? `data-required` goes on the **button** and holds the comma-separated **ids of the inputs**. Putting it on the input instead leaves the button disabled forever. - **setCookie(name, value, days) · getCookie(name) · in_array(needle, haystack) · base64_encode(str) · base64_decode(str)**: Small conveniences that behave the way their names suggest; the base64 pair is safe for non-ASCII text. - **toggleDisableFormElements(wrapper, checkbox) · previewImage(input, imgId)**: Enable or disable a whole block of fields from one checkbox. Show a local preview of a chosen file before it is uploaded. - **the wcp-form class**: Forms built by the form builder carry it and are submitted over AJAX automatically. You get change tracking, a disabled submit until something changes, the loader, upload progress and the pinned submit bar. Do not add a submit listener or call the request helper yourself on such a form. - **the dashboard grid**: Dashboard cards are laid out by a packing library, which re-measures every card on each pass. A card whose height changes after the first paint repacks the whole board, visibly. Reserve the height in CSS for anything that fills in late, such as a chart. ### Example A row delete takes three pieces: the JavaScript that asks, the operation that answers, and the row that disappears without a reload. ```js function deleteWidget(id, name) { confirmDeleteModal({ message: escHtml(name), description: L.deleteWarning, buttonLoader: deleting_loader, // The callback is handed the confirm button, which is exactly what the request // wants: it disables it, shows the loader on it, and re-enables it at the end. onConfirm: (btn) => WcpRequest(clink, { method: 'POST', data: { operation: 'delete_widget', id: id }, button: btn, // The success notice is small: a delete does not deserve a full overlay. successToast: true, // afterDone runs AFTER the automatic handling, so the message has been shown // and an error has already been reported. Nothing here runs on failure. afterDone: () => { confirmDeleteModalClose(); WCPTable.get('widgetList')?.removeRow('id', String(id)); }, }), }); } ``` ```php public function delete_widget(Operation $operation): bool { $operation->demo(); $id = (int) Filter::init("POST/id", "rnumbers"); if (!$id) throw new Exception(Language::gc("admin/widgets/error-id-required")); // A thrown exception becomes {"status":"error","message":"..."} on its own, which is // the shape the helper turns back into an error notice. No error branch is needed. if (!$this->model->delete($id)) throw new Exception(Language::gc("admin/widgets/error-delete-failed")); // Deliberately no redirect: the page removes the row itself, so reloading would cost // a round trip and lose the operator's place in the list. return $operation->output([ 'status' => "successful", 'message' => Language::gc("admin/widgets/deleted"), ]); } ``` The same list in bulk. The dialog owns the button, so the request is not given one. ```js function bulkWidgets(action, ids) { actionModal({ action: action, title: L.bulkTitle, headerClass: action === 'delete' ? 'bg-danger' : 'bg-success', // selectedCountText is what gets printed. selectedCount alone renders nothing. selectedCount: ids.length, selectedCountText: ids.length + ' ' + L.records, info1Title: L.permanentTitle, info1Desc: L.permanentDesc, info2Title: L.providerTitle, info2Desc: L.providerDesc, // Offer the provider switch only when at least one row has a module behind it. showApiSwitch: ids.some(hasModule), // Ask for the password once more than one record is at stake. showPassword: ids.length > 1, passwordHtml: confirmActionRequirePassword, confirmText: L.confirm, data: { ids: ids }, onConfirm: (ctx) => { ctx.setLoading(true); WcpRequest(clink, { method: 'POST', data: { operation: 'bulk_actions', action: action, id: ctx.data.ids, // an array arrives as id[0], id[1], ... apply_on_module: ctx.applyApi ? 1 : 0, password: ctx.password, }, afterDone: () => { ctx.setLoading(false); ctx.close(); // nothing closes it for you WCPTable.get('widgetList')?.reload(); }, afterFail: () => ctx.setLoading(false), }); }, }); } ``` ### Pitfalls > **Do not hand a ready FormData to data** > > The helper walks the object's own properties, and a `FormData` instance has none. The request goes out carrying **nothing**, not even the operation name. The server answers with ordinary markup, console clean. Pass a plain object, or put a prepared body in `options.body`. > **done switches the automatic handling off** > > `done` means no success notice, no redirect and no error alert; you write each again at the call site. Extra work belongs in `afterDone`. > **Supplying onConfirm makes closing your job** > > The dialogs bind an automatic close only when no callback was given. Forget it and the record is gone but the dialog stays on screen, with no error. Close it in `afterDone`, not on the click. > **Repaint a button in finally** > > The original markup is put back **between** the two, so a label written in `afterDone` is silently erased. The colour changes and the text does not. Attributes survive; only the contents are restored. > **Text going to textContent must not be HTML-encoded** > > Encoding a translation into HTML entities is right for `innerHTML` and wrong for everything else. `textContent` and an input's `value` do not decode, so the operator reads `Düzenle`. Hand such strings to JavaScript as JSON. > **Never put raw JSON inside an inline event attribute** > > `onclick="fn(' + JSON.stringify(v) + ')"` puts JSON's double quotes inside a double-quoted attribute. The browser ends the attribute there and the handler is never bound, silently. Escape it (`escHtml(JSON.stringify(v))`) or set the attribute on the element. > **Your page script runs before alert.js and modal.js** > > The footer prints those two after yours. Defining functions is fine; calling `open_modal` or `alert_success` at parse time throws. Put the work inside a handler. ### Related Articles - [Interface Components](https://dev.wisecp.com/en/interface-components) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) - [Operations](https://dev.wisecp.com/en/operations) - [Views and Templates](https://dev.wisecp.com/en/views-and-templates) - [Adding a Dashboard Widget](https://dev.wisecp.com/en/adding-a-dashboard-widget) # Developer Programme ## Joining the Developer Programme https://dev.wisecp.com/en/joining-the-developer-programme Publishing on the Marketplace starts with a developer application: what the form asks, what the decision looks at, and what opens once you are approved. ### Overview The developer programme is a membership. Until your application is approved you cannot create a Marketplace product or open the developer portal. The application and your public profile are one record. The tagline, links and avatar you fill in are the profile itself, published the moment the application is approved. There is no second profile form. Applications are sent from a customer account. That gives the profile and every product you later publish an owner, and it is the account the decision is sent to. ### Prerequisites - **A signed-in customer account**: Visitors cannot apply. Register first, then return to the application page. - **A name on the account**: The name you publish under is read off your account: the company name, or the account holder's name when there is none. The field cannot be edited on the form. - **A domain for the licence**: Required. The developer licence the programme grants is issued for this domain; moving it later goes through the team. - **Work we can look at**: Optional, but the field that weighs most. A link to a module, a theme or a repository says more than any description. - **Developer Agreement accepted**: Nothing is filed until the box is ticked. The server checks it too, not only the page. ### Structure The form has two kinds of fields: the profile text a visitor will read, and the case for your application that only a moderator sees. | Field | Required | Who reads it | | --- | --- | --- | | Developer or studio name | From the account | Everyone — directory card and product byline | | One-line description | Yes | Everyone — the line under your name in the directory | | About your studio | No | Everyone — your public profile page | | What you plan to build | Yes | Moderators only | | Domain to licence | Yes | The team only — the licence is issued for it | | Website, email, support, docs, social profiles | No | Everyone — the contact block on your profile | | Developer avatar | No | Everyone — your initials are shown when empty | An account carries one application at a time. While one is pending or approved, the form cannot be submitted again. ### Walkthrough #### Send the application 1. Sign in and open the developer application page. The page shows which account the application will belong to. 2. Write the one-line description. This is the sentence read directly under your name in the directory. 3. Describe what you plan to build in a few sentences and link to your work if you have any. 4. Enter the domain to licence, tick the agreement box and select **Submit Application**. #### Wait for the decision 1. Applications are reviewed in order. The form stays closed meanwhile, so a second one cannot be sent. 2. The decision reaches your account. Approval publishes your profile in the directory. 3. A refusal carries a reason and shows it on the application page. Once the reason is addressed you may apply again from the same form. #### After approval 1. The developer portal opens. Products, releases and your profile are managed there. 2. A free **WISECP Developer** licence is issued to your account for the domain you entered. 3. Publishing a product additionally requires the Premium Developer subscription. Without it the editor opens read-only. ### Reference - **Pending review**: The application is with a moderator. The profile is not published yet and no new application can be sent. - **Approved**: The profile is live in the directory, the portal is open and the developer licence is issued. - **Not approved**: The reason is shown on the application page. This state does not close the form; correct it and apply again. - **Developer licence**: Granted once per account, for life, and never renewed into an invoice. It cannot be transferred to another account. ### Example The two texts you write are read in two different places. The one-liner belongs to the directory card, the long text to your profile page. ```bash Acme Studio <- the name on your account Server automation and backups <- the one-line description 3 products · 4.6 rating <- computed from your published products ``` Product count and rating are never asked for. Both are computed from your published products, so you do not appear in the directory until your first product goes live. ### Pitfalls > **The published name comes from the account, not the form** > > The studio name field is shown read-only and the value it posts is never read. If the name is wrong, correct your account settings first, then apply. > **Approval is not guaranteed** > > Every application is judged against the programme criteria. One that does not meet them is refused with a reason, and you keep the right to apply again. > **The licence follows the profile** > > The developer licence exists because you are a developer. Switching the profile off cancels it; switching it back on restores it. ### Related Articles - [The Developer Portal](https://dev.wisecp.com/en/the-developer-portal) - [Premium Developer Subscription](https://dev.wisecp.com/en/premium-developer-subscription) - [Becoming a Certified Developer](https://dev.wisecp.com/en/becoming-a-certified-developer) - [Publishing a Marketplace Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Developing for WISECP](https://dev.wisecp.com/en/developing-for-wisecp) ## The Developer Portal https://dev.wisecp.com/en/the-developer-portal The developer portal is where products and releases are managed from draft to live: what each section holds and what every state means. ### Overview The portal opens from inside your customer account and exists only for approved developers. An account without an application cannot reach the address at all. It answers three questions: where your publishing stands, what your products and releases are doing, and whether your public profile is current. Selling does not happen here. The portal publishes your card, stores your package and hands it to buyers; payment is taken on your own site. ### Prerequisites - **An approved application**: The portal opens only for an approved developer profile. - **An open session**: The portal is tied to your customer account; signed out, the address is not found. - **A subscription for writes**: Reading is always open. Creating and submitting products need Premium Developer. ### Structure The portal has five sections, each answering a different question. | Section | What it shows | | --- | --- | | Summary | Published products, total views, review count and average rating | | Marketplace submissions | Each product's step from draft to live, and what is expected of you now | | Developer Account | The certified mark, subscription state, account owner and whether the public profile is live | | Recent products | Product cards: version, views, buy clicks, price and the reason a submission came back | | Developer resources | Links to the docs, the Marketplace, the certification criteria and the request platform | The product and release editors are sub-pages of the portal. From the product list, **Manage** opens the product editor and **Releases** opens its release list. ### Walkthrough #### Create your first product 1. Select **New Product**. Without a subscription the button reads *Subscription Required* instead. 2. Fill the editor section by section; the readiness meter at the top counts what is complete. 3. Once every section is complete, **Submit for Review** becomes available. #### Follow the state 1. The submissions section shows each product's current step and says when something is expected from you. 2. When changes are requested, the reason appears on the card. Correct it and send the product again. 3. An approved product goes live and is listed under your profile in the directory. #### Update your profile 1. Select the gear icon on the Developer Account card. 2. Edit the avatar, tagline, about text and contact addresses. 3. Select **Save Profile**. The changes appear on your public profile immediately. ### Reference - **Draft**: Visible only to you. It can be edited, deleted and submitted for review. - **In review**: Handed to a moderator. You may withdraw it until it is picked up; after that editing and withdrawing close. - **Changes requested**: The reason is written onto the record. The product can be edited and sent again. - **Published**: Visible in the catalogue. Description, artwork and price stay editable; package, version number and licence check do not. - **Retired**: Taken off the catalogue. The record, its reviews and existing buyers' access are kept. - **Card metrics**: Views count openings of the product page; buy clicks count visitors sent on to your own page. ### Example The portal addresses are fixed, and both editors live underneath the same path. ```bash /developer portal home /developer/products product list /developer/products/new new product editor /developer/releases release list ``` These addresses exist only for an approved developer. Opened from any other account the page is not found. That is not a permission error; the address does not exist for them. ### Pitfalls > **Editing closes when review starts** > > Once a moderator picks up the record it cannot be edited or withdrawn. Make final changes before you submit. > **A published product is not deleted** > > Deleting works on drafts only. What you can do with a live product is retire it, which keeps its reviews and its buyers' access. > **A developer without products is not in the directory** > > Even with a published profile, the directory lists only developers who have something published. ### Related Articles - [Joining the Developer Programme](https://dev.wisecp.com/en/joining-the-developer-programme) - [Premium Developer Subscription](https://dev.wisecp.com/en/premium-developer-subscription) - [Preparing a Product for Review](https://dev.wisecp.com/en/preparing-a-product-for-review) - [Publishing a Marketplace Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Shipping a Marketplace Release](https://dev.wisecp.com/en/shipping-a-marketplace-release) ## Premium Developer Subscription https://dev.wisecp.com/en/premium-developer-subscription Premium Developer is the publishing subscription that opens creating and submitting Marketplace products: what it covers, how it starts and what happens when it ends. ### Overview An approved application puts you in the programme. Publishing asks for one more thing: the Premium Developer subscription. The platform takes no share of your sales. The buyer pays on your own page and no money passes through the portal, so the subscription buys publishing infrastructure rather than standing in for a commission. The subscription runs in periods and starts when its invoice is paid. Selecting the button grants nothing on its own; the portal raises an invoice and publishing opens the moment that invoice is settled. ### Prerequisites - **An approved developer profile**: The subscription is bought from the developer portal, which only opens for approved accounts. - **An account that can pay**: The invoice is raised for the account the profile belongs to and is paid from that account's billing page. - **The plan offered by the installation**: When the operator does not sell the plan, the subscription card is hidden and the publishing lock never applies. ### Structure #### What the subscription buys Six things stay open for the length of the period. What it opens is the right to publish, not a number of products. | What you get | What it gives you | | --- | --- | | Unlimited submissions | Products and releases are not counted. Correcting a product that came back for changes and sending it again costs nothing extra | | Manual review | Every product and release passes a quality review and a baseline security assessment, and buyers know it | | Package storage and the licence gate | The platform stores your archive and hands it only to accounts with an active WISECP service. Your own licence check hangs off the same gate | | Catalogue and newsletter reach | Your product reaches Marketplace visitors worldwide and the WISECP newsletters | | Sales without commission | You set the price and take payment on your own page; the platform takes no share | | Profile and the path to certification | Every published product feeds your directory profile. Three published products are the precondition for a certification review | What you pay for it is a flat fee per period: how many products you sell, and how much you earn, do not change that amount. #### Subscription states A subscription is in one of four states. The state is decided by the date, never by a stored status word. | State | What it means | What the portal shows | | --- | --- | --- | | Subscription required | Never purchased | Upgrade to Premium Developer | | Invoice waiting | Invoice raised, not paid yet | Pay Invoice | | Active | The period is running | The end date | | Expired | The period ended without renewal | Renew Premium Developer | Only one open invoice exists at a time. While one is waiting, the purchase button becomes a link to it. ### Walkthrough #### Start the subscription 1. Open the subscription row on the Developer Account card in the portal. 2. Read the period and the amount, then select **Upgrade to Premium Developer**. 3. The portal raises an invoice and sends you to it. Publishing opens as soon as it is paid. #### Renew the period 1. A renewal invoice is raised automatically as the period nears its end. 2. Paying it adds the next period; your products and your right to submit continue uninterrupted. 3. To stop automatic renewal, cancel the subscription. Cancelling does not end the period you already paid for. #### If it has expired 1. Published products stay in the catalogue and buyers keep downloading them. 2. Creating, editing and submitting products close. 3. Paying the renewal invoice opens all of them again; nothing is deleted. ### Reference - **What the fee is based on**: A flat amount per period. Product count, sales volume and revenue do not enter it. - **One open invoice**: Only one exists at a time. While it waits, the purchase button becomes a link to that invoice. - **Cancellation**: Turns off automatic renewal. The period you already paid for runs to its end unchanged. - **Products after expiry**: Published products stay in the catalogue and buyers keep downloading them; nothing is deleted. - **Closed without a subscription**: Creating, saving, uploading files, testing the licence and submitting for review. The request is refused on the server too. - **Open without a subscription**: Viewing the portal, reading your published products and editing your profile details. ### Example A yearly period reads as three dates, and the portal prints all three on the subscription row. ```bash Marketplace subscription : Active Started : 12 January <- the day the invoice was paid Ends : 12 January +1 year Renewal : invoice raised near the end of the period ``` If the invoice is deleted or cancelled, the waiting state clears itself. The portal shows the purchase button again instead of a dead link to a missing invoice. ### Pitfalls > **The purchase button does not start the subscription** > > It only raises an invoice. Publishing stays closed until the invoice is paid, so a period nobody paid for never grants publishing rights. > **A period ending mid-review closes resubmission** > > Sending back a product that came back for changes also needs the subscription. If a review is running long, check the end date. > **The developer licence is separate** > > The WISECP Developer licence granted at approval has no end date and survives an expired subscription. They are two different records. ### Related Articles - [Joining the Developer Programme](https://dev.wisecp.com/en/joining-the-developer-programme) - [The Developer Portal](https://dev.wisecp.com/en/the-developer-portal) - [Preparing a Product for Review](https://dev.wisecp.com/en/preparing-a-product-for-review) - [Publishing a Marketplace Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) ## Becoming a Certified Developer https://dev.wisecp.com/en/becoming-a-certified-developer The certified mark shows developers who passed our review and keep their standard: the five criteria, what the mark gives you and how it is lost. ### Overview Certification is not bought and not requested on a form. It is earned: developers who meet the criteria are reviewed, and the mark is granted by the team. You cannot switch it on yourself. The profile dialog edits your name, tagline and links; the mark and whether the profile is published are the team's decision. The mark is not permanent. It is reviewed periodically and can be removed when the standards are no longer met. ### Prerequisites - **At least three published products**: Add-ons or themes live on the Marketplace. Drafts and pending submissions do not count. - **A 4.0 or higher average rating**: Computed from the reviews your published products received. - **Code and security review**: A separate review run by the WISECP team. It is broader than the one each submission goes through. - **Compatibility with the current release**: Your products keep working with the current WISECP version. - **Answers within 24 hours**: How quickly customer questions are answered. This is why the support address on your profile matters. ### Structure The mark appears on three screens and means the same thing on all of them. | Where | What changes | | --- | --- | | Developer directory | Your card carries the mark and certified developers sort to the top of the list | | Your public profile | The mark sits next to your name; visitors can open it to read the criteria | | Product pages | It appears in the byline, beside the developer name | The directory sorts by certification first, then by the rating the products earned, then by how many are published. The mark lifts you above developers with a similar rating. ### Walkthrough #### Meet the criteria 1. Get three products live. The first usually takes longest; the next ones move faster through the same path. 2. Watch the reviews. The average is computed from all your published products, not from the best one. 3. Test your products on each new WISECP release and ship a new version when one is needed. #### Be ready for the review 1. Fill in the support and contact addresses on your profile. The response criterion runs through them. 2. Keep your product documentation current. Products with missing setup steps generate questions. 3. The code and security review is run by the team. There is no separate form to fill in. #### Keep the mark 1. Reviews repeat periodically, and the mark can be removed when the criteria are no longer met. 2. Retiring a product lowers your published count and may put you under the three-product threshold. 3. If the mark is removed, your profile and products stay live; only the mark and the sorting priority go. ### Reference - **Who grants it**: The WISECP team only. Neither the mark nor the published state can be changed from the developer's profile dialog. - **Directory visibility**: The directory lists only profiles with something published. An approved developer without products does not appear. - **Where the figures come from**: Product count, rating and review totals on your profile are derived from your products, not stored on the profile. - **Periodic review**: Repeated at intervals. The mark can be removed when the criteria are no longer met. ### Example The five criteria are read one by one; meeting four of them is not enough. ```bash Published products : 4 -> met (three or more) Average rating : 4.3 -> met (4.0 or higher) Compatibility : v5 ready -> met Response time : ~2 days -> not met (24 hours) Code review : pending -> run by the team ``` This profile clears the numbers but misses the response criterion. The mark is not granted until the missing one is addressed. ### Pitfalls > **One product cannot carry the average** > > The rating comes from every published product. Leaving an older, poorly rated one unmaintained pulls your newer products down with it. > **The mark is not permanent** > > Each review asks whether the criteria still hold. Retired products and unanswered questions can take it back. > **Certification does not replace product review** > > Every new product and every new release from a certified developer still goes through review. ### Related Articles - [Joining the Developer Programme](https://dev.wisecp.com/en/joining-the-developer-programme) - [Preparing a Product for Review](https://dev.wisecp.com/en/preparing-a-product-for-review) - [The Developer Portal](https://dev.wisecp.com/en/the-developer-portal) - [Shipping a Marketplace Release](https://dev.wisecp.com/en/shipping-a-marketplace-release) ## Preparing a Product for Review https://dev.wisecp.com/en/preparing-a-product-for-review Every product you submit is reviewed by hand: what completes each section, what the review looks at and which gaps come back most often. ### Overview Submission is not automatic. Every product and every new release goes through the WISECP team's quality review and a baseline security assessment. The readiness meter in the editor is a copy of the rule, not the rule itself. On submission the server counts the sections again and refuses anything incomplete. Review is a quality gate, not a design audit. Most requests that come back concern what a buyer reads before purchasing rather than the product itself. ### Prerequisites - **A submittable state**: Only a draft or a product that came back for changes can be sent. One in review or cancelled cannot. - **An active subscription**: Submitting and resubmitting need Premium Developer. - **Verified identity**: When documents are requested, upload them to your account. The product does not enter the queue until they are approved. - **An installable package**: One archive carrying the whole product. Directory layout and what must never go in: [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution). ### Structure A section is complete only when its own answer is. The table below says what completes each one. | Section | What completes it | | --- | --- | | Product identity | Title, category and a one-line summary | | Product pitch | At least 120 characters of description in your primary language, a logo and at least one screenshot | | Pricing | The free model, or a commercial model carrying at least one priced offer | | Licensing | No check declared, or a declared check whose test has passed | | Compatibility | Version number, oldest supported WISECP, and the purchase address on a paid product | | Installation file | The complete package for that version | | Review | Three confirmations: distribution rights, testing, installation documentation. Not asked once the product is live | Licensing is a step of its own and does not follow the price: a free product may require a check, and a paid one may ship without any. The highest supported WISECP version is not asked for — leaving it empty means there is no upper bound. ### Walkthrough #### Before you submit 1. Read the description as a buyer would: what the product does, who it suits, what it needs. 2. Take screenshots from the product's real screens; a cover image alone is not enough. 3. Write the compatibility range from versions you actually tested. The upper end is a gate: installations above it cannot download the package. 4. Leave the package for last, so the archive already carries the licensing you configured above it. #### Submit 1. Tick the three confirmations in the review section. 2. Select **Submit for Review**. The product enters the queue and the editor becomes read-only. 3. You may withdraw it until a moderator picks it up; after that the decision is awaited. #### After the decision 1. When changes are requested, the reason appears on the record. Correct what it names and send the product again. 2. An approved product goes live; every later update goes through its own release submission. 3. While live, description, artwork and price stay editable, and every section opens directly. A new package can still be uploaded from here, but sending it takes the card out of the catalogue until the decision comes back; a version number and a licence change normally travel with a release. ### Reference - **Distribution rights confirmation**: You state that the package holds no third-party content whose licence forbids distribution. - **Testing confirmation**: You state that you tested the product across the range written in the compatibility section. - **Documentation confirmation**: You state that installation and configuration steps are available somewhere the buyer can reach. - **Verified fields**: The licence check, the package and the version number are what a moderator verified. Changing them means a new submission, which is why the editor warns before it accepts one. - **What review covers**: Quality review and a baseline security assessment. It is not a full security audit of the product; responsibility stays with the developer. ### Example A refused submission names each missing section. The reading below shows the three most common gaps. ```bash Product identity complete Product pitch missing -> description 96 characters (120 required) Pricing missing -> commercial model selected, no priced offer Licensing missing -> check declared, test never passed Compatibility complete Installation file missing -> no package uploaded Review complete ``` The character count is measured on the text a visitor reads, after formatting tags are stripped. Empty paragraphs do not reach the threshold. ### Pitfalls > **Declaring a licence check without testing it** > > A declared check must have passed its test, whatever the product costs. An untested one does not complete the section and the submission is refused. > **What must stay out of the package** > > The archive carries the product itself: your configuration file, database dumps, personal keys and development leftovers do not belong in it. > **Downloading requires a WISECP licence** > > Even a free product is handed only to an account with an active WISECP service. The buyer needs a system to install it on. ### Related Articles - [Publishing a Marketplace Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Marketplace Product Licensing](https://dev.wisecp.com/en/marketplace-product-licensing) - [Shipping a Marketplace Release](https://dev.wisecp.com/en/shipping-a-marketplace-release) - [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution) - [Becoming a Certified Developer](https://dev.wisecp.com/en/becoming-a-certified-developer) ## Developer Agreement and Rules https://dev.wisecp.com/en/developer-agreement-and-rules The programme's rules live in three places: the agreement you accept, the confirmations you give on each submission, and the decisions moderation makes. ### Overview You accept the Developer Agreement on the application form, and the application cannot be sent until the box is ticked. Its full text lives on its own page and is always read from there. The agreement states the obligations; the portal enforces them. The three confirmations on every submission, the moderation decisions and the review rules are the working parts of one frame. This article does not replace the agreement. It shows where each rule meets you in the portal and what it changes. ### Prerequisites - **Having read the agreement**: The link on the application form opens the agreement page. The server checks the confirmation as well. - **Distribution rights**: You must hold the right to distribute everything you publish. Third-party content whose licence forbids distribution cannot go in the package. - **A reachable support address**: Buyers need somewhere to reach you, and your profile carries it. Response time is one of the certification criteria. - **Verified identity**: When the operator asks for documents, you upload them to your account. Your product does not enter the queue before they are approved. ### Structure The rules are not all in one place. The table below shows where each one meets you. | Where | What it binds | | --- | --- | | Developer Agreement | The full text: rights, obligations and termination terms | | Application confirmation | Records that you accepted the agreement and the privacy policy | | Submission confirmations | Three per product and release: distribution rights, testing, installation documentation | | Moderation decision | Requesting changes, cancelling and writing the reason, which you read on the record | | Review rules | Your right to answer a review, and how reporting one works | Accepting the agreement does not stand in for the confirmations: you give them again on every submission, because each release is its own commitment. ### Walkthrough #### Meet the obligations 1. Read the agreement before applying; the confirmation box gates the form. 2. Audit what goes in your package: remove any library, theme fragment or image you do not hold the rights to. 3. Keep installation and configuration documentation where buyers can reach it, which is what you confirm on submission. #### Answer the decisions 1. When changes are requested, read the reason and correct what it names. 2. A cancelled product carries its reason on the record. You cannot send the same record again; open a new draft from a copy of it. 3. Retire a product you no longer sell instead of deleting it, so buyers keep their access and the reviews stay. #### Work with reviews 1. You may answer any review on your product, signed with your studio name. 2. Report a review only when it breaks a rule. The report button is shown to the product's owner alone. 3. A report takes the review down until a decision is made. Use it for a violation, not for a rating you dislike. ### Reference - **Distribution rights confirmation**: Given on every submission. Content you do not hold the rights to is one of the grounds for cancelling a product. - **Testing confirmation**: States that you actually tested the range written in the compatibility section. - **Documentation confirmation**: States that installation and configuration steps are open to the buyer. - **Changes requested**: A correctable decision. The reason is written onto the record and the product can be sent again. - **Cancelled**: The record closes and cannot be resubmitted. A new draft starts from a copy of it. - **Review report**: Only the product's developer can report, and the report returns the review to moderation at once. ### Example Reporting a review does three things at the same moment. ```bash Review -> taken down, no longer shown on the product page Rating -> recounted, that review no longer counts Decision -> the review returns to the moderation queue ``` If the decision does not go your way the review is published again. That is why reporting belongs to a review that breaks a rule, not to one you disagree with. ### Pitfalls > **Using a report as an appeal** > > A report silences the review until a decision is made. Removing an honest but negative review this way damages trust in the programme and turns the decision against you. > **Content with unclear rights** > > Dropping a library or an image into the package because you found it online voids the distribution rights confirmation. Verify the source licence before you submit. > **The agreement can change** > > The programme text may be updated. The version in force is always the one on the agreement page; articles summarise it and do not replace it. ### Related Articles - [Joining the Developer Programme](https://dev.wisecp.com/en/joining-the-developer-programme) - [Preparing a Product for Review](https://dev.wisecp.com/en/preparing-a-product-for-review) - [Becoming a Certified Developer](https://dev.wisecp.com/en/becoming-a-certified-developer) - [Publishing a Marketplace Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) ## The Requests Board https://dev.wisecp.com/en/the-requests-board The requests board is where users and developers raise what the platform is missing: how a request is opened, what the states say and who sees which requests. ### Overview The board is a discussion surface rather than a wish list. Each request stands in one person's own words; readers support it, comment on it and the team gives it a state. Requests are not translated. A request stays in the language it was written in, and that language is recorded with it; the team does not rewrite the text. For a developer the board does two jobs: you report the extension points you find missing, and you read what customers are asking for before you plan your own product. ### Prerequisites - **A signed-in account**: Anyone can read the board. Opening a request, voting and commenting need an account. - **A complete profile**: An unfinished account can read the board but cannot put words on it under its own name. - **Moderator approval**: A new request waits for approval. It appears on the board when the team moves it out of the queue. ### Structure A request sits in one of eight states. Everyone sees all but the first. | State | What it says | | --- | --- | | Pending | Newly submitted; visible to its author and the team only | | Under review | Published on the board, the team is reading it | | Considered | The idea landed; its scope is being discussed | | Planned | On the roadmap | | In progress | Being worked on | | Completed | Met by a published release | | Declined | Will not be built; the reason sits under the request | | Already possible | The need is met by the product as it is today | The board sorts by state by default, so work in progress and planned items come first. Newest, oldest and top-rated sorting are available too. ### Walkthrough #### Open a request 1. Search the board first; when the same need is already open, support it instead of opening a second one. 2. Select **New Request** and write the need concretely: what you are trying to do and what stops you today. 3. After submitting, the request waits for approval. Once the team publishes it, it appears on the board and starts collecting votes. #### Support and discuss 1. Vote on a request when you share the need; votes are read during prioritisation. 2. Add something concrete in a comment: the scenario that needs it and your current workaround. 3. Add a request to your favourites to keep following it. #### Follow the outcome 1. When the state changes the badge changes with it; the record keeps the same address. 2. Declined and already-possible requests carry their reason under the record. 3. A completed request closes together with the release that met it. ### Reference - **Language visibility**: Reading the site in English shows the requests written in English. Reading it in any other language lists the whole board. - **Vote**: One vote per account. You may take your support back; the tally comes from the list of voters. - **Comment**: Comments pass through moderation and sort by newest, oldest or most helpful. - **Favourite**: Adds the request to your own list. It changes neither its visibility nor its order. - **Who owns a request**: The text belongs to its author and is never translated. The team sets the state and does not rewrite the words. ### Example A well-written request says three things: what you are trying to do, what stops you today and what it would solve. ```bash Need : a hook that runs before a service is created Today : I work from the post-create hook and cannot stop the order Impact : I want to halt an order when the provider quota is full ``` The distance between a general wish ("make it more flexible") and a concrete scenario decides how quickly a request is assessed. ### Pitfalls > **Opening the same need twice** > > Duplicate requests split the votes and both stay under the threshold. Search first, and support the existing one when it is there. > **The board is not a support channel** > > Open a support ticket for a problem on your own installation. A record here discusses the product's future, not one installation. > **A new request is not visible at once** > > What you submit waits for approval first. If you cannot find it on the board, the record is not lost; it is in the queue. ### Related Articles - [The Developer Portal](https://dev.wisecp.com/en/the-developer-portal) - [Joining the Developer Programme](https://dev.wisecp.com/en/joining-the-developer-programme) - [Developing for WISECP](https://dev.wisecp.com/en/developing-for-wisecp) # Module Development / Module Basics ## The Module System https://dev.wisecp.com/en/the-module-system Every integration is a module: a directory under a type folder. The platform discovers it by name, loads it on demand, and hands it to you through one factory. ### Overview A module is never registered: no manifest to edit, no container entry, no install routine. The registry reads the file system. A directory whose name matches the class file inside it is a module the moment it exists on disk, and the panel lists it on the next request. The directory one level up is the module's **type**, and the type is the whole contract. It decides which methods the core calls, which admin screen lists the module, and whether a base class exists to extend. Eight of the sixteen types have one. The other eight are plain classes whose contract is whatever the core looks for with `method_exists`. - **coremio/modules**: The whole extension surface: one subdirectory per type, one per module inside it. 16 types, 300 modules, including the `Sample` sandbox modules that ship as templates. - **Modules**: The registry and factory, all static. It scans directories, includes class files, caches configuration and language packs, and builds instances. - **Module type**: The parent directory name, with its exact case (`Servers`, `Payment`, `Registrars`). It is a string in every registry call, so a typo gives a silent empty result rather than an error. - **Module name**: The module directory name. The class file and the class carry the same name, character for character, including case. ### Structure #### The Sixteen Types Counts include the sandbox modules whose names start with `Sample`. Those exist to be read and copied, not enabled in production. | Type | Base class | What the core calls it for | Present | | --- | --- | --- | --- | | Servers | `ServerModule` | Provisioning and managing a hosting account, a virtual machine or a game server on a control panel | 50 | | Payment | `PaymentGatewayModule` | Taking money: the payment screen, the capture, the callback and the settlement | 164 | | Registrars | `RegistrarModule` | Registering, renewing and transferring a domain name at a registrar | 21 | | Product | `ProductModule`, `SslProductModule` | A product that is provisioned without a server. All four non-sample modules here are SSL certificate products | 7 | | Addons | `AddonModule` | A feature bolted onto the panel itself: its own settings page, privileges and hooks | 9 | | SMS | none, plain class | Sending a text message through a provider | 11 | | Mail | none, plain class | Delivering outgoing mail, by SMTP or through a provider API | 4 | | Authentication | none, plain class | A second factor at login: mail code, SMS code or an authenticator app | 3 | | Pipe | none, plain class | Pulling mail from a mailbox and turning it into support tickets | 3 | | Imports | none, plain class | Migrating clients, services and invoices in from another platform | 3 | | Fraud | `FraudModule` | Scoring an order or a signup and recording what was found | 2 | | SocialAuth | `SocialAuthProvider` | Social login: the authorisation redirect, the code exchange and the identity token check | 3 | | Captcha | none, plain class | Challenging a public form before it is accepted | 4 | | Currency | none, plain class | Fetching exchange rates for the configured currencies | 7 | | IP | none, plain class | Resolving an address to a country and a network, used for locale and risk | 3 | | Storage | `StorageModule`, `CloudStorageModule` | A remote destination backups are uploaded to and restored from | 6 | #### Discovery and Class Resolution Loading is two separate things. Configuration and the language pack are `include` calls into a static cache. The class file is included only when an instance is wanted, and the loader's third argument controls that split. ```bash coremio/modules/{Type}/{Name}/ ├── {Name}.php # included only when an instance is built (nominc = false) ├── config.php # returns an array, cached under Modules::$modules[Type][Name]['config'] └── lang/ ├── en.php # fallback, always tried when the active language file is missing └── {lang}.php # cached under ['lang'] ``` Three class names are tried in this order, and the first that exists wins. That is why the global namespace and the type namespace both work. ```php $classList = [ $name . "_Module", // legacy suffix form $name, // global namespace "WISECP\\Modules\\" . ucfirst($type) . "\\" . $name, // the form new modules use ]; ``` ### Reference #### The Registry All static, all in one class. The exact signatures: ```php // Build. Returns null when no class of any candidate name exists. public static function getInstance(string $type, string $name, array $params = []): ?object; // Load configuration and language into the static cache. Returns the loaded record(s), // or false when a named module directory does not exist. public static function Load($type = '', $name = '', $nominc = false, $status = ''); public static function add($file, $type, $nominc = false, $status = ''); // Read back from the cache. Config() does NOT load; Lang() does. public static function Config($type, $module); public static function Lang($type, $module, $lang = ''); public static function getName(string $type, string $module): string; public static function getModules($type = '', $name = ''); // Surfaces the panel renders for a module. public static function getPage(string $type, string $name, string $page, array $data = []): string; public static function getController($type = '', $name = '', $cname = ''); public static function view($file, $variables = []): string; public static function logo(string $name = '', $type = 'Servers'): string; // Settings-field rendering, shared by every type that declares fields in config.php. public static function fields_output($data = [], $input_name = ''): string; public static function fields_output_wBuilder(AdminFormBuilder $form, $data = [], $input_name = ''): void; // The module action log the panel shows under the client's action history. public static function save_log($type = '', $module = '', $action = '', $request = '', $response = '', $processed = ''); ``` #### Arguments That Change the Result - **$name = 'All'**: Scan the whole type directory instead of one module. The literal `'All'`, not an empty string: for `Mail` and `SMS` an empty name means something else (see Pitfalls). - **$nominc = true**: No include. Configuration and language are read, the class file is not touched. The cheap call, used by every listing screen. - **$status**: Left as the default empty string it filters nothing. Pass a non-string, in practice `true`, and only modules whose `config['status']` equals it load. That is how the panel lists enabled addons only. - **$params**: Positional constructor arguments. The factory reflects the constructor and pads with `null` up to the required count. A module with a mandatory argument still builds when you pass nothing. #### Return Shapes - **getInstance()**: The object, or `null`. Cached per type, name and serialised parameters, so two calls in one request give the same object. - **Load()**: With a name: `['config' => [], 'lang' => [], 'config_file' => '']`, or `false` when the directory is missing. With `'All'`: a map of name to that same record, ordered by display name. - **Config()**: The cached configuration array, or `null` when nothing has loaded it yet. It never reads the file itself. - **Lang()**: The language array, or `[]`. Unlike `Config()` it does read the file, falling back to the active interface language, then the system default, then English. - **getName()**: The display label: `lang['name']`, then `config['name']`, then the directory name. It loads for you, so it is safe to call cold. ### Example Building one module and calling it, then listing a whole type without building anything. ```php $module = Modules::getInstance("Registrars", "ExampleRegistrarModule"); if (!$module) throw new Exception(Language::gc("modules/error-not-found")); // Guard on the contract, not on the class: a module is plain PHP and may predate a method. if (!method_exists($module, "create")) throw new Exception(Language::gc("modules/error-unsupported")); // Context is bound with setters, not passed as arguments. $module->set_service($serviceId); $result = $module->create(); ``` ```php // nominc = true: read config.php and lang/, do not include any class file. $installed = Modules::Load("Currency", "All", true) ?: []; $rows = []; foreach ($installed as $name => $record) { $rows[$name] = [ 'label' => Modules::getName("Currency", $name), 'active' => (bool) ($record['config']['status'] ?? false), 'logo' => Modules::logo($name, "Currency"), ]; } // Enabled modules only, in one call: the fourth argument is compared to config['status']. $enabled = Modules::Load("Addons", "All", true, true) ?: []; ``` The reading side, when you know the module and need only its settings: ```php // Config() reads the cache only, so the load has to come first. Modules::Load("Servers", "cPanel", true); $config = Modules::Config("Servers", "cPanel") ?: []; $fields = $config['fields'] ?? []; // Same record, one call, when the load result is all you need. $fields = Modules::Load("Servers", "cPanel", true)["config"]["fields"] ?? []; ``` ### Pitfalls > **Never build a module with new** > > The factory includes the class file, fills the configuration and language properties, pads missing constructor arguments and caches the result. Constructing the class directly skips all of it. The module runs with an empty configuration, which looks exactly like a provider returning nothing. > **Reading configuration without loading returns null, not an empty array** > > The read is cache only. Called cold it returns `null`, which flows into your code as a missing setting rather than an error. Load first, or take the configuration from the load result. > **An empty name means the active module for Mail and SMS** > > For those two types only, a load with no name resolves the configured active driver and loads that one. Every other type reads the whole directory. For the full list, always pass `'All'`. > **The instance is shared for the whole request** > > The factory caches per type, name and parameters, so a loop over many services keeps handing back the same object, with whatever a previous iteration bound to it. Bind the new context with the setters at the top of every iteration. > **Dashboard widgets are not a module type** > > Panel widgets come from privilege checks in the dashboard controller and are extended with hooks. A directory under the modules folder does nothing, and there is no widget module type. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Your First Module](https://dev.wisecp.com/en/your-first-module) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Bootstrap and Autoloading](https://dev.wisecp.com/en/bootstrap-and-autoloading) ## Module Anatomy https://dev.wisecp.com/en/module-anatomy The files a module is made of, the three names that must agree, and what the base class hands you. ### Overview A module is a directory with one mandatory class file, plus optional files the platform looks up by name. Nothing is registered: the loader builds each path from the type and the module name. A file in the right place is found, and a misspelled one is ignored without a warning. Most types give you a base class that has already done the setup. Paths, configuration and language are ready before your first method runs, and provisioning types also know which service they act on. Rebuilding any of it by hand is the most common beginner mistake. ### Structure #### The File Tree ```bash coremio/modules/{Type}/{Name}/ ├── {Name}.php # REQUIRED: the class, same name as the directory ├── config.php # returns an array; settings, metadata, field definitions ├── logo.png # or .svg/.webp/.jpg; resolved by name when config does not name one ├── hooks.php # Hook::add() registrations, included on EVERY request ├── AdminArea.php # own admin page, registered from router.php ├── router.php # include + ModuleAdminArea::register() ├── lang/ │ ├── en.php # REQUIRED in practice: the fallback language │ └── tr.php # one file per translated language ├── pages/ # settings and management markup, resolved by page name ├── views/ # same role, alternative directory name ├── controllers/ # named entry points reached through the controller dispatcher ├── assets/ │ ├── style/ # css │ ├── js/ # javascript │ └── images/ # interface images, not the logo └── src/ # your own helper classes, NOT autoloaded ``` #### What Each File Is For | Path | Required | Read when | What it holds | | --- | --- | --- | --- | | `{Name}.php` | Yes | An instance is built | The class. Included unless the loader is told to skip it. | | `config.php` | In practice yes | Every load, with or without the class | An array: settings, metadata, field definitions, enabled flag. | | `lang/en.php` | Yes | Every load | An array. The fallback whenever the active language has no file. | | `lang/{code}.php` | Optional | That language is active | The same keys, translated. | | `logo.png` and friends | Optional | The panel shows the module | The icon. Found by extension when configuration names no file. | | `hooks.php` | Optional | Every request, for every module on disk | Hook registrations. They run whether or not the module is enabled. | | `AdminArea.php` plus `router.php` | Optional | Every request, before routing | An admin page of your own: route, menu entry, privileges. | | `pages/` or `views/` | Optional | A page is shown | Markup addressed by page name; both directory names are tried. | | `controllers/` | Optional | A named controller is dispatched | One file per entry point, tried before the method of that name. | | `assets/` | Optional | The browser requests it | Style, script and image files, addressed through the URL property. | | `src/` | Optional | You include it yourself | Your API client and helpers. The autoloader does not reach here. | ### Reference #### The Three Names That Must Agree - **Directory name**: The module name, and the only thing the loader is given. Case matters on a case-sensitive filesystem: `cPanel` is not `CPanel`. - **Class file name**: The directory name plus `.php`. The loader builds this path and looks nowhere else. - **Class name**: Three candidates are tried in order. The name with a `_Module` suffix, the bare name in the global namespace, then the name under the type namespace. Write new modules as the third form. - **Namespace**: `WISECP\Modules\{Type}`, with the type spelled as the directory is. Inside it, a bare core class name resolves into the module namespace and fails at runtime. Import core classes, or prefix them with a backslash. #### Base Class by Type Eight of the sixteen types have one. The rest inherit nothing; their contract is whatever methods the core looks for. | Base class | Type | Shares the trait | What its constructor already did | | --- | --- | --- | --- | | `ServerModule` | Servers | Yes | Paths, configuration, language, the current admin, the default tool set, and the server when one was passed in. | | `PaymentGatewayModule` | Payment | No | Paths, configuration, language, the pay button label, an empty client info object. | | `RegistrarModule` | Registrars | Yes | Paths, configuration, language, and the crypt sub-key moved from the per-user key to the system key. | | `ProductModule` | Product | Yes | Paths, configuration and language, nothing else. | | `SslProductModule` | Product | Inherited | Abstract. The product base, plus the validation, reissue and SAN contract every certificate module answers. | | `AddonModule` | Addons | No | Paths, configuration, language, the admin area link, the signed-in admin and member records. | | `FraudModule` | Fraud | No | Paths, configuration, language, the current controller link, the signed-in identities. | | `StorageModule` | Storage | No | Abstract. Takes the storage configuration as a constructor array; no directory or URL property. | | `SocialAuthProvider` | SocialAuth | Yes | Abstract. Paths, configuration and language; every endpoint and the token check stay abstract. | #### Inherited Properties From the shared trait, so identical in server, registrar, product and social login modules. | Property | Type | Filled by | Holds | | --- | --- | --- | --- | | `$_name` | string | Constructor | Module name; the class short name when you do not set it. | | `$_type` | string | Constructor | Type directory, as the base class declared it. | | `$dir` | string | Constructor | Absolute path to the module directory, trailing separator included. | | `$url` | string | Constructor | Public URL of the module directory, trailing slash. Build asset links from it. | | `$config` | array | Constructor | The whole `config.php` array, already loaded. | | `$lang` | array | Constructor | Module strings for the active language. | | `$service` | array | Binding a service | The full service record; empty until you bind one. | | `$product` | array | Binding a service or a product | The product the service was ordered from. | | `$order` | array | Binding an order | The order record, while provisioning from an order. | | `$user` | array | Binding a service | Owner identity, contact details and billing address. | | `$admin` | array | Binding a service | The admin acting; empty outside the panel. | | `$options` | array | Binding a service | The service options, where a module keeps its per-service state. | | `$addons` | array | Binding a service | The service add-on records, keyed by add-on id. | | `$addon_params` | array | Binding a service | Configurable add-on values merged into totals; cancelled and waiting excluded. | | `$addon_params_by_id` | array | Binding a service | The same values per add-on, cancelled ones included. | | `$requirement_params` | array | Binding a service | Customer answers to product requirements, keyed by your parameter name. | | `$callable_methods` | array | You declare it | Allowlist of methods reachable by bare name from a URL; anything else is not. | | `$error` | string | Nothing, in new code | Left from the previous major version. Report failures by throwing instead. | #### Inherited Helpers ```php // Context binding. Each takes an id OR the already-loaded record; an int is fetched for you. public function set_service(array|int $service = []): void; public function set_product(array|int $product = []): void; public function set_order(array|int $order = []): void; // Persist $this->options back onto the bound service. False when nothing is bound. public function save_options(): bool; // Rewrite config.php. $auto_status = true flips status on when a settings array is present. protected function save_config($data = [], $auto_status = true); // One log row per provider call, shown in the panel's action history. protected function save_log($action = '', $request = '', $response = '', $processed = ''): int|bool; // Encrypt with the module's crypt sub-key; $key overrides it for one call. protected function encode_str(string $str = '', string $key = ''): string; protected function decode_str(string $str = '', string $key = ''): string; // Resolved logo URL, or an empty string. public function logo(): string; // Metered metric limits enabled on the bound service, keyed by metric type. protected function enabled_metrics(): array; protected function enabled_metric_values(): array; protected function reapply_enabled_metrics(): void; // Data attributes for a dropdown that loads its options from one of your methods. protected function method_url_data(string $method): array; // Dispatch "reset-password" to handle_reset_password(). Returns null when it does not exist. protected function use_method($param = ''); // Add-on values for one option, multiplied by quantity unless the param opts out. public function resolveAddonConfigurable(array $moduleParams, int $quantity = 0): array; ``` - **save_config()**: Writes the whole array, so merge into the existing configuration first. Pass `false` as the second argument when a settings write should not also enable the module. - **encode_str()**: The sub-key is a property, not an argument: `user` by default, switched to `system` by the registrar base. A value encrypted under one key does not decrypt under the other. - **use_method()**: Maps a dashed action name onto a `handle_` method, dashes becoming underscores. Any other name is unreachable from the panel action buttons. - **save_log()**: Arrays are encoded for you. Put the request URL under `api_url` in the request array; it is lifted into its own column. #### The Addon Exception An addon module does not share the trait. None of the properties above exist on it, apart from the four its own base declares. ```php // Properties: $config, $lang, $dir, $url, $area_link, $_name, $user, $admin, $error. public function __construct(); // Render views/{file}.php with $variables extracted into it. Returns the markup. protected function view($file = '', $variables = []): string; // Privilege keys this addon adds to the admin role screen. public function privileges(); // Called by the addon settings screen: $pFields is the posted settings, // $accessPs the posted privilege selection. public function save_settings($pFields, $accessPs): bool; // Enable or disable from the addon list. public function change_addon_status($arg = ''); // Rewrite config.php. Note the single argument: no auto-status flag here. public function save_config($data = []): bool; // Same crypt helpers, but the addon base defaults the sub-key to 'system'. protected function encode_str(string $str = '', string $key = ''): string; protected function decode_str(string $str = '', string $key = ''): string; public function use_default_settings($formElements = null); public function isEnabled(); ``` ### Example A skeleton that declares nothing it inherits, and the caller that drives it. ```php namespace WISECP\Modules\Registrars; use Exception; use Language; use RegistrarModule; use WISECP\Modules\Registrars\AcmeDomains\ApiClient; class AcmeDomains extends RegistrarModule { private ?ApiClient $api = null; // No constructor. The base one already set $_name, $_type, $dir, $url, $config and $lang. // Build the client lazily instead: the service is bound AFTER construction, so anything // that needs $this->service cannot run here. private function api(): ApiClient { if ($this->api) return $this->api; // src/ is not autoloaded, so the file is included explicitly. include_once $this->dir . 'src' . DS . 'ApiClient.php'; $settings = $this->config['settings'] ?? []; $this->api = new ApiClient( (string) ($settings['username'] ?? ''), $this->decode_str((string) ($settings['apiKey'] ?? '')), ); return $this->api; } // NOT create(). The base class owns create() and dispatches to register() or // transfer() depending on whether an EPP code is present. Overriding create() // would drop the transfer branch and both of its hooks. public function register(): array|bool { // The bound record carries the ordered domain in options; service['name'] // is the fallback. There is no 'domain' column on the service itself. $domain = (string) ($this->options['domain'] ?? $this->service['name'] ?? ''); if ($domain === '') throw new Exception(Language::gc("acme/error-no-domain")); // 'year' is the registration length. service['period'] is the billing cycle // string ('y', 'm', 'none'), never a number of years. $year = (int) ($this->options['year'] ?? $this->service['period_time'] ?? 1) ?: 1; $response = $this->api()->register($domain, $year); // Every provider call is recorded; api_url is lifted into its own column. $this->save_log('register', ['api_url' => $this->api()->last_url, 'domain' => $domain], $response); if (!($response['ok'] ?? false)) throw new Exception($response['message'] ?? Language::gc("acme/error-refused")); return true; } } ``` ```php $module = Modules::getInstance("Registrars", "AcmeDomains"); if (!$module) throw new Exception(Language::gc("modules/error-not-found")); // Binding is a separate step, and it is what fills $service, $product, $user and $options. $module->set_service($serviceId); try { // The caller always asks for create(). On a registrar that is the base method, // which routes to register() or transfer() and runs the domain hooks around it. $result = $module->create(); } catch (\Throwable $e) { // A module reports failure by throwing; the caller turns the message into a response. return $operation->output(['status' => "error", 'message' => $e->getMessage()]); } // Module state the module changed goes back to the service through its own helper. $module->save_options(); ``` ### Pitfalls > **The hook file of every module on disk runs on every request** > > A disabled or half-finished module still registers its hooks. Gate the body on the module's own enabled flag, and keep expensive work out of the file. > **Your own helper classes are not autoloaded** > > The autoloader maps the type namespace to the type directory and stops there. An import under the module's source folder loads no file. Include it before first use. A fatal inside a hook silently kills the rest of that hook body. > **An unqualified core class name resolves into your namespace** > > It is looked up under the module namespace and not found, at runtime rather than at lint time. The file appears to work, because the classes you did import are fine. > **Do not build an API client in the constructor** > > Configuration and language are ready there, but the service, product, owner and options are bound afterwards. A client built from service data at that moment reads empty values. It then fails against the provider with a message that points nowhere near the cause. Build it lazily on first use. > **Some base methods are orchestrators, not empty slots** > > A name the core calls is not automatically the name you implement. On a registrar the base already defines `create()`. It chooses between registration and transfer, runs the gate and action hooks, and turns a submitted transfer into a pending state. You write `register()` and `transfer()`. Check the base before overriding. > **Two directory names mean the same thing** > > The names `configuration` and `settings` are also tried for each other. Pick one of each and stay with it. ### Related Articles - [The Module System](https://dev.wisecp.com/en/the-module-system) - [Your First Module](https://dev.wisecp.com/en/your-first-module) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Language Files](https://dev.wisecp.com/en/module-language-files) - [Module Assets and Logo](https://dev.wisecp.com/en/module-assets-and-logo) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) ## Your First Module https://dev.wisecp.com/en/your-first-module Build a working module from an empty directory. The class, its configuration, its language files, and the moment it appears in the admin panel. ### Overview A module is a directory named after the module. It holds a class file of the same name. It also holds the two files every module has: a configuration array and a language folder. Nothing has to be registered anywhere. The module list reads the directory, so a correctly named directory is a module the moment it exists. This walkthrough builds a currency rate module, because that is the smallest type with a real job: it answers three methods. The same skeleton is what every other type starts from, and each type adds its own required methods on top of it. ### Prerequisites - A development installation you can edit and reload. Do not build against an installation you cannot break. - Write access to `coremio/modules`. - The conventions the codebase holds itself to, because a module is read and reviewed like core code. ### Structure Four paths, and the names are not free: the directory, the class file and the class all carry the same name. ```bash coremio/modules/Currency/AcmeRates/ ├── AcmeRates.php # the class, named after the directory ├── config.php # returns an array; the module rewrites it when settings are saved └── lang/ ├── en.php # returns an array; 'name' and 'description' are what the panel shows └── tr.php ``` - **Module type**: The directory one level up (`Currency` here). It decides which contract the class has to satisfy and where the panel lists it. - **Module name**: The directory name. Directory, file and class must match exactly, including case. - **Namespace**: Always `WISECP\Modules\{Type}`. Core classes are then reached with a leading backslash. ### Walkthrough #### Create the Directory 1. Create `coremio/modules/Currency/AcmeRates/`. 2. Create `lang/` inside it. 3. The panel will not list the module yet: it has no class to load. #### Write the Class 1. Create `AcmeRates.php` with the namespace, the class and the three properties every module declares. 2. Load the configuration and the language pack in the constructor, so the rest of the class can read them. 3. Implement the methods the type requires, listed in the contract below. 4. Reload the modules screen; the module is listed with the name from its language file. #### Add Configuration 1. Create `config.php` returning an array of the settings your module needs, with empty or safe defaults. 2. Implement `save_config()` so it merges the submitted values into the array and writes the file back. 3. Write the file through the file manager rather than with a raw write. A configuration file is PHP, and a stale compiled copy keeps serving the old values after a save. 4. Save a setting in the panel and reload; the new value is the one you read back. #### Add Language Files 1. Create `lang/en.php` and `lang/tr.php`, each returning an array. 2. Give both a `name` and a `description`: those two keys are what the module list prints. 3. Reload the list; the module now shows its own name instead of its directory name. ### Reference #### What a Currency Module Must Implement There is no base class to extend. The contract is the set of methods the core actually calls, and each type has its own. For `Currency` it is three: ```php // Rate fetch. Called by Money::get_exchange_rates() and by the panel's connection test, // which checks for it with method_exists first. $to holds the target codes, uppercase. // Return a map of CODE => rate; anything falsy is treated as a failure. public function exchange_rates(string $from = '', array $to = []): array|false; // Settings persistence. The currency settings operation calls it with the submitted // values for this module only, i.e. $_POST['module_data']['AcmeRates']. public function save_config(array $data = []): bool; // The settings markup shown on the currency screen. Called unconditionally on the // instance, so it must exist even if it returns an empty string. public function page_settings(): string; ``` - **Field names in page_settings()**: Inputs must be named `module_data[{ModuleName}][{key}]`; that is the shape `save_config()` receives. A differently named field never reaches the module. - **config['help-link']**: Read by the currency screen and printed next to the module's settings as a link to the provider's own page. Leave it out and no link is shown. - **lang['name'] · lang['description']**: What the module list prints. Without `name` the list falls back to `config['name']` and then to the directory name. #### Core Calls a Module Makes ```php // Modules : the factory and the two loaders. All static. public static function getInstance(string $type, string $name, array $params = []): ?object; public static function Config($type, $module); // cached; requires a prior load public static function Lang($type, $module, $lang = ''); // loads the file itself public static function Load($type = '', $name = '', $nominc = false, $status = ''); public static function getName(string $type, string $module): string; public static function save_log($type = '', $module = '', $action = '', $request = '', $response = '', $processed = ''); // Utility : request and JSON helpers. public static function HttpRequest($url = '', $params = [], $retry = 0); // array first argument, see below public static function jdecode($string = '', $mode = false); // $mode = true for an associative array public static function jencode($string = '', $flags = 0): string|false; public static function array_export($array = [], $options = []); // ['pwith' => true] wraps it as a PHP file // FileManager : the write that invalidates the compiled copy. public static function file_write($file, $data = null, $mode = 'w', $flags = 0); ``` `HttpRequest()` has two shapes; pass an array as the first argument and the rest is ignored: - **url**: Full address. Build query strings with `urlencode()`; nothing escapes it for you. - **type**: Method, defaulting to `'GET'`. A body is only sent when the method is not GET. - **data**: The body. An array is sent as form fields; a string is sent as-is, which is how you post JSON. - **header**: List of raw header lines: `['Authorization: Bearer ' . $key, 'Content-Type: application/json']`. - **timeout · connect_timeout · ssl_verify · allow_ipv6**: Defaults are 30s, 10s, verification on and IPv6 off. Leave the last two alone unless the provider forces you. ### Example The complete class, then the two files it reads. It declares what every module declares and answers the three methods its type asks for. It reports failure the way the rest of the platform does. ```php namespace WISECP\Modules\Currency; class AcmeRates { public string $name = "AcmeRates"; public ?array $config = null; public ?array $lang = null; public function __construct() { $this->config = \Modules::Config("Currency", $this->name); $this->lang = \Modules::Lang("Currency", $this->name); } public function save_config(array $data = []): bool { $merged = array_replace_recursive($this->config ?: [], $data); return (bool) \FileManager::file_write(__DIR__ . DS . "config.php", \Utility::array_export($merged, ['pwith' => true])); } public function exchange_rates(string $from = '', array $to = []): array|false { $key = (string) ($this->config['apiKey'] ?? ''); if ($key === '') throw new \Exception($this->lang['error-no-key'] ?? 'API key is not set.'); $response = \Utility::HttpRequest([ 'url' => 'https://api.example.com/rates?base=' . urlencode($from), 'type' => 'GET', 'header' => ['Authorization: Bearer ' . $key], ]); // Every call is recorded, so a failing provider can be diagnosed from the panel. \Modules::save_log("Currency", $this->name, "exchange", ['from' => $from, 'to' => $to], $response); $data = \Utility::jdecode((string) $response, true); if (!isset($data['rates'])) throw new \Exception('Unexpected response from the rate provider.'); $out = []; foreach ($to as $code) $out[$code] = (float) ($data['rates'][$code] ?? 0); return $out; } public function page_settings(): string { $key = htmlspecialchars((string) ($this->config['apiKey'] ?? ''), ENT_QUOTES); // The field name is the contract: this is exactly what save_config() receives. return '
' . '' . '
'; } } ``` ```php // config.php : the keys page_settings() prints and save_config() writes back. return [ 'apiKey' => '', 'help-link' => 'https://api.example.com/docs', ]; // lang/en.php : 'name' and 'description' are what the module list shows. return [ 'name' => 'Acme Rates', 'description' => 'Exchange rates from the Acme provider. An API key is required.', 'error-no-key' => 'API key is not set.', ]; ``` The other half: how the platform reaches the module. Never construct one with `new`, because the factory is what loads the class, fills the configuration and caches the object. ```php $module = Modules::getInstance("Currency", "AcmeRates"); // Guard on the contract, not on the type: a module is a plain class and may predate a method. if ($module && method_exists($module, "exchange_rates")) $rates = $module->exchange_rates("USD", ["EUR", "TRY"]); // Only the configuration and the language pack, without instantiating anything. Modules::Load("Currency", "AcmeRates", true); $config = Modules::Config("Currency", "AcmeRates"); $label = Modules::getName("Currency", "AcmeRates"); ``` ### Pitfalls > **Report failure by throwing** > > A module signals a problem the same way an operation does: throw, with a message a person can read. The caller catches it and surfaces that message. An `$error` property set alongside `return false` is a leftover from the previous major version. You will see it in older ports, but new code does not use it. > **A configuration file is compiled PHP** > > Write it through the file manager. A raw write leaves the previously compiled copy in place. The panel then shows the old value after a save that actually succeeded. > **Inside the module namespace an unqualified core class does not resolve** > > `Utility::jdecode(...)` written in a module file is looked up as `WISECP\Modules\Currency\Utility` and fails at runtime, not at lint time. Prefix core classes with a backslash or import them. > **Your own helper classes are not modules** > > A class you put under the module's own source folder is ordinary PHP and is constructed with `new`. It is also not autoloaded, so include it before use. The factory is only for the module types the platform knows about. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Language Files](https://dev.wisecp.com/en/module-language-files) - [Writing a Currency Module](https://dev.wisecp.com/en/writing-a-currency-module) ## Module Lifecycle https://dev.wisecp.com/en/module-lifecycle What happens to a module between arriving on disk and being deleted. When it is loaded, when it is built, where its enabled flag lives, and which of your methods the platform calls. ### Overview There is no install routine. Copying the directory into place is the installation, and the module is listed on the next request. Everything after that is a sequence of small, separately triggered steps. Almost all of the methods involved are optional. The platform checks whether your class has them and moves on when it does not. Two ideas are easy to confuse. **Present** means the directory exists. That is enough for it to be listed, to have its hook file executed and to be instantiated by anyone who asks. **Enabled** means an operator picked it, and where that decision is stored depends entirely on the type. Nothing about being disabled stops your code from being loaded. ### Structure #### The Stages | Stage | What makes it happen | What runs in your module | How it is undone | | --- | --- | --- | --- | | On disk | The directory is copied, extracted from an archive, or fetched by the panel | Nothing. No code is executed by the act of arriving | Deleting the directory | | Hooks registered | Every request, for every module directory. No status is checked | `hooks.php` from top to bottom | Only by gating the body yourself, or removing the file | | Loaded | Something asks the registry for this module or for its whole type | Nothing. `config.php` and the language file are read into a static cache | Nothing to undo; the cache lives for one request | | Instantiated | The factory is asked for an object | The base constructor, then yours if you wrote one | Nothing to undo. The object is cached for one request | | Bound | A caller binds a service, an order or a product | Your `set_service` override, when you have one | Binding something else onto the same object | | Enabled | An operator turns it on, or an import is told to activate it | `activate()` then `enable()`, both optional, both able to refuse | Disabling | | Disabled | An operator turns it off | `deactivate()` then `disable()`, both optional | Enabling again, which reruns the enable methods | | Deleted | The delete action on the module list | `uninstall()`, before any file is touched | Restoring the files. Nothing restores data you dropped | ### Reference #### Where the Enabled Flag Lives Four types keep a `status` key in their own configuration file. The rest are selected somewhere else, and two have no flag at all. | Type | Where the decision is stored | Written by | | --- | --- | --- | | Addons | `status` in the module's own `config.php` | The toggle on the addon list, through the base status method | | Product | `status` in the module's own `config.php` | The settings screen for that module group, writing every module's file in one pass | | Fraud | `status` in the module's own `config.php` | The fraud settings screen | | SocialAuth | `status` in the module's own `config.php` | The social login settings | | Mail, SMS, IP, Currency | One module name in the platform's module configuration | The settings screen for that group. Only one can be active at a time | | Payment | A list of module names in the platform's module configuration | The payment settings screen, plus a separate entry naming the card storage gateway | | Authentication | A list of module names in the platform's module configuration | The security settings | | Captcha | The chosen type in the options configuration, alongside an on and off flag | The security settings | | Servers | No flag anywhere | A server record naming the module is what puts it in use | | Registrars | No flag anywhere | A top level domain extension pointing at the module is what puts it in use | | Storage, Pipe, Imports | Chosen at the point of use | The backup destination, the ticket mailbox and the import run respectively | #### Lifecycle Methods You Can Declare The first five are pure convention. No base class declares them; each is looked up with `method_exists` and skipped when absent. A falsy return stops the step it belongs to. The last two are different, and the difference matters. `change_addon_status` is already implemented on the addon base. `testConnection` is declared abstract on the social login base, which makes it mandatory there rather than optional. ```php // Enable path, in this order. activate() runs first, then enable(). // Returning false leaves the module disabled and nothing is written. public function activate(): bool; public function enable(): bool; // Disable path, in this order. public function deactivate(): bool; public function disable(): bool; // Runs before the module directory is removed. Returning false aborts the delete. // This no-argument boolean form is the ADDON one. public function uninstall(): bool; // The Authentication type reuses the name with a different shape: the stored // enrolment data is passed in, and an array is returned. Only 'error' aborts. public function uninstall(array $data = []): array; // ['status' => 'successful'] // The Test button. Two shapes, and the caller decides which one you get: // social login providers are called with NO argument (abstract on the base), // registrars are called WITH the merged configuration as the only argument. public function testConnection(): bool; // SocialAuth public function testConnection($config = []): bool; // Registrars // Already implemented on the addon base: it runs the four methods above and then // writes the new status into config.php. Override only to replace that behaviour. public function change_addon_status($arg = ''); ``` > **Two of these names are reused with a different signature** > > Declaring the no-argument `testConnection()` on a registrar is the trap. The base passes the merged configuration array as the first argument. Your method needs that value to test the credentials the operator typed, and it threw it away. Every shipped registrar declares `$config = []`. The same applies to `uninstall`: the addon form takes nothing, the authentication form takes the stored enrolment array. #### The Addon Status Chain This is the sequence in full. Reading it is the fastest way to see why a failing `enable()` leaves the module exactly as it was. ```php public function change_addon_status($arg = '') { $status = $arg == "enable"; $apply = true; if ($status && method_exists($this, 'activate')) $apply = $this->activate(); if ($status && method_exists($this, 'enable')) $apply = $this->enable(); if (!$status && method_exists($this, 'deactivate')) $apply = $this->deactivate(); if (!$status && method_exists($this, 'disable')) $apply = $this->disable(); // The flag is written LAST, and only when the module agreed. if ($apply) { $config = $this->config; $config["status"] = $status; $this->save_config($config); } return $apply; } ``` The operation that calls it, so you can see how a refusal reaches the operator and where the two arguments come from: ```php $key = (string) Filter::init("POST/module", "route"); $status = (int) Filter::init("POST/status", "rnumbers"); $instance = Modules::getInstance("Addons", $key); if (!method_exists($instance, "change_addon_status")) throw new Exception("Module class does not have a method named change_addon_status."); $status = $status ? "enable" : "disable"; // A thrown Exception travels straight out as the error message. A false return is // the older path: the caller then reads the legacy error property for a reason. $result = $instance->change_addon_status($status); if (!$result) throw new Exception($instance->error ?: "Unknown error"); User::addAction($adata["id"], "alteration", "change-addon-status-" . $status, ['module' => $key]); Hook::run('action:addon.status_changed', $key, $status); ``` #### Lifecycle Hooks Gates can veto, actions only observe. A gate returning a non-empty string turns that string into the error the operator sees. - **gate:module.activate**: Runs before any module group activation is written, with the group and the list of newly activated names. Return a non-empty string, or an array carrying a message, to refuse. - **action:module.activated**: After the group settings were saved, with the names that went from off to on. Only the difference is reported, not the whole selection. The return value is ignored. - **action:module.deactivated**: The mirror of the previous one, with the names that went from on to off. The return value is ignored. - **action:addon.status_changed**: After an addon was enabled or disabled, with the module key and the literal word that was applied. The return value is ignored. - **gate:module.addon_install**: Before an uploaded archive is unpacked, with the upload entry and whether it is to be activated immediately. - **action:addon.installed**: After extraction, with the module key and the activation flag. This is where a marketplace record or an update manifest is written. - **gate:module.addon_delete**: Before an addon is deleted, with its key. Use it to refuse while the module still owns live data. - **action:addon.deleted**: After the directory is gone. The module's own class no longer exists at this point, so listen from somewhere else. - **gate:module.delete**: The same veto for a non-addon module, with the type and the key. - **action:module.config_saved**: After a module's configuration file was rewritten, with the type and the key. Useful for clearing a cache your module keeps. ### Example An enable that creates its own schema, a disable that deliberately keeps the data, and an uninstall that finally removes it. The whole point of the three is that only the last one is destructive. ```php public function enable(): bool { // Idempotent on purpose: enable() runs again on every re-enable and after an update. $this->check_database(); return true; } /** Only ever adds. Never drops, never rewrites an existing column. */ private function check_database(): void { if (!\WDB::hasTable("Acme_events")) \WDB::exec('CREATE TABLE `Acme_events` (' . '`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,' . '`service_id` INT(11) UNSIGNED NOT NULL DEFAULT 0,' . '`payload` TEXT NULL DEFAULT NULL,' . 'PRIMARY KEY (`id`)' . ') ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci;'); // Columns added after the table first shipped are checked one by one. $col = \WDB::query("SHOW COLUMNS FROM `Acme_events` LIKE 'created_at'"); if (!($col ? \WDB::getAssoc($col) : false)) \WDB::exec("ALTER TABLE `Acme_events` ADD `created_at` INT(11) UNSIGNED NOT NULL DEFAULT '0'"); } public function disable(): bool { // Nothing is dropped here. Disabling is reversible, and an operator who turns the // module off for an afternoon must not lose a year of rows. return true; } public function uninstall(): bool { // Refuse rather than destroy silently when there is still something to lose. // select() returns the BUILDER, so build() and getAssoc() are called on it. // Handing the builder to WDB::getAssoc() as an argument is a fatal: that // parameter expects a PDOStatement, which is what WDB::query() gives back. $stmt = \WDB::select('COUNT(id) AS total')->from('Acme_events'); $rows = $stmt->build() ? (int) (($stmt->getAssoc() ?: [])['total'] ?? 0) : 0; if ($rows > 0 && !(int) \Filter::init("POST/purge", "rnumbers")) throw new \Exception($this->lang['error-uninstall-has-data'] ?? 'The module still holds records.'); \WDB::exec("DROP TABLE IF EXISTS `Acme_events`"); return true; } ``` The other side lives in a listener, not in the module. A delete listener has to outlive the class it reacts to: ```php // Runs on EVERY request, for this module, enabled or not. Keep it to registrations. Hook::add('gate:module.addon_delete', 1, function ($key) { if ($key !== 'Acme') return ''; $stmt = WDB::select('COUNT(id) AS cnt')->from('Acme_events'); $stmt->where('processed', '=', 0); $open = $stmt->build() ? (int) (($stmt->getAssoc() ?: [])['cnt'] ?? 0) : 0; // A non-empty string is the refusal, and it is what the operator reads. return $open > 0 ? 'Acme still has ' . $open . ' unprocessed events.' : ''; }); Hook::add('action:addon.status_changed', 1, function ($key, $status) { if ($key !== 'Acme') return; Cache::getInstance()->clear(['acme']); }); ``` ### Pitfalls > **Enable runs more than once, so it has to be idempotent** > > It runs on the first activation, on every re-activation, and it is the usual place an update rebuilds a schema. Check before you create, add columns one at a time, and never let it overwrite a row an operator edited. Seeding is safe only behind a count check on an empty table. > **Disabling is not uninstalling, and deleting is not either** > > Disable must leave every table and every row alone; it is a switch, not a cleanup. Delete removes the directory and nothing else, so anything your module wrote to the database survives it. If the data should go, drop it from the uninstall method, which is the only step that runs before the files disappear. > **A disabled module still registers its hooks** > > Hook files are collected by walking the modules directory, with no reference to any status. Your listeners fire while the module is off unless the body checks the module's own flag first. Treat the hook file as a registration list and put the decision inside each listener. > **Refuse by throwing, with a sentence a person can act on** > > A thrown exception becomes the message on screen. Returning false without a message produces the words "Unknown error". The caller then falls back to a legacy error property that new code does not set, and the operator learns nothing. > **Only the addon delete action asks your class first** > > The uninstall method is invoked by the addon delete action, and by the authentication type when an enrolment is removed. Deleting a module of any other type removes the files after its gate hook has had a chance to refuse. No method on your class is called at all. Put anything that must run at removal time behind the gate rather than in a method nobody invokes. ### Related Articles - [The Module System](https://dev.wisecp.com/en/the-module-system) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Writing an Addon Module](https://dev.wisecp.com/en/writing-an-addon-module) - [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema) ## Module Configuration https://dev.wisecp.com/en/module-configuration Settings an operator can change: the array a module ships with, the field descriptors that become a form, and the write that saves the answers. ### Overview A module's configuration is one PHP file that returns an array. It holds both the defaults you ship and the operator's saved answers, because saving rewrites that same file. There is no settings table and no migration step: the file is the state. Two properties of that file drive most of the mistakes here: it is compiled, and it is readable source. ### Prerequisites - A module that already loads. If you have none, start from [Your First Module](https://dev.wisecp.com/en/your-first-module). - Write permission on the module directory for the web server user; without it every save fails silently at the file layer. - How a form field's name becomes a request key, from [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder). ### Structure #### The Shape of the File The top level is yours apart from a handful of keys the platform reads, which have to be spelled exactly. | Key | Read by | What it holds | | --- | --- | --- | | `meta.name` | The module list | A display name used when the language file has none | | `meta.version` | You, and the update machinery | Your own version string | | `meta.logo` | Logo resolution | A file name inside the module directory, or an absolute address | | `settings` | Your code, and the settings save path | The operator's answers; every declared field lands here under its own key | | `status` | The registry, on status-filtered loads | Whether the module is enabled, for the four types that store it here | | `fields` | Server modules, on the product and service screens | Field descriptors shown on the product configuration form | | `access_ps` | The addon settings screen | The privilege selection saved alongside the settings | | `show_on_adminArea`, `show_on_clientArea` | The addon page router | Whether the addon opens a panel page, a customer page, or both | ```php return [ 'meta' => [ 'name' => 'AcmeDomains', 'version' => '1.0', 'logo' => 'logo.png', ], // Ship every key your code reads, with a safe default. A key that only appears // after the first save is a key your code has to guard on every read. 'settings' => [ 'username' => '', 'apiKey' => '', 'test-mode' => 0, 'nameservers' => ['ns1.example.com', 'ns2.example.com'], 'cost-currency' => 4, ], ]; ``` #### Declaring the Settings Fields You do not write the form: you return an array of descriptors and the admin form builder turns it into one. The array key is the field name; the `name` entry inside it is the label. That pair is the most common thing to get backwards. Which method you declare, and whether it is handed anything, depends on the type. | Type | Method you declare | What the screen passes | Where the saved value comes from | | --- | --- | --- | --- | | Registrars | `config_fields($settings = [])` | The settings block of the current configuration | The argument | | Payment | `config_fields()` | Nothing | `$this->config['settings']` | | Addons | `fields()` | Nothing | `$this->config['settings']` | ```php // The registrar form. The screen calls this with the saved settings block, so // $data is populated. On a payment gateway or an addon the same method is called // with NO argument, and $data would silently stay empty: read the property there. public function config_fields($data = []): array { return [ // KEY is the field name. 'name' is the LABEL. 'username' => [ 'name' => $this->lang['username'] ?? 'Username', 'description' => $this->lang['username-desc'] ?? '', 'type' => 'text', 'value' => $data['username'] ?? '', 'placeholder' => 'api-user', ], 'apiKey' => [ 'name' => $this->lang['api-key'] ?? 'API Key', 'type' => 'password', 'value' => $data['apiKey'] ?? '', ], // A checkbox. 'checked' is the current state, not the submitted value. 'test-mode' => [ 'name' => $this->lang['test-mode'] ?? 'Test Mode', 'type' => 'approval', 'checked' => (bool) ($data['test-mode'] ?? false), ], // Shown only while the checkbox above is ticked. 'test-endpoint' => [ 'name' => $this->lang['test-endpoint'] ?? 'Test Endpoint', 'type' => 'text', 'value' => $data['test-endpoint'] ?? '', 'parent' => 'test-mode', 'parentEffect' => 'hide', ], 'mode' => [ 'name' => $this->lang['mode'] ?? 'Mode', 'type' => 'dropdown', 'value' => $data['mode'] ?? 'live', 'options' => ['live' => 'Live', 'sandbox' => 'Sandbox'], ], ]; } ``` ### Walkthrough #### Ship the Defaults 1. Create `config.php` returning an array with a `meta` block and a `settings` block. 2. Put every key your code reads into `settings`, with an empty or harmless default. Never ship a real credential. 3. Reload the module list; the settings screen now has something to show. #### Declare the Form 1. Add `config_fields($data = [])` to your class, or `fields()` if you are writing an addon. 2. Return one descriptor per setting, keyed by the setting name, with the current value read from the source your type provides. 3. Open the module's settings page. The generic template finds your method and builds the form, submit button and action address included. 4. Change a value and save. The answers arrive under a single request key, `fields`, keyed by your field names. #### Write It Back 1. Merge the posted values into the loaded array rather than replacing it: the write is a full-file write, so anything you drop is gone. 2. Encrypt secrets on the way in, and keep the stored value when the field arrives masked or empty. 3. Write through the file manager, which invalidates the compiled copy. 4. Reload. If you read back the old value, the write went through but the compiled copy did not. ### Reference #### Writing the File ```php // The shared trait, used by server, registrar, product and social login modules. // $auto_status = true turns the module on when the array carries a non-empty settings block. protected function save_config($data = [], $auto_status = true); // Server modules narrow it: no auto-status flag, and a strict boolean return. public function save_config($data = []): bool; // Addons narrow it the same way, and also assign the array to $this->config. public function save_config($data = []): bool; // What all of them call underneath. It invalidates the compiled copy of any .php target. public static function file_write($file, $data = null, $mode = 'w', $flags = 0); // Array to source. ['pwith' => true] wraps it as a complete PHP file. public static function array_export($array = [], $options = []); // Platform configuration, not module configuration. Slash paths into the files // under the configuration directory. public static function get($arg = null); public static function set($key, $values, $merge = false): array|false; public static function save($name = '', $data = []): bool; // Database-backed settings, keyed by name. A module admin area uses these // instead of its own file. public static function getd($name = ''); public static function setd($name = '', $content = ''); ``` #### Field Descriptor Keys - **type**: One of `text` (the default), `password`, `textarea`, `dropdown`, `radio`, `switch`, `approval`, `file`, `output` and `javascript`. An output field prints free markup and is never saved. - **name**: The label shown beside the field. Not the field name: that is the array key. - **value**: The current value for text-like and dropdown fields, and the submitted value for a switch. Checkboxes use `checked` for their state instead. - **options**: A `value => label` map for a dropdown or a radio group. A comma-separated string is accepted and expanded into a map with identical keys and labels. - **description · description_pos · is_tooltip**: Help text, on the right by default and beside the label with `'L'`. With `is_tooltip` it collapses into a question-mark icon. - **parent · parentEffect · parentValue**: Show or disable this field based on another one. The effect is `hide`, `disable` or `collapse`; with a radio parent, `parentValue` lists which options reveal it. The parent must be declared before the child. - **width · wrap_width**: Percentages for the input itself and for its row. A wrap width of one hundred is treated as unset. - **advanced_selector · multiple · rows · disabled**: A searchable dropdown, a multi-value field, the height of a textarea, and a read-only field. The searchable dropdown is the one that can load its options from one of your own methods. - **fieldOptions · rowOptions**: Passed straight through to the form builder for that field and its row. This is the escape hatch for attributes the descriptor has no key for. ### Example The full round trip: what arrives, what is written, and what your code reads back. The saving half is an override of the settings controller, so the merge is visible. ```php public function controller_settings($extraFields = []): array { // Everything the form posted, under one key, named after your descriptor keys. $fields = \Filter::POST("fields") ?: []; // Start from what is already on disk: the write below replaces the whole file. $config = $this->config; $config['settings']['username'] = \Filter::html_clear((string) ($fields['username'] ?? '')); $config['settings']['mode'] = in_array($fields['mode'] ?? '', ['live', 'sandbox'], true) ? $fields['mode'] : 'live'; // An unticked checkbox is ABSENT from the post, so absence is the value "off". $config['settings']['test-mode'] = (int) ($fields['test-mode'] ?? 0) === 1 ? 1 : 0; // Secrets: encrypt on the way in, and keep the stored value when the field came // back masked or empty, which is what the screen sends for an unchanged secret. $posted = (string) ($fields['apiKey'] ?? ''); if ($posted !== '' && !str_starts_with($posted, '*')) $config['settings']['apiKey'] = $this->encode_str($posted); // Full-file write, through the manager that invalidates the compiled copy. \FileManager::file_write($this->dir . 'config.php', \Utility::array_export($config, ['pwith' => true])); return ['status' => "successful", 'message' => \Language::gc("admin/ac-settings/successful1")]; } ``` ```php private function credentials(): array { $settings = $this->config['settings'] ?? []; return [ // Null-safe on every read: a key can be missing on an installation that // upgraded from an older version of your module. 'username' => (string) ($settings['username'] ?? ''), 'apiKey' => $this->decode_str((string) ($settings['apiKey'] ?? '')), 'sandbox' => (int) ($settings['test-mode'] ?? 0) === 1, ]; } ``` The same values read without building the module, which is what a listing screen or a hook does: ```php // Config() reads the static cache only, so the load has to happen first. // The third argument keeps the class file out of it. $record = Modules::Load("Registrars", "AcmeDomains", true); $mode = $record["config"]["settings"]["mode"] ?? 'live'; // The secret is NOT readable from here: decoding is a method on the instance. // If you need the plain value, build the module and ask it. ``` ### Pitfalls > **A configuration file is compiled PHP** > > It is read back with an include, so the compiled copy is served until something invalidates it. The file manager does; a raw write or a rename does not. The operator saves, reloads, sees the old value, and no log explains it. > **Saving replaces the whole file** > > Build the new array from the one already loaded, change the keys you own and leave the rest alone. Passing only the settings block wipes the metadata, the status and everything else in one save. > **A field that was not posted can be written as false** > > The addon settings path walks your declared fields and stores `false` for every one the post did not contain. An unticked checkbox sends nothing, so that is right for checkboxes and wrong for anything shown conditionally. Derive the fields you declare from the same source you read, never a hand-written list. > **Encrypt secrets, and never paste one in by hand** > > An API key belongs in the array encrypted, through the module's own helper, so the sub-key bound to this installation is used. A value typed straight into the file cannot be decrypted and reads as garbage; enter it through the settings screen. > **Module configuration and platform configuration are different things** > > The module file is yours and travels with the module. The platform configuration files hold installation-wide settings, including which module of each single-choice type is active. A module writes only to its own file, and reads the platform files. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Language Files](https://dev.wisecp.com/en/module-language-files) - [Reading and Writing Configuration](https://dev.wisecp.com/en/reading-and-writing-configuration) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) ## Module Language Files https://dev.wisecp.com/en/module-language-files Give a module its own translatable strings. One file per language, how the right one is chosen, and why a missing key does not fall back to English. ### Overview A module carries its strings in a `lang` directory: one PHP file per language code, each returning a flat array. The loader picks exactly one file and hands the whole array to your class as a property. The fallback is per file, not per key. If the active language has a file, that file is all you get, and any key it does not define is absent. Keeping every file on the same key list is what makes the model safe. Measured here: of 297 modules that ship an English and a Turkish file, 295 pairs carry identical top level keys. The other two are Turkish files holding keys English never defines, which is the failure this article is about. ### Prerequisites - A module directory that already loads; the language file is read on every load, class or not. - An English file: the last resort for every language with no file of its own. - Knowing which strings are yours and which belong to the platform, covered in [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files). ### Structure #### The Directory ```bash coremio/modules/{Type}/{Name}/lang/ ├── en.php # the fallback for every language without a file of its own ├── tr.php # same keys, translated └── de.php # add a file per language you support; the code is the file name ``` Every file returns a flat array. Two keys are read by the platform; the rest are yours to name. ```php return [ // Read by the platform: the label and the blurb in the module list. 'name' => 'Acme Domains', 'description' => 'Domain registration through the Acme API. An API key is required.', // Yours. Group them with a prefix so a long file stays navigable. 'username' => 'Username', 'username-desc' => 'The API account this installation connects with.', 'api-key' => 'API Key', 'test-mode' => 'Test Mode', 'error-no-domain' => 'No domain name is bound to this service.', 'error-refused' => 'The provider refused the request.', // Placeholders are positional, and the caller passes the values. 'error-locked' => 'The domain %s is locked and cannot be transferred.', ]; ``` #### How the File Is Chosen The language is resolved first, then the file. The two steps are separate, and only the second has a fallback. | Order | What decides the language | Applies when | | --- | --- | --- | | 1 | The language argument you passed | You named one explicitly | | 2 | The registry's shared language marker | A module was already loaded in some language this request | | 3 | The active interface language | The marker is still empty | | 4 | The installation's default locale | No interface language is selected | | 5 | English | Nothing else answered | ```php // Exactly one file is included. There is no per-key merge with English. if (file_exists($path . 'lang' . DS . $lang . '.php')) $strings = include $path . 'lang' . DS . $lang . '.php'; elseif (file_exists($path . 'lang' . DS . 'en.php')) $strings = include $path . 'lang' . DS . 'en.php'; // Neither present: the property is an empty array, and every read falls to its default. ``` ### Walkthrough #### Add the Files 1. Create `lang/en.php` returning an array with `name` and `description`. 2. Copy it to `lang/tr.php` and translate the values, leaving every key as it was. 3. Reload the module list. The module now shows its own label instead of its directory name. #### Read a String 1. Inside the class, read from the language property with a null-safe default. The key may be missing on an installation running an older translation. 2. For a string with a value in it, keep the placeholder in the language file. Format at the call site, so translators see the whole sentence. 3. Change the panel language and reload. The same code now gives the other file's value. #### Label a Configuration Entry 1. In a settings field descriptor, put the language value into the `name` and `description` entries. That array is built at runtime and can read the property. 2. Where a server module's configuration file holds static data rather than code, write the placeholder form `{lang.key}` instead. The module resolves it against the same array. 3. Open the settings screen in both languages and confirm each label follows. ### Reference #### The Loader ```php // Module strings. Loads the file itself, so no prior load call is needed. // $lang is a language code such as 'en' or 'tr'; empty means "resolve it". public static function Lang($type, $module, $lang = ''); // Module configuration. Reads the static cache ONLY: returns null when nothing loaded it. // This asymmetry with Lang() is the single most surprising thing about the pair. public static function Config($type, $module); // The display label: lang['name'], then config['name'], then the directory name. // It performs the load for you. public static function getName(string $type, string $module): string; // Platform strings, NOT module strings. A module uses these for shared wording only. public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool; public static function gc($name = '', $replaces = [], $slang = ''): array|string|int|bool; public static function selected(): string; ``` - **Lang()**: Returns the array, or an empty array when neither the requested file nor the English file exists. It never returns null, so reading the result is safe. - **Config()**: Returns null when the module has not been loaded, because it only reads the cache. Unlike the language loader, it does not go to disk. - **The shared language marker**: One static value for the whole registry, set to the last language anyone asked for. Passing an explicit language changes it for every later call that omits one. - **When a reload happens**: Only when the requested language differs from the marker, or the module has no cached strings yet. A module already cached is not re-read when the marker moves. #### Keys the Platform Reads - **name**: The label in the module list, and everywhere a module is named. Without it: the configuration's name entry, then the directory name. - **description**: The sentence under the label in the module list. One line in the operator's language: what it connects to and what it needs. - **{lang.key}**: Accepted where a server module's configuration file holds a label as static data: a card item, an add-on parameter. Resolved against this module's own array; an unknown key appears as written, not as an empty string. - **Sorting side effect**: A type listing is ordered by the resolved display name, so translating `name` also moves the module in that language's list. ### Example Both files, then the two places the strings are read: the class, and a configuration entry that cannot call PHP. ```php // lang/en.php return [ 'name' => 'Acme Domains', 'description' => 'Domain registration through the Acme API.', 'api-key' => 'API Key', 'api-key-desc' => 'Found under Account, API in the provider panel.', 'error-locked' => 'The domain %s is locked and cannot be transferred.', 'addon-privacy' => 'WHOIS Privacy', ]; // lang/tr.php : the SAME keys, in the same order, values translated. return [ 'name' => 'Acme Alan Adları', 'description' => 'Acme API üzerinden alan adı kaydı.', 'api-key' => 'API Anahtarı', 'api-key-desc' => 'Sağlayıcı panelinde Hesap, API altında bulunur.', 'error-locked' => '%s alan adı kilitli ve transfer edilemez.', 'addon-privacy' => 'WHOIS Gizliliği', ]; ``` ```php public function config_fields($data = []): array { return [ 'apiKey' => [ // Always with a default: a translation shipped before this key existed // would otherwise render an empty label. 'name' => $this->lang['api-key'] ?? 'API Key', 'description' => $this->lang['api-key-desc'] ?? '', 'type' => 'password', 'value' => $data['apiKey'] ?? '', ], ]; } public function transfer(): array|bool { if ($this->is_locked()) { // The placeholder lives in the language file; the value is applied here, // so a translator sees the whole sentence rather than two fragments. $message = sprintf( $this->lang['error-locked'] ?? 'The domain %s is locked.', (string) ($this->service['domain'] ?? ''), ); throw new \Exception($message); } // A platform string, not a module string: the wording is shared with the rest // of the panel and does not belong in this module's files. if (!$this->credentials()['apiKey']) throw new \Exception(\Language::gc("admin/modules/error-missing-credentials")); return ['status' => 'SUCCESS']; } ``` ```php return [ 'addon-params' => [ // Static data, resolved against lang/ when the screen renders it. 'whois_privacy' => [ 'label' => '{lang.addon-privacy}', 'description' => '{lang.addon-privacy-desc}', 'type' => 'toggle', ], ], ]; ``` ### Pitfalls > **A missing key does not fall back to English** > > A key present in English and absent from the active language is absent, full stop. It shows whatever default your read supplied. Add a key to every language file in the same change, and always read with a default. > **Asking for a specific language moves a shared marker** > > The registry keeps one language marker for the whole request, and requesting a module in another language sets it. Measured: a module already cached kept returning its first language after the marker moved. Do not request a specific language on a display path unless the whole page is in it. > **Module strings and platform strings are different systems** > > Your own wording lives in the module's files, read from the language property. Wording shared with the panel comes from the platform's translation helpers. A module cannot add keys to those, so anything you invent lives in your own files. > **Do not build a sentence out of fragments** > > Concatenating two keys around a value gives word order that works in one language only. Keep the whole sentence in one key with a positional placeholder, and apply the value at the call site. > **The placeholder form only works where it is resolved** > > Writing a language placeholder into an arbitrary configuration value does nothing. It is expanded only where the resolver is called: a server module's card item and add-on parameter labels. Anywhere else it reaches the screen as literal text. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) - [The Module System](https://dev.wisecp.com/en/the-module-system) - [Your First Module](https://dev.wisecp.com/en/your-first-module) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) ## Module Assets and Logo https://dev.wisecp.com/en/module-assets-and-logo Ship stylesheets, scripts and images inside your module, link them without hard-coding a path, and give it a panel icon. ### Overview A module's static files live in the module directory and travel with it. Nothing is copied or registered. The directory is reachable over the web, and the base class hands you its address. Two properties do all the work and are easy to mix up: a filesystem path and a public URL. The logo is separate, with its own resolution order, and the one asset the platform finds by itself. ### Prerequisites - A module extending one of the base classes, so the directory and URL properties are populated. Plain-class types build them themselves. - A hook file, if the asset must reach a page the module does not own. See [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module). - No build step, no manifest, no asset pipeline. ### Structure #### The Assets Directory ```bash coremio/modules/{Type}/{Name}/ ├── logo.svg # the panel icon. Module ROOT, not assets/ └── assets/ ├── style/ # css ├── js/ # javascript └── images/ # images used INSIDE your interface, never the logo ``` Do not repeat the module name in file names. The directory already says which module a file belongs to, so `assets/js/app.js` is the convention. A relative address such as `url(../images/icon.svg)` resolves in a stylesheet, because the file is served from its real location. #### The Two Paths - **$this->dir**: Absolute filesystem path to the module directory, ending in a separator. Read files, check existence, or take a modification time for cache busting. - **$this->url**: Absolute public URL of the same directory, ending in a slash. Everything in markup is built from it: `$this->url . 'assets/style/app.css'`. - **Never a literal path**: Both are set by the base constructor, before your first method runs. A literal path works on your machine only. ### Walkthrough #### Add a Stylesheet 1. Create `assets/style/app.css` in the module directory. 2. Prefix class names with something specific to the module. The panel's stylesheet is on the same page, and a generic name collides silently. 3. Reference images with a relative address, and put them in `assets/images`. #### Load It on the Right Page 1. Decide where it belongs. A page your module builds returns its own styles and scripts; anything else goes through a head hook. 2. In the hook body, return an empty string unless the page needs the asset. 3. Build the address from the URL property, with a cache-busting value from the file's modification time. 4. Confirm it appears in the network panel on that page, and not on an unrelated one. #### Add a Logo 1. Put an image called `logo` in the module root, with an `svg`, `webp`, `png`, `jpg`, `jpeg` or `gif` extension. It is found by name. 2. For another file name, or one in a subdirectory, name it in the configuration under `meta.logo`. 3. Reload the module list; the icon appears next to the name. ### Reference #### Logo Resolution ```php // On the instance: resolves for this module's own name and type. public function logo(): string; // Statically, when you have no instance. Note the type DEFAULT: a call that omits it // looks the module up as a server module. public static function logo(string $name = '', $type = 'Servers'): string; ``` | Order | Source | How it is turned into an address | | --- | --- | --- | | 1 | `meta.logo` in the configuration, or a top level `logo` entry | Used unchanged when it starts with a protocol. Otherwise resolved against the module directory, so `images/logo.png` works | | 2 | A file called `logo` in the module root with a supported extension | Found by pattern, resolved against the module directory. Keep one file | | 3 | A file named after the module, lowercased, in the shared admin logo directory | The last resort. A module with no image still shows a brand icon | | 4 | Nothing matched | An empty string; the caller shows a placeholder | #### Asset Injection Points - **ui:admin.head.css**: Return a complete stylesheet tag, or an empty string. It appears in the panel's head, once per listener. - **ui:admin.head.js**: The same, for scripts. Several tags in one string is normal: a configuration object and the script that reads it. - **ui:client.head.css**: The customer-facing equivalent. Keep them apart: a panel stylesheet on a public page leaks your interface into the theme. - **ui:client.head.js**: Scripts for the customer side. Anything on a public page must survive a signed-out visitor. - **page_styles · page_scripts**: Keys on the array a module admin page returns. Preferred over a hook for a page your module owns: the gating is implicit. - **Cache busting**: Append the file's modification time as a query value, falling back to `meta.version` in the configuration. The instance has no version property, so read it from `$this->config`. Without it an operator gets the old file after an update and reports an unreproducible bug. ### Example The whole pattern in one hook file. A page gate keeps the asset off the rest of the panel. The configuration is handed to the script, not scraped from the markup. ```php // This file runs on EVERY request, for this module, enabled or not. Only register here. $acme_on_page = fn () => in_array(Controllers::$cname ?? '', ['tickets', 'services'], true); Hook::add('ui:admin.head.css', 1, function () use ($acme_on_page) { if (!$acme_on_page()) return ''; $m = Modules::getInstance('Addons', 'Acme'); // dir for the file on disk, url for the address in the markup. $v = @filemtime($m->dir . 'assets' . DS . 'style' . DS . 'app.css') ?: ($m->config['meta']['version'] ?? '1.0'); return ''; }); Hook::add('ui:admin.head.js', 1, function () use ($acme_on_page) { if (!$acme_on_page()) return ''; $m = Modules::getInstance('Addons', 'Acme'); $lang = $m->lang; // Everything the script needs, encoded once. Escaping flags matter: this string // is printed inside a script tag in an HTML document. $config = Utility::jencode([ 'endpoint' => Controllers::$init->ControllerURI(), 'i18n' => [ 'run' => $lang['btn-run'] ?? 'Run', 'failed' => $lang['err-failed'] ?? 'Request failed', ], ], JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); $v = @filemtime($m->dir . 'assets' . DS . 'js' . DS . 'app.js') ?: ($m->config['meta']['version'] ?? '1.0'); return '' . ''; }); ``` The reading side, which never guesses an address and never re-derives a label: ```javascript (function () { var cfg = window.Acme; if (!cfg) return; // asset loaded on a page it was not meant for document.querySelectorAll('[data-acme-run]').forEach(function (btn) { btn.textContent = cfg.i18n.run; btn.addEventListener('click', () => WcpRequest(cfg.endpoint, { data: { operation: 'use_addon_method', method: 'run', id: btn.dataset.acmeRun }, })); }); })(); ``` The logo, declared only when the file is not called `logo` at the module root: ```php return [ 'meta' => [ 'name' => 'Acme', 'version' => '1.0', // Resolved against the module directory. A subpath is allowed, and an // address that starts with a protocol is used exactly as written. 'logo' => 'assets/images/brand.svg', ], ]; ``` ### Pitfalls > **The logo cache is keyed by module name alone, not by type** > > Two modules of different types that share a name share one resolved logo per request. The winner is whichever was asked for first. Give a module a name no other type uses. > **The logo helper does not load the module** > > It reads the configuration from the cache, so a configured logo name counts only when something already loaded that module. Called cold it falls through to a file called `logo` — which is why that name is safe. From an instance it is always safe. > **A hard-coded modules path works only on your machine** > > The same file has a different address on every installation. The application may live in a subdirectory, on another host, or behind another protocol. Both properties account for that; a literal string does not, and it fails as a missing stylesheet elsewhere. > **An ungated asset is loaded on every screen** > > Hook files run for every module directory on every request, enabled or not. A head listener with no page condition adds them to every panel page. Gate on the controller, and have the script return early when its configuration is absent. > **The logo is not an interface image** > > The panel icon stays at the module root, where the resolver looks for it. Images used inside your screens belong in the assets directory. Apart, re-branding is one file, not a hunt through the interface. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Admin JavaScript Library](https://dev.wisecp.com/en/admin-javascript-library) - [Theme Assets](https://dev.wisecp.com/en/theme-assets) # Module Development / Module Surfaces ## Adding an Admin Page https://dev.wisecp.com/en/adding-an-admin-page Give a module its own page in the admin panel with two files. An area class returns the HTML; one line in `router.php` registers it. ### Overview A module that manages its own records or runs a bulk job needs a real page. That page does not belong in the core: you declare a class and register it, and the route, menu entry, privilege check, breadcrumb and theme shell come for free. New to modules? See [Your First Module](https://dev.wisecp.com/en/your-first-module). The dispatcher is `coremio/controllers/admin/module-page.php`; slug routes resolve to it and the result goes to the addon theme wrapper. ### Structure - **classes/ModuleAdminArea.php**: The base class and registry: `register()`, route wiring, menu hook, instance helpers. - **admin/module-page.php**: The internal dispatcher: resolves `page_*` and `op_*`, invisible in the URL. - **tools/addons-area.php**: The theme wrapper: title bar, buttons, plugins, styles, scripts, modals. - **{module}/AdminArea.php + router.php**: The two files you write. `Router::loadModuleRouters()` reads the router file before any route is matched. ### Walkthrough #### Declare the Area Class 1. Create `AdminArea.php` in your module directory, namespaced `WISECP\Modules\{Type}\{Name}`. 2. Extend `\ModuleAdminArea` and return a manifest from the static `manifest()` method. 3. Override `available()` when the page only makes sense under a condition; it guards the menu entry too. #### Register It 1. Create `router.php` next to it: include the class file, then call `\ModuleAdminArea::register(AdminArea::class)`. 2. Nothing else belongs there. Registration adds three routes, stores the manifest and hooks the menu entry. #### Write a Page Method 1. `page_home()` answers the area root; every method takes the trailing URL segments as an array. 2. Build the HTML with heredoc; read your strings from `$this->lang`. 3. Point links at yourself with `$this->link()`, never a hand-written path. #### Write an Operation 1. Name the method `op_{name}`; only that prefix is callable. 2. Post to the area URL with an `operation` parameter; a thrown exception becomes the standard JSON error. 3. Start with `$operation->demo()`, read input through `Filter::init()`, finish with `$operation->output()`. #### Store Area Settings 1. Use `settings()`, `setting()` and `save_settings()`, not a file of your own. 2. `save_settings()` merges into the current values, so a partial save keeps the rest, then fires `action:module.area_settings_saved`. ### Reference #### Base Class API ```php class ModuleAdminArea { public string $module_type = ''; // resolved from the namespace public string $module_name = ''; // resolved from the namespace public array $lang = []; // {module}/lang/{selected}.php, falling back to en.php public function __construct(); // You override this one. Anything else with a default is optional. public static function manifest(): array; public static function register(string $areaClass): void; public static function get(string $type, string $name): ?array; public function available(): bool; // default true public function slug(): string; public function link(array $params = []): string; public function module_dir(): string; // filesystem path, trailing separator public function module_url(): string; // public URL of the module directory public function settings(): array; public function setting(string $key, mixed $default = null): mixed; public function save_settings(array $values): void; protected function license_state_badge(string $slug): string; } ``` > **Eleven module types, not sixteen** > > The accepted types are Servers, Payment, Registrars, Product, Addons, SMS, Mail, Authentication, Pipe, Imports and Fraud. For anything else `register()` does nothing, silently. #### What the Manifest Accepts - **title**: Page and browser title; the menu label when `menu.name` is absent. - **slug**: The URL segment, default the lowercased module name. `Filter::route()` keeps `a-zA-Z0-9`, hyphen, underscore and dot. - **privileges**: Privilege keys, checked once for the whole area. Empty means no check. - **menu**: `['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Hetzner Cloud']`. The path walks down the tree; omit it and no entry is created. - **type, name, class**: Written by `register()` from the namespace and the argument; do not set them. #### URL Scheme and Method Resolution ```php // Three routes, most specific first. $target is module-page/{Type}/{Name}. $router->add($slug . '-2', $slug . '/(?)/(?)', $target . '/(1)/(2)'); $router->add($slug . '-1', $slug . '/(?)', $target . '/(1)'); $router->add($slug, $slug, $target); // Which is why link() derives the route key from the parameter count: // $this->link() -> /{admin}/{slug} // $this->link(['configuration']) -> /{admin}/{slug}/configuration // $this->link(['logs', 'archive']) -> /{admin}/{slug}/logs/archive ``` | Request | Method called | Argument | | --- | --- | --- | | `GET /{admin}/{slug}` | `page_home()` | empty array | | `GET /{admin}/{slug}/configuration` | `page_configuration()` | `['configuration']` | | `GET /{admin}/{slug}/logs/archive` | `page_logs_archive()`, else `page_logs()` | `['logs', 'archive']` | | `POST` with `operation=sync_prices` | `op_sync_prices(Operation $operation)` | the `Operation` | | no matching method | 404 page | nothing appears | Hyphens, dots and commas in a segment become underscores: `/{slug}/price-list` resolves to `page_price_list()`. Operation names too. #### What a Page Method May Return A string becomes the page body; an array passes the keys below to the wrapper. | Key | Type | Effect | | --- | --- | --- | | `content` | string | The page body. Empty shows a `No module area content is available.` alert. | | `page_title` | string | Overrides the manifest title. | | `page_title_buttons` | array | A list of button descriptors; plain HTML shows nothing. | | `page_title_after` | string | Free HTML after the title and buttons. | | `page_title_logo` | string | An image URL, or raw HTML when it contains `<`. | | `content_layout` | string | `panel` by default; `plain` drops the panel. | | `content_data_class` | string | Extra class on the content wrapper (plain layout). | | `breadcrumbs` | array | Appended to the Dashboard + area title trail. | | `plugins` | array | Merged onto the wrapper defaults. | | `page_styles`, `page_scripts`, `modals` | string | Head, footer and modal area. | #### The Signed-in Administrator ```php public static function LoginData($type = 'member', $isRemembered = false, $recheck = false); ``` ```php $adminId = (int) (\UserManager::LoginData('admin')['id'] ?? 0); // 0 outside the panel (CLI, cron) // The operator picker for an assignment field. $staff = \Admin::list(); // [id => ['id' => 1, 'full_name' => 'Jane Doe'], ...] ``` ### Example A two-file area for an imaginary `Acme` module. ```php 'Acme', // 'slug' => 'acme', // default: strtolower(module name) 'privileges' => ['PRODUCTS_OPERATION'], 'menu' => ['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Acme'], ]; } /** Hides the menu entry and 404s the page while no Acme server exists. */ public function available(): bool { static $available = null; if ($available === null) $available = (bool) WDB::select('id')->from('servers') ->where('type', '=', 'Acme', '&&') ->where('status', '=', 'active') ->build(); return $available; } public function page_home(array $params): array { $L = $this->lang; $conf = $this->settings(); $form = new AdminFormBuilder('acmeAreaForm', $this->link(), ['disableStickySubmit' => true]); $form->addHidden('operation', 'save_settings'); $form->addAmount('profit_rate', $L['profit-rate'] ?? '', (string) ($conf['profit_rate'] ?? 25)); $form->addSwitch('auto_sync', $L['auto-sync'] ?? '', $L['auto-sync-desc'] ?? '', '1', (int) ($conf['auto_sync'] ?? 0) === 1); return [ 'content' => $form->render(), 'page_title' => $L['area-title'] ?? 'Acme', 'page_title_after' => $this->license_state_badge('acme'), ]; } public function op_save_settings(Operation $operation): bool { $operation->demo(); $values = [ 'profit_rate' => (float) Filter::init('POST/profit_rate', 'amount'), 'auto_sync' => (int) Filter::init('POST/auto_sync', 'rnumbers') === 1, ]; if ($values['profit_rate'] < 0) throw new Exception($this->lang['err-negative-rate'] ?? 'The profit rate cannot be negative.'); $this->save_settings($values); $adminId = (int) (UserManager::LoginData('admin')['id'] ?? 0); User::addAction($adminId, 'update', 'acme-settings-updated', ['changes' => $values]); return $operation->output([ 'status' => 'successful', 'redirect' => 'reload', ]); } } ``` ```php page_404(); if (($area_info['privileges'] ?? []) && !Admin::isPrivilege($area_info['privileges'])) return 'Access Denied'; $area = new $area_info['class'](); if (!$area->available()) return $this->page_404(); if ($operation = Filter::init('REQUEST/operation')) return $this->area_operation($area, $operation); $page = Filter::route($this->params[2] ?? '') ?: 'home'; $subpage = Filter::route($this->params[3] ?? ''); $filter_method = fn ($param) => str_replace(['-', '.', ','], '_', $param); $method = $filter_method('page_' . $page); // page_logs $method2 = $filter_method($method . '_' . $subpage); // page_logs_archive $page_params = array_slice($this->params, 2); if ($subpage && method_exists($area, $method2)) $result = $area->$method2($page_params); elseif (method_exists($area, $method)) $result = $area->$method($page_params); else return $this->page_404(); if (!is_array($result)) $result = ['content' => (string) $result]; ``` ### Pitfalls > **A page method that ends silently is a fatal, not a routing problem** > > An undefined property or a forgotten import produces no output, and `try/catch` on `Exception` will not catch it because an `Error` is not an `Exception`. The commonest case is `Admin::$data['id']`: that static property does not exist, so read the operator with `UserManager::LoginData('admin')`. > **A slug that collides with a core route breaks link generation** > > Real controller files win at dispatch, so an area registered as `services` is unreachable and overwrites that key for every link. Pick a slug no core route uses. > **A missing parent hides the menu entry without a word** > > The menu hook walks down the tree and returns as soon as a step is absent. If the parent was trimmed by the administrator's privileges your entry is not added, while the page stays reachable by URL. > **Operations are blocked while the licence is not active** > > The area dispatcher checks the licence before it looks for your method and answers with a JSON error. Pages are affected too, at the view layer. > **Area settings live under the module name** > > The row key in the configurations table is the module name as the directory spells it, so renaming the directory orphans the saved settings. Migrate by overriding `settings()`. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) - [Operations](https://dev.wisecp.com/en/operations) - [Building Links and Routes](https://dev.wisecp.com/en/building-links-and-routes) ## Exposing API Endpoints https://dev.wisecp.com/en/exposing-api-endpoints Publish your module's capabilities as REST endpoints by adding route entries to one hook, with no core edit. ### Overview Authentication, per-endpoint permissions, rate limiting, CORS, idempotency and request logging already exist. You declare which address does what, and the Kernel does the rest before it calls your method. What the installation owner sees is a checkbox: each endpoint becomes a line on the API credentials screen, and a key only reaches the endpoints its owner ticked. ```text Settings -> API credentials -> Create [ ] GET /admin/mymodule/items [x] POST /admin/mymodule/items/{id}/rebuild <- only this one was granted [ ] DELETE /admin/mymodule/items/{id} ``` ### Prerequisites - A module with a `hooks.php` file; see [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module). - For write endpoints, an existing panel operation to bridge to rather than a second implementation. - Module `src/` classes are not autoloaded; `include_once` anything `hooks.php` references. ### Structure - **filter:api.routes**: The single connection point, fired once per audience with the route list by reference. Append; the return is ignored. - **Routes / ClientRoutes / ModuleRoutes**: The three registries; each runs the hook with its own `$audience` (see the surface table). - **Kernel::dispatch()**: Sees a group beginning with `Module:` and hands the request to your module instance. - **Config::set()**: Publishes your checkboxes into the in-memory permission catalogue. ### Walkthrough #### Declare the Surface in One Place 1. Create `src/ApiSurface.php` with a `GROUP` constant and a `map()` listing every endpoint: verb, path, action, target. 2. Derive the routes and the permission catalogue from that list, so route, scope and checkbox cannot drift. 3. Write literal paths before their parametric twins. The router takes the first match at a given segment count. #### Register the Routes 1. In `hooks.php`, add a listener on `filter:api.routes` and return when the audience is not yours. 2. Append your tuples to `$routes`. Both parameters arrive by reference, so append, do not return. 3. Publish the permission catalogue from the **body** of `hooks.php`, outside every listener. #### Write the Handler Methods 1. Add one `api_{action}` method per endpoint. A hard `method_exists` check means `__call` will not answer. 2. Give every method the same one-line body forwarding to your bridge, and generate them from the declaration. 3. Return a `Response`, or a legacy envelope array that the Kernel normalises. 4. For a list, keep the envelope identical to the core resources: `page`, `limit` and `search` in, `meta.total`, `meta.page`, `meta.limit` and `meta.next_page` out. #### Bridge Writes to the Panel Operation 1. Mirror the superglobals, call `op_{name}`, capture what it prints, then restore them in a `finally` block. 2. Translate the printed envelope: drop `data.html`, move `message` into `meta`, and turn a thrown exception into a validation error. 3. Let the operation's privilege gate step aside during an API call; the Kernel applied a narrower check. ### Reference #### The Hook and the Route Tuple ```php // The listener. Both arguments are references; append to $routes and return nothing. Hook::add('filter:api.routes', 20, function (array &$routes, string &$audience): void { if ($audience !== 'admin') return; // 'admin' | 'client' | 'module' // ... }); // One entry. Index 6 exists only on the free surface. // [0] string $method GET | POST | PUT | PATCH | DELETE // [1] string $pattern full path after the surface prefix; {x} captures a path parameter // [2] string $group 'Module:{Type}/{Name}' routes to your module instance // [3] string $action the permission name AND the api_{action} method suffix // [4] bool $public false = a credential is required (default false) // [5] bool $authOnly false = ALSO enforce the scope "Group/Action" // default false on admin/client, TRUE on the free surface // [6] string $audience free surface only: 'admin' (default) | 'client' | 'any' $routes[] = ['POST', 'mymodule/items/{id}/rebuild', 'Module:Addons/MyModule', 'item_rebuild', false, false]; ``` | Surface | Audience value | Address the entry answers on | | --- | --- | --- | | credentialed admin | `admin` | `/api/v1/admin/{pattern}` | | customer | `client` | `/api/v1/client/{pattern}` | | free | `module` | `/api/v1/{pattern}` | The scope a route enforces is always `{group}/{action}`, so the entry above is spent against `Module:Addons/MyModule/item_rebuild`. Appending never shadows a core address: the core entries are registered first and the router returns the first match. To take over one, edit its tuple in place. #### The Handler Contract ```php public function api_item_rebuild(Request $request, array $match): Response; // $request, every property public and already parsed: // string $method 'GET', 'POST', ... // string $audience 'admin' | 'client' | '' // array $segments the path split on '/' // string $resource the first segment // array $query the query string // array $body the decoded JSON body (or the form body) // array $headers lower-cased header names // string $ip the resolved client address // ?string $token the raw credential, when one was sent // ?string $idempotencyKey // string $rawBody // $match, produced by the router: // 'group' => 'Module:Addons/MyModule' // 'action' => 'item_rebuild' // 'params' => ['id' => '42'] the {x} captures, keyed by name // 'scope' => 'Module:Addons/MyModule/item_rebuild' // 'public' => false // 'authOnly' => false // 'audience' => 'admin' ``` #### Building the Response ```php class Response { public function __construct(int $status = 200, array $payload = []); public static function success($data = null, int $status = 200, array $meta = []): self; public static function error(string $code, string $message, int $status = 400, array $details = []): self; public static function fromLegacy(array $ret): self; // {status, message, data} envelope public function withHeader(string $name, string $value): self; public function withHeaders(array $headers): self; public function getStatus(): int; public function getPayload(): array; public function send(): void; // the Kernel calls this, you do not } // Failures are thrown, not returned. Each factory carries its own HTTP status. class ApiException extends Exception { public static function badRequest(string $message, string $code = 'bad_request', array $details = []); public static function unauthorized(string $message = 'Authentication required.', string $code = 'unauthorized'); public static function forbidden(string $message = 'Insufficient scope.', string $code = 'forbidden'); public static function notFound(string $message = 'Resource not found.', string $code = 'not_found'); public static function methodNotAllowed(string $message = 'Method not allowed.', string $code = 'method_not_allowed'); public static function validation(string $message, array $details = [], string $code = 'validation_failed'); public static function rateLimited(string $message = 'Too many requests.', string $code = 'rate_limited'); public static function server(string $message = 'Internal server error.', string $code = 'server_error'); } ``` #### Publishing the Checkboxes ```php public static function set($key, $values, $merge = false): array|false; ``` ```php // The third argument switches the merge from array_replace_recursive to array_merge, so your // action list is written whole instead of being blended index by index into a same-named list. // The catalogue's other groups survive either way. Config::set('api-actions', ['Module:Addons/MyModule' => ['items_list', 'item_rebuild']], true); ``` Only the in-memory copy is written; `Config::save()` is never called here, so `coremio/configuration/api-actions.php` stays as the core shipped it. #### How Far a Granted Scope Reaches | Granted on the key | What it opens | Safe to recommend | | --- | --- | --- | | `Module:Addons/MyModule/item_rebuild` | that one endpoint | yes | | `Module:Addons/*` | **every Addons module on the installation** | no | | `*` | the whole admin API | no | > **There is no wildcard for one module** > > The gate cuts the required scope at the *first* slash and your group already contains one. So the group of `Module:Addons/MyModule/x` is `Module:Addons`, and the only wildcard matching it also matches every other Addons module. ### Example A minimal admin surface: declaration, registration, one handler and the bridge. ```php $e[2], self::map()); if (!$actions) return; Config::set('api-actions', [self::GROUP => $actions], true); } /** @return array{0:string,1:string}|null [kind, name] for an action, e.g. ['op', 'rebuild_item'] */ public static function target(string $action): ?array { foreach (self::map() as $entry) if ($entry[2] === $action) return array_pad(explode(':', $entry[3], 2), 2, ''); return null; } } ``` ```php query, $request->body, $match['params'] ?? []); $snapshot = [ 'post' => $_POST, 'get' => $_GET, 'request' => $_REQUEST, 'method' => $_SERVER['REQUEST_METHOD'] ?? '', ]; $_POST = $_REQUEST = $input; $_GET = []; $_SERVER['REQUEST_METHOD'] = 'POST'; // operations assume a form POST self::$in_api = true; $failure = null; ob_start(); try { $area->{'op_' . $name}(new Operation($name)); } catch (Exception $e) { $failure = $e; } finally { $printed = (string) ob_get_clean(); self::$in_api = false; $_POST = $snapshot['post']; // hand the borrowed request back clean $_GET = $snapshot['get']; $_REQUEST = $snapshot['request']; $_SERVER['REQUEST_METHOD'] = $snapshot['method']; } if ($failure) throw ApiException::validation($failure->getMessage(), [], 'operation_failed'); $envelope = Utility::jdecode($printed, true) ?: []; $data = $envelope['data'] ?? null; if (is_array($data)) unset($data['html']); // the panel's modal body is not API payload return Response::success($data, 200, ['message' => $envelope['message'] ?? '']); } ``` The operation's own privilege gate has to step aside for the bridge, and only for the bridge: ```php private function require_operation(): void { // No signed-in operator exists during an API call, and the Kernel already checked a // narrower permission: this exact endpoint's scope. if (ApiBridge::in_api()) return; if (!\Admin::isPrivilege(['MY_MODULE_OPERATION'])) throw new Exception($this->lang['err-no-privilege'] ?? 'You do not have permission for this action.'); } ``` ### Pitfalls > **A parametric path declared first swallows its literal twin** > > With `items/{id}` above `items/export`, a request for the export answers **200** from the detail handler with `id = "export"`. Nothing is logged and nothing fails. Feed a concrete value through the router and check the action. > **Publishing the catalogue inside the listener hides every checkbox** > > The settings screen reads the catalogue before it asks for the scope map, so a listener that has not fired publishes nothing. The routes still work and a hand-written key still fails the scope check, which looks like a permissions bug. Call it from the file body. > **A magic __call will not answer** > > The dispatcher tests `method_exists` before calling and answers `404 action_not_implemented` when it fails. That is deliberate: one real method per endpoint is what makes each endpoint separately grantable. > **Kernel::internal cannot reach these endpoints** > > It splits its argument at the first slash and your group already has one, so the lookup fails with `endpoint_not_found`. Code inside the installation calls your classes directly. > **The category label prints the raw group name** > > The tab label comes from a core translation key a module cannot add, so the screen shows `Module:Addons/MyModule` verbatim. If you rewrite the visible text, never touch the checkbox values: they are the scope strings the gate compares. > **A licensed module has to repeat its own licence gate** > > The panel dispatcher checks the licence before every operation and an API request does not pass through it. Put the same check at the top of your bridge and answer `403`. On a local machine that gate is closed permanently. ### Related Articles - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [Operations](https://dev.wisecp.com/en/operations) - [The WISECP API](https://dev.wisecp.com/en/the-wisecp-api) ## The Client Area Bridge https://dev.wisecp.com/en/the-client-area-bridge Call a method on your addon module from the customer's browser. The operation already exists: no controller, no route, no endpoint of your own. ### Overview An addon page in the client area is HTML your module produced. Then it needs to do something: save a preference, fetch a status, start a job. One operation is already wired on the website, and it calls any method whose name starts with `use_`. The admin panel has the same bridge behind the tools controller. What differs is who is allowed through, and that difference is the security part of this article. ### Prerequisites - An Addon module with `status` true in its configuration; a disabled addon is invisible to the bridge. - A web face: the client area opt-in (`show_on_clientArea` plus `clientArea()`) or a public `main()` page. Without one the bridge refuses every call. - `coremio/modules/Addons/SampleAddon`, a working demonstration of both faces. ### Structure - **controllers/website/addon.php**: The website face: resolves `/addon/{Name}`, produces the page and dispatches the bridge operation. - **operations/ClientAddon.php**: The trait holding `use_addon_method`, plus the gate that decides whether your addon has a web face. - **operations/AdminTools.php**: The admin twin: same operation name and prefix rule, behind an administrator session. - **AddonModule**: Your base class. Supplies `$area_link`, `$error`, `$config`, `$lang`, `$dir`, `$url` and `view()`. ```text browser POST /addon/MyAddon operation=use_addon_method & method=save-preference | +-- addon controller -> addon_ctx() enabled config + module instance + web face? +-- ClientAddon -> member session required when the face is the client area +-- name normalised -> 'save-preference' becomes use_save_preference +-- method_exists -> refuse when absent +-- $module->use_save_preference() called with NO arguments +-- falsy return -> Exception($module->error) '-- array or string -> JSON body ``` ### Walkthrough #### Give the Addon a Web Face 1. Set `show_on_clientArea` to true in `config.php` and add a `clientArea()` method returning `['page_title' => …, 'breadcrumbs' => …, 'content' => …]`. That makes `/addon/{Name}` the customer page and requires a signed-in member. 2. Or add a `main()` method for a public page, which carries no session guarantee at all. 3. Set `meta.slug` for a pretty address; the controller rewrites `$area_link` to it. #### Write the use_ Method 1. Name it `use_{something}`; nothing else is callable, and that prefix is the whole boundary at the dispatch layer. 2. Take no parameters. Read your input yourself with `Filter::init("POST/…")`, as an operation does. 3. Return a non-empty array or string; a falsy return counts as failure and raises an exception carrying `$this->error`. 4. For a real failure, throw: the caller converts it into the standard error envelope. #### Call It from the Page 1. Post to `$this->area_link`, which the controller already points at the correct address. Never hand-write the path. 2. Send `operation=use_addon_method` and `method={name without the prefix}`, plus whatever else your method reads. 3. On the website use plain `fetch` with the `X-Requested-With` header; in the admin panel use `WcpRequest`. ### Reference #### The Two Endpoints | Face | Address | Who gets through | | --- | --- | --- | | client area | `/addon/{Name}` or the configured slug | a signed-in member, if the addon opted in | | public page | `/addon/{Name}` | **anyone**, with no session | | legacy alias | `/addon/{Name}/client` | as the client area; 404 without the opt-in | | admin panel | the tools addons address | an administrator with the tools privilege | | admin-only addon called from the website | any of the above | nobody: the operation throws `Addon not found.` | > **The web face check is a real security boundary, added after a real hole** > > Before it existed, an admin-only addon's `use_*` methods were reachable from the website with no session. Settings were overwritten, ticket data read, paid API credit burned. Treat it as the outer wall, not the only one. #### The Operation and the Name Rule ```php public function use_addon_method(Operation $operation): bool; ``` ```php $method = (string) Filter::init("REQUEST/method", "route"); // keeps a-zA-Z0-9 - _ . $method = "use_" . str_replace([' ', '-', '.'], '_', $method); // spaces, hyphens, dots -> underscore if ($method === "use_" || !method_exists($module, $method)) throw new \Exception("Undefined addon method."); $result = $module->{$method}(); // NO arguments if (!$result) throw new \Exception((string) (($module->error ?? '') ?: 'An error occurred')); return $operation->output($result); ``` So `method=save-preference`, `save.preference` and `save_preference` all reach `use_save_preference()`. The `route` filter runs first, keeping only `a-zA-Z0-9`, hyphen, underscore and dot, so a name cannot escape into another class. #### What Your Method Must Return ```php public function use_sample_method(): array|string; ``` | You return | The customer receives | Use it for | | --- | --- | --- | | a non-empty array | that array, JSON encoded | the normal case; keep the standard keys | | a non-empty string | the string, written as is | a ready HTML fragment for the DOM | | `[]`, `''`, `false` or `null` | `{"status":"error","message":"…"}` built from `$this->error` | nothing: a legacy path, do not design for it | | a thrown exception | `{"status":"error","message":"your message"}` | every real failure | The envelope to aim for matches the rest of the panel: ```php return [ 'status' => 'successful', 'message' => $this->lang['saved'] ?? 'Saved.', 'data' => ['preference' => $value, 'updated_at' => time()], ]; ``` #### What the Base Class Hands You ```php class AddonModule { public string|bool $error = ''; // read by the bridge when your method returns falsy public array $config = []; // config.php merged with the saved settings public array $lang = []; // lang/{selected}.php public string $area_link = ''; // the address to post to; REWRITTEN per face public string $_name = ''; // the directory name public array $user = []; // the signed-in member, when there is one public array $admin = []; // the signed-in administrator, when there is one public string $url = CORE_FOLDER . DS . MODULES_FOLDER . DS . 'Addons' . DS; public string $dir; // filesystem path of the module directory // The constructor appends {Name} to $url and resolves it into a public URL, fills $dir, // $config, $lang, $user and $admin, and points $area_link at the current face. public function __construct(); protected function view($file = '', $variables = []): string; public function privileges(); public function save_settings($pFields, $accessPs): bool; public function change_addon_status($arg = ''); public function save_config($data = []): bool; public function use_default_settings($formElements = null); public function isEnabled(); } ``` > **area_link is not one fixed value** > > In the panel it points at the tools addons address. On the website the addon controller overwrites it with the public address. Print it rather than building a path. #### Reading Core Data ```php // $groupAction is "Group/Action" from the route registry; prefix it with "client:" for the // customer registry, in which case $input must also carry owner_id. public static function internal(string $groupAction, array $input = [], array $query = []): array; ``` ```php use WISECP\Api\Kernel; // In process: no HTTP, no authentication, no rate limit, but the SAME envelope the // external API returns. $input fills the path parameters first, then the body. $resp = Kernel::internal('Tickets/GetTicketMessages', ['id' => $ticketId], ['limit' => 50]); $replies = $resp['data'] ?? []; ``` Prefer this over calling a helper directly: the resource layer hands back decrypted, allow-listed and normalised rows. ### Example A client page that stores one customer preference. ```php $this->lang['meta']['name'] ?? 'My Addon', 'breadcrumbs' => [['link' => '', 'title' => $this->lang['meta']['name'] ?? 'My Addon']], 'content' => $this->view('client.php', [ 'link' => $this->area_link, // post here 'preference' => (string) (User::getInfo((int) $member['id'], ['my_addon_pref'])['my_addon_pref'] ?? ''), ]), ]; } /** * Reached as method=save-preference. No parameters: it reads its own input, exactly * like an operation, and it re-reads the account from the session rather than trusting * anything the request sent. * * @throws Exception */ public function use_save_preference(): array { $member = UserManager::LoginData('member'); if (!$member) throw new Exception(Language::gc('website/index/addon-login-required') ?: 'Login required.'); $value = Filter::init('POST/preference', 'route'); if ($value === '' || !in_array($value, ['daily', 'weekly', 'never'], true)) throw new Exception($this->lang['err-bad-preference'] ?? 'Choose one of the offered options.'); User::AddInfo((int) $member['id'], ['my_addon_pref' => $value]); return [ 'status' => 'successful', 'message' => $this->lang['saved'] ?? 'Saved.', 'data' => ['preference' => $value], ]; } } ``` ```html ``` The same call from an admin page: ```javascript WcpRequest(AREA_LINK, { method: 'POST', data: { operation: 'use_addon_method', method: 'save-preference', preference: 'weekly' }, button: runBtn, buttonLoader: window.saving_loader, done: function (response) { // omit `done` and the standard handling applies output.textContent = JSON.stringify(response, null, 2); } }); ``` ### Pitfalls > **A public page makes every use_ method public too** > > An addon whose web face is `main()` is reachable with no session by design, for compatibility with older modules. Ask, for each method, who should be able to call it. Write that check in the method. > **Do not trust an account id that arrives in the request** > > The bridge authenticates the visitor, not the record. A method that reads a customer id from the body serves another customer's data to whoever asks. Read the account from the member session. > **An empty successful result reads as a failure** > > Returning `[]` after a legitimately empty query produces an error envelope carrying whatever is in the error property, often an empty message. Return an array whose data field is the empty list. > **Saving through the settings operation overwrites status and access privileges** > > The standard settings save writes the status flag and access privilege list along with your fields, so a custom save that reuses it can disable your addon. Give the custom save its own `use_` method. > **A disabled addon answers nothing** > > The context resolver checks the enabled flag before it builds the instance, so a call against a disabled addon fails with `Addon not found.`, not a method error. ### Related Articles - [Writing an Addon Module](https://dev.wisecp.com/en/writing-an-addon-module) - [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [Operations](https://dev.wisecp.com/en/operations) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [The Client Area](https://dev.wisecp.com/en/the-client-area) ## Registering Hooks from a Module https://dev.wisecp.com/en/registering-hooks-from-a-module Put a `hooks.php` in your module directory: that is how a module reaches into the core without editing it. ### Overview Hooks are the whole extension surface. From one file it owns, a module catches an event, changes a value, injects markup, registers a capability or refuses an operation. Nothing in `coremio/` knows your module exists. The catalogue holds **979** hook points in five categories: `ui` 343, `action` 298, `filter` 196, `gate` 131, `register` 11. Browse it rather than guessing a name. ### Prerequisites - A module directory at `coremio/modules/{Type}/{Name}/`; any of the sixteen types works. - The hook name, copied from `hooks/INDEX.md`. A name that does not exist fails silently. - Its page under `hooks/{domain}/`: parameters, which are references, and the return contract. ### Structure - **classes/Hook.php**: The engine: registration, priority ordering, argument binding, and the loader that finds your file. - **{module}/hooks.php**: Your listeners. Found by a glob over `coremio/modules/*/*/hooks.php`: the name and location are fixed. - **coremio/hooks/**: The core's own listener files, loaded in the same pass, before the module files. - **hooks/INDEX.md**: The generated catalogue: every hook point, its domain, and the file and line that fires it. - **{module}/router.php**: A second, earlier entry point: loaded while the router is built, before hook files. ### Walkthrough #### Create the File 1. Add `hooks.php` at the root of your module directory, beside `{Name}.php` and `config.php`. 2. It is included; write plain statements at the top level. 3. Classes under your own `src/` are not autoloaded, so `include_once` them before you name them. #### Gate It on Your Own State 1. Load your configuration without instantiating the module: pass `true` as the loader's third argument. 2. Wrap every listener in a check on your enabled flag, and on your licence when the module is licensed. 3. Keep the file cheap; it runs on every request that touches a hook. #### Register a Listener 1. Call the add method with the hook name, a priority and either a closure or a descriptor array. 2. Declare only the parameters you need; the engine matches them positionally. 3. To change a value, declare that parameter by reference; only the reference variant carries it back. #### Choose a Priority 1. Lower runs first. There is no default, so pass a number deliberately. 2. Use a low number to shadow a later entry, as route overrides do, and a high one to append last. ### Reference #### Registering ```php class Hook { // $properties is EITHER a callable OR a descriptor array (see the three forms below). public static function add($name, $priority, $properties = []): void; public static function run($name, ...$args): array; // by value public static function runRefs($name, &...$args): array; // EVERY argument by reference public static function runDetailed($name, ...$args): array; // per-listener telemetry } ``` | Run method | Returns | On a listener exception | | --- | --- | --- | | `run` | every non-null listener return, in priority order | logged, then the next listener runs | | `runRefs` | the same, plus your reference edits reach the caller | logged, then the next listener runs | | `runDetailed` | `[['source' => ['type','class','method','file','line'], 'value' => …, 'error' => ?string], …]`, nulls kept | captured in `error` | | any, unknown name | an empty array | nothing happens; a typo is silent | #### The Three Registration Forms ```php // 1. Closure. Anything callable goes in directly. Hook::add('action:service.created', 10, function ($id, $data) { // ... }); // 2. Instance method. The class is constructed ONCE with NO arguments and cached for the // whole request, so its constructor must work without parameters. Hook::add('filter:invoice.totals', 10, [ 'class' => 'MyAddonHooks', 'method' => 'adjustTotals', ]); // 3. Static method. Nothing is constructed. Hook::add('ui:admin.service_detail.bottom', 10, [ 'class' => 'MyAddonHooks', 'method::static' => 'renderPanel', ]); ``` A missing class or method makes the listener return null and the request continues. That looks exactly like a hook that was never fired. Prefer a closure. #### How Arguments Reach Your Listener ```php // For each parameter your listener declares, at position $i: // declared by reference AND an argument exists at $i -> passed by reference // an argument exists at $i -> passed by value // no argument at $i -> the parameter is dropped, // so your default applies $callArgs = []; foreach ($reflection->getParameters() as $i => $param) { if ($param->isPassedByReference() && isset($args[$i])) $callArgs[] = &$args[$i]; else if (isset($args[$i])) $callArgs[] = $args[$i]; } ``` Declaring fewer parameters than the hook supplies is safe. A reference carries your change back only when the hook was fired with the reference variant. On a by-value hook, `&$value` silently changes nothing. #### The Five Categories and What Each Expects Back | Prefix | Your listener does | Your return value | | --- | --- | --- | | `action:` | reacts to an event | ignored | | `filter:` | changes a value before it is used | the edit goes through the reference parameter | | `ui:` | injects markup, styles or scripts | the string that gets printed | | `register:` | registers a capability: cron task, route, menu entry, widget | the registration, or `false` to register nothing | | `gate:` | vetoes an operation | **a non-empty value blocks**, `null` lets it continue | The name is `category:domain.subject.action`, lower case, snake case parts. A `ui:` name ends in a placement word: its listener gets no context and infers the target from the name. #### Reading Your Own Configuration in the Hook File ```php // $nominc = true loads the config and the language file WITHOUT including the class. public static function Load($type = '', $name = '', $nominc = false, $status = ''); public static function Config($type, $module); public static function getInstance(string $type, string $name, array $params = []): ?object; ``` ```php Modules::Load('Addons', 'MyAddon', true); // config only, no class $my_config = Modules::Config('Addons', 'MyAddon') ?: []; if (($my_config['status'] ?? false) && License::valid_addon('my-addon')) { // ... register listeners here } ``` Build the instance inside a listener. Constructing the module on every request only to decide whether to register is the most common reason a module slows the panel. ### Example A hook file touching four surfaces: an event, a value, a page and a scheduled task. ```php Modules::getInstance('Addons', 'MyAddon')->render_service_panel(is_array($service) ? $service : [])); /* A capability. Registration hooks run during bootstrap of the thing they feed. */ Hook::add('register:cronjobs', 1, function () { include_once __DIR__ . DS . 'cronjobs' . DS . 'SyncTask.php'; CronJobQueue::register( \WISECP\Modules\Addons\MyAddon\CronJobs\SyncTask::TYPE, \WISECP\Modules\Addons\MyAddon\CronJobs\SyncTask::class ); }); ``` The reading side: the core call that fires the value hook above. ```php // coremio/helpers/Invoices.php, inside recalculate_totals(): after the figures are built // and before they are written to the row. Hook::runRefs('filter:invoice.totals', $totals, $invoice, $items); // $totals is now whatever the listeners left in it, and $persist writes that. ``` The same care applies when your own module publishes an extension point: ```php // By value: an event other modules may observe. Hook::run('action:myaddon.sync_finished', $summary, $startedAt); // By reference: EVERY argument is by reference here, including the context ones, so // each of them must be a plain variable first. $context = ['id' => $recordId, 'lang' => Language::selected()]; Hook::runRefs('filter:myaddon.payload', $payload, $context); // A gate: a non-empty return from any listener stops the operation. $veto = Hook::run('gate:myaddon.export', $recordId); if (array_filter($veto)) throw new Exception((string) current(array_filter($veto))); ``` ### Pitfalls > **Every argument of the reference variant is a reference, context included** > > The signature is variadic by reference. A literal, a cast, a function return or a null-coalescing expression in *any* position is a fatal error. Move each one into a plain variable first; the by-value variant is unaffected. > **An exception inside a listener is swallowed** > > The engine catches every throwable, logs it and moves on. Your integration does not happen, the page appears normally and nothing says so; a missing `include_once` is the usual cause. When a listener seems inert, read the error log first. > **Work that must run before the first hook does not belong here** > > Hook files are read lazily, on the first run call, and not at all while the installation date is the zero date. Anything needed earlier (a route, a permission catalogue) goes in `router.php`, loaded while the router is built. > **The file body runs on every request that touches a hook** > > Instantiating your module, querying the database or calling a service at the top level costs that every time, customer page views included. Read the configuration with the class-free loader and build the instance inside the listener. > **A priority collision is resolved, not reported** > > Registering two listeners at the same number gives the second the next free slot, so order follows registration order, which follows the alphabetical order of module directories. Pick a number far from the crowd. ### Related Articles - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [The Hook Catalog](https://dev.wisecp.com/en/hook-domains) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints) - [Adding a Dashboard Widget](https://dev.wisecp.com/en/adding-a-dashboard-widget) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) ## Adding a Dashboard Widget https://dev.wisecp.com/en/adding-a-dashboard-widget Add a card to the admin dashboard by returning a descriptor from one registration hook, with no core file touched. ### Overview **Dashboard widgets are not a module type:** there is no widget module and no base class. `get_widgets()` builds them out of privilege checks, one template shows them, hooks extend them. You return an array from `register:admin.dashboard_widgets`. The core gives it a rank, applies the operator's saved layout, and hands it to the shared card shell. Four more hooks reach the rest. ### Prerequisites - A module with a working `hooks.php`; see [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module). - Your content as an HTML string; the body is echoed as is. - A privilege key if the card should be restricted. ### Structure - **controllers/admin/index.php**: `get_widgets()` builds the list and merges your hook's return; `get_statistics()` builds the figures strip. - **templates/admin/index.php**: The dashboard shell: fires the injection hooks and the widget filter, then loops the list into cards. - **inc/template-widget-item.php**: The card shell. An unknown name falls through to your content. - **operations/AdminIndex.php**: `get_widget_content`, behind the refresh button. - **js/home.js**: Lays the grid out with Packery: rank is only the order in the HTML, not where a card lands. ### Walkthrough #### Register the Card 1. Listen on `register:admin.dashboard_widgets` from your `hooks.php` and return one descriptor array. 2. Give it at least `name`, `title` and `content`; everything else has a default. 3. Check the privilege inside the listener: return `false` when it fails, or set `allowed` to the result. #### Build the Body 1. Produce the HTML with heredoc and read your strings from the module's language file. 2. Build it inside the listener, not in the file body, so a dashboard without your card costs nothing. 3. Reserve the height of anything that fills in later. #### Control Placement and Size 1. Set `rank` for the order in the markup; left out, the core appends yours last. 2. Set `size` to `wide` for a full-width card; any other value gives the standard half-width one. 3. For the strip, the surrounding markup or someone else's cards, use the four other hooks below. ### Reference #### The Five Dashboard Hooks | Hook | Call site | Your return | | --- | --- | --- | | `register:admin.dashboard_widgets` | `Hook::run`, no arguments | one descriptor becomes one card; `false` registers nothing | | `filter:admin.dashboard.widgets` | `Hook::runRefs`, `$widgets` by reference | edit in place: reorder, drop, retitle | | `filter:admin.dashboard.statistics` | `Hook::run`, `$result` | a non-empty return **replaces the whole array** | | `ui:admin.dashboard.top` | `Hook::run`, no arguments | a string above the figures strip | | `ui:admin.dashboard.statistics.after` and `ui:admin.dashboard.bottom` | same, no arguments | a string after the strip, and after the grid | > **The statistics filter replaces, it does not merge** > > The call site keeps the last non-empty listener return and assigns it over the whole result. Change the key you care about and return the whole array; returning only your own key wipes the strip, which still looks plausible. #### The Descriptor Array - **name**: The identity: `data-id`, the saved-layout key, and the refresh argument. Default `wt{rank}`, never rely on it. - **title**: The heading, shown as raw HTML. Default `Untitled Widget`; wrapped in a link when you set `link`. - **content**: The card body, echoed as is. Empty shows the widget name instead, the symptom of forgetting it. - **icon**: Bootstrap icon class for the disc beside the title. Default `bi bi-box`. - **allowed**: Boolean gate, default true. False removes the card before it appears. - **status**: `open` or `close`, default open. A closed card shows its header only. - **rank**: Integer order in the markup. Default: one past the last registered widget. - **size**: `wide` adds the full-width class; any other value keeps the standard width. - **hidden**: Boolean. The card is produced but starts with display none, how the close button remembers itself. - **link**: Turns the title into a link. Build it with the link generator, never a literal path. - **buttons**: Header buttons: `['create' => ['name' => …, 'link' => …, 'icon' => …]]`. `create` gets a plus icon; other keys use `icon`, or the name as text. - **header_buttons**: Raw HTML before the refresh, collapse and close buttons. #### What the Core Fills In ```php $hook = Hook::run("register:admin.dashboard_widgets"); if ($hook) { $last = end($widgets); $rank = $last["rank"] ?? 10; foreach ($hook as $h) { $rank++; $wn = "wt" . $rank; if (isset($h["name"]) && $h["name"]) $wn = $h["name"]; if (!isset($h["allowed"])) $h["allowed"] = true; if (!isset($h["status"])) $h["status"] = "open"; if (!isset($h["rank"])) $h["rank"] = $rank; $widgets[$wn] = $h; // your name is the key: it can REPLACE a built-in card } } ``` #### The Refresh Button ```php public function get_widget_content(Operation $operation): bool; ``` ```php // GET {dashboard}?operation=get_widget_content&name={your name} $widgets = $this->get_widgets(); // the WHOLE list is rebuilt, hook included if (!isset($widgets[$wn])) throw new Exception("Invalid widget"); // One card re-rendered through the same shell, returned as an HTML string. return $operation->output($this->view->chose("admin")->render("inc" . DS . "template-widget-item", [ 'widget' => $widgets[$wn], ], true)); ``` #### Putting a Table in a Card ```php $t = new \WISECP\Components\Table("myAddonWidget", [ 'preset' => 'invoiceList', // row rendering comes from the MAIN list 'hideActions' => true, 'perPage' => false, 'search' => false, 'info' => false, 'pagination' => false, ]); foreach ($t->getColumns() as $k => $v) $t->setColumn($k, ['sortable' => false]); $t->deleteColumn("selection"); $t->setRows($rows); $html = $t->build(); ``` > **Rows must carry every key the preset reads** > > A widget table borrows the main list's row builder, which reads far more keys than the columns you kept. A missing key shows an empty cell instead of failing. Derive the list from the preset file; a change there reaches your card too. ### Example A module's own queue: registered, gated, ranked and refreshable. ```php 'myaddon_queue', 'title' => $module->lang['widget-title'] ?? 'My Addon Queue', 'icon' => 'bi bi-list-check', 'rank' => 6, 'status' => 'open', 'link' => LinkGenerator::admin('tools-2', ['addons', 'MyAddon']), 'buttons' => [ 'create' => [ 'name' => Language::gc('admin/index/button-create-a-new'), 'link' => LinkGenerator::wQS(LinkGenerator::admin('tools-2', ['addons', 'MyAddon']), ['trigger' => 'create']), ], ], 'content' => $module->render_dashboard_widget(), ]; }); /* A figure on the strip. Read what you were given, change one key, return ALL of it. */ Hook::add('filter:admin.dashboard.statistics', 10, function ($result) { if (!is_array($result)) return false; $result['static_blocks']['myaddon_pending'] = [ 'title' => 'Pending syncs', 'value' => (int) WDB::select('COUNT(id) AS total')->from('MyAddon_queue') ->where('status', '=', 'pending')->build() ? (int) (WDB::getAssoc()['total'] ?? 0) : 0, ]; return $result; }); ``` ```php public function render_dashboard_widget(): string { $rows = ''; foreach ($this->queue_preview(5) as $row) { $label = htmlspecialchars((string) ($row['label'] ?? ''), ENT_QUOTES); $state = htmlspecialchars((string) ($row['status'] ?? ''), ENT_QUOTES); $rows .= << {$label} {$state} HTML; } if ($rows === '') $rows = '
  • ' . ($this->lang['widget-empty'] ?? 'Nothing queued.') . '
  • '; // The class carries the reserved height (see the stylesheet below), which is what stops // the whole grid from repacking once the list fills in. return <<{$rows} HTML; } ``` ```css /* The final height, declared before the content exists. Ship it through ui:admin.head.css from the same hooks.php that registers the card. */ .myaddon-queue { min-block-size: 220px; } ``` The reading side is the card shell: the fallback for a missing `content`, and why the body is not escaped. ```php $w_name = $widget["name"] ?? "widget" . $w_rank; $w_title = ($widget["title"] ?? '') ?: 'Untitled Widget'; $w_icon = ($widget["icon"] ?? '') ?: 'bi bi-box'; $w_content = $widget["content"] ?? ''; if ($w_name == "orders_chart") { // ... a long chain of built-in names, one branch each } else echo $w_content ?: $w_name; // your HTML, unescaped, or the name when you forgot it ``` ### Pitfalls > **A card that grows after the first paint repacks the grid** > > The layout is masonry and re-measures every card on each pass. A body that fills in later changes its card's height, and unrelated cards slide across the screen. Measured: a chart box growing 54 pixels moved two other cards 733 pixels. > **The operator's saved arrangement outranks your descriptor** > > A stored rank, collapsed state or hidden flag for your widget name beats the descriptor, and it travels in a cookie: a card registered open can be collapsed for one administrator, open for a colleague. A collapsed card's content is in the page, hidden by CSS. > **Your listener runs on every dashboard load, not once** > > It also runs for each refresh of any card, because that operation rebuilds the entire list. That is what makes the refresh button work, and it means a query there is paid every time. Do the privilege check first and cache anything expensive. > **Registering under an existing name replaces that card** > > The merge keys by name, so `notes` or `tasks` as your widget name takes over the built-in card. Prefix the name with your module. ### Related Articles - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [The Hook Catalog](https://dev.wisecp.com/en/hook-domains) - [Interface Components](https://dev.wisecp.com/en/interface-components) - [The Module System](https://dev.wisecp.com/en/the-module-system) - [Building Links and Routes](https://dev.wisecp.com/en/building-links-and-routes) # Module Development / Provisioning Modules ## Writing a Server Module https://dev.wisecp.com/en/writing-a-server-module A server module turns a paid order into a real account on a hosting panel or a cloud provider. The core calls your lifecycle methods by name; what you return is written back onto the service. ### Overview Servers is the largest module type: 50 modules, three of them sandbox archetypes for shared hosting panels, dedicated machines and virtualization. Your class extends `ServerModule`, which brings in `ModuleBaseTrait`. Between them they already hold the server, the service, the product, the buyer, the resolved limits, the addon answers and the tool machinery. The core never constructs your class: it resolves an instance through the factory and calls the verb by name. A verb you did not implement is skipped, and that is the opt-in mechanism of the whole type. - **Services::run_module()**: The single door: builds the instance, resolves aliases, runs the method, applies the result. - **Services::instance_module()**: Picks the module type from the service, then calls the factory and fills the service and the order. Hosting and server go to Servers. - **Modules::getInstance()**: The canonical factory: config, language, instance cache. `new` is never used for a module. - **ModuleQueue**: The retrying background runner: same door, then the status the action implies. ### Prerequisites - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration) first; this article covers only the Servers type. - A provider account with API access and a server record that passes the connection test. - A test product bound to that server, otherwise `create()` has no plan to read. - Failure is reported by throwing, not by returning `false`. ### Structure One directory per module, named exactly like the class inside it. ```bash coremio/modules/Servers/Acme/ ├── Acme.php the class: extends ServerModule ├── ApiClient.php your HTTP wrapper, plain new, not a WISECP module ├── config.php metadata, server form fields, supported cards and tools ├── logo.png shown in the module picker ├── lang/en.php $this->lang, one file per language └── pages/ optional templates rendered with get_page() ``` ```php namespace WISECP\Modules\Servers; use Exception; use Language; use ServerModule; class Acme extends ServerModule { private ApiClient $api; // Called by set_server(), which the constructor and set_service() both run, // once $this->server is filled and its secrets are decoded. protected function define_server_info(array $server = []): void { include_once __DIR__ . DS . 'ApiClient.php'; $this->api = new ApiClient($server); } } ``` | Surface | Entry point in your class | Reached from | | --- | --- | --- | | Server settings form | `config.php` fields, then `test_connect()` | Admin, Servers, Manage Server | | Product settings | `product_configuration()`, `save_product_configuration()` | Product detail, Module tab | | Provisioning | `create()`, `suspend()`, `unsuspend()`, `cancel()` | Order approval, admin actions, the queue | | Client area dashboard | `dashboard_data()` | Service detail in the client area | | Client area tools | `tool_data()`, `tool_action()` | The tool sidebar, see the tools article | | Metered billing | `metrics_usage()` or `metrics_usage_bulk()` | The usage collection cron | | Import | `list()` | Admin, import accounts from the panel | ### Walkthrough #### Write the Layers in This Order Lifecycle first is wasted work. `create()` reads the buyer's choices out of the service options, and those options exist only once the product form is defined. 1. `define_server_info()`, then `test_connect()`: add the server and prove the credentials. 2. `product_configuration()` and `save_product_configuration()`, plus the callable that fills the plan dropdown. 3. `create()`, then `suspend()`, `unsuspend()`, `cancel()`. 4. `change_password()` and `upgrade()`. 5. `list()`, `dashboard_data()`, the single sign on methods, metrics. 6. `configure_features()` and the tools. Verify each layer against the live provider before moving on: a first failure at the end has six possible causes. #### Connecting to the Provider Credentials come from the server record. `config.php` decides which fields the form shows, and the base class hands them to you decoded. ```php return [ 'name' => 'Acme Cloud', // 'hosting' = domain based accounts, 'server' = VPS or dedicated machines. 'type' => 'hosting', // A long API token belongs in the access hash field, not in the password field: // servers.password is a varchar and an encrypted long token overflows it. 'use-access-hash' => true, 'require-access-hash' => true, 'use-test-connection' => true, 'use-port' => true, 'not-secure-port' => 2082, 'secure-port' => 2083, // Extra fields land in $server['fields']. 'crypt' stores the value encrypted. 'fields' => [ 'region' => ['type' => 'text', 'name' => '{lang.region}', 'col_class' => 'col-md-6'], 'sub_token' => ['type' => 'password', 'name' => '{lang.sub_token}', 'crypt' => true], ], // Which service option identifies the account on the provider side. 'service-relationship' => 'domain', ]; ``` > **The password is already decrypted** > > The base class round trips `$server['password']` in one place, so it always reaches `define_server_info()` in plain text. Decrypting it again gives the API a mangled secret. #### Product Fields and the Plan Dropdown `product_configuration()` returns a field descriptor map. What the admin picks is saved as the product's module data, and it reaches your provisioning code as `$this->options['creation_info']`. Make the option value the plan **name**, not the provider's numeric id, and resolve it to an id inside `create()`. #### The Lifecycle Body Every provisioning verb has the same three beats: read what the buyer chose, call the provider, return what to store. Returning an array is how you persist. 1. Read the plan from `creation_info`, the limits through `get_limit()`, the addon answers from `addon_params`, the order form answers from `requirement_params`. 2. Call the provider. Let the client throw on an API error, or throw yourself with a translated message. 3. Return `['config' => [...]]`; the core merges it into the service options and every later verb reads the account back from there. ### Reference #### Lifecycle Signatures None is declared abstract on the base class. The core probes each with `method_exists()` and skips what is absent, so the signature is the contract. ```php // Setup. define_server_info runs from set_server(), before any other verb. protected function define_server_info(array $server = []): void; public function test_connect(): array|bool; public function configure_features(): void; // Product and service forms. Both save methods take the values BY REFERENCE. public function product_configuration(array $data = []): array; public function save_product_configuration(array &$values): void; public function service_configuration(): array; public function save_service_configuration(array &$values): void; // Provisioning. public function create(): array|bool; public function suspend(): bool; public function unsuspend(): bool; public function cancel(): bool; public function renew(): bool; public function upgrade(array $new_product = []): bool; public function change_password(string $password): bool; public function change_limits(array $limits): bool; public function reset_limits(): array|bool; // Addons. $addon is one row of the service's addon table. public function addon_create(array $addon = []): array|bool; public function addon_suspend(array $addon = []): array|bool; public function addon_unsuspend(array $addon = []): array|bool; public function addon_cancel(array $addon = []): array|bool; public function addon_upgrade(array $addon, array $new_addon): array|bool; // Client area. public function dashboard_data(): array; public function tool_data(string $tool, string $action = 'index', array $params = []): array; public function tool_action(string $tool, string $action, array $data = []): array; public function sso_panel_login(): string; public function sso_root_panel_login(): string; // Metered billing and import. public function metrics_usage(): array; public function metrics_usage_bulk(array $services = []): array; public function metric_enable(array $metric): void; public function metric_disable(array $metric): void; public function list(bool $rCount = false, array $filters = [], array $orders = [], int $start = 0, int $end = -1): array|int; ``` | Method | Required | Called when | Status afterwards | | --- | --- | --- | --- | | define_server_info | yes | Every instantiation | none | | test_connect | yes | Test Connection on the server form | none | | product_configuration | yes | Product detail, Module tab | none | | create | yes | Order approved, or Recreate in admin | active | | suspend | yes | Overdue invoice, or manual suspend | suspended | | unsuspend | yes | Payment received, or manual unsuspend | active | | cancel | yes | Cancellation processed | cancelled | | change_password | yes | Account password changed | unchanged | | upgrade | yes | Plan change on the same module | unchanged | | renew | optional | Renewal invoice paid | unchanged | | change_limits, reset_limits | optional | Provider allows per account limit overrides | unchanged | | addon_* | optional | An addon on the service changes state | addon only | | list | optional | Admin opens Import Accounts; implementing it makes that screen appear | none | | metric_enable, metric_disable | optional | Metered billing with provider side limit overrides | none | #### What create() May Return Return `true` for a bare success, or an array. Every key is an instruction; the reserved ones are merged into the service options recursively. - **config**: Merged under `config`: the account identity, so `user`, the encrypted `password`, `home_dir`, the provider side id. - **login**: Merged under `login`. Credentials the client area shows or the single sign on uses. - **creation_info**: Merged under `creation_info`, the same bag the product form writes into. Record what was actually provisioned. - **options**: Merged into the option root; the escape hatch for anything else. - **status**: Consumed by the queue and never stored. `'inprocess'` or `'waiting'` keeps the service out of active state while asynchronous provisioning finishes. - **any other key**: Written to the option root as is: `hostname`, `ip`, `ftp_info`. #### The State You Already Have All of it is filled before your method runs. Do not query for any of it. - **$this->server**: ip, hostname, username, the decrypted password, the access hash and `fields` from your config. - **$this->service, $this->product, $this->user, $this->order**: Plain arrays: the service row, its product, the buyer, the order. - **$this->options and save_options()**: A live array. Mutate it and call `save_options()` to persist mid method; returning an array does the same at the end. - **get_limit(string $key): mixed**: One resolved limit, service level overriding product level. Keys: `disk_limit`, `bandwidth_limit`, `email_limit`, `database_limit`, `addons_limit`, `subdomain_limit`, `ftp_limit`, `park_limit`, `max_email_per_hour`. - **$this->addon_params, $this->addon_params_by_id**: Merged totals of the active addons, and the same split per addon row id. Keys from `addon-params`. - **$this->requirement_params**: The buyer's order form answers, keyed by `requirement-params`. - **encode_str(), decode_str()**: Encrypt before storing a secret, decrypt before sending it. Never store a panel password in clear. - **username_generator(string|int|null $domain): string**: Static. Derives a panel safe username from the domain; an empty domain yields an empty one, which the provider rejects. #### The Core Side of the Call ```php public static function run_module(int|array $service, string $action, array $params = []): mixed; public static function instance_module(int|array $service): ?object; ``` ```php Services::run_module($id, 'create'); // no arguments Services::run_module($id, 'change_password', [$password]); // one positional string Services::run_module($service, 'upgrade', [$newProduct]); // the new product array Services::run_module($service, 'addon_create', [$addon]); // one addon row ``` - **returns null**: Your class has no such method. The queue records "module method not found" and the action fails. - **returns false**: The module refused; the queue marks the item failed and retries. - **terminate resolves to cancel**: If `terminate()` is missing the core tries `cancel()`, then `cancelled()`. - **gate:service.module_action**: Runs before your method. A listener returning a non empty string vetoes the action with that message. - **filter:service.module_result**: Runs on the result before it is applied, by reference, so a listener can rewrite what is stored. - **action:service.module_ran**: Fires after the result is applied, with the service, the instance, the action, the result and the error. ### Example A complete provisioning method and the core code that reads the return value back. The keys you return are the keys the core merges. ```php public function create(): array|bool { $domain = $this->options['domain'] ?? ''; if (!$domain) throw new Exception($this->lang['error-domain-required']); // Re-provision: reuse the identity from the previous run instead of minting a new one. $username = $this->options['config']['user'] ?? ''; if (!$username) $username = self::username_generator($domain); $password = ($this->options['config']['password'] ?? '') !== '' ? $this->decode_str($this->options['config']['password']) : Utility::generate_hash(12); $creation = $this->options['creation_info'] ?? []; $parameters = [ 'username' => $username, 'password' => $password, 'domain' => $domain, // Option values carry the plan NAME, so resolve it on this server. 'plan_id' => $this->resolve_plan_id($creation['plan'] ?? ''), 'disk' => $this->get_limit('disk_limit'), 'bandwidth' => $this->get_limit('bandwidth_limit'), // A switch posts "0" or "1" as a string; empty("0") is true, so cast instead. 'shell' => (int) ($creation['shell_access'] ?? 0) === 1, ]; // Order form answers and addon totals, keyed by the names declared in config.php. foreach ($this->requirement_params as $key => $value) $parameters[$key] = $value; foreach ($this->addon_params as $key => $value) $parameters[$key] = $value; // Retry safety: the queue re-runs this method after a failure. if (!$this->api->account_exists($username)) $this->api->call('accounts', $parameters, 'POST'); return [ 'config' => [ 'user' => $username, 'password' => $this->encode_str($password), 'home_dir' => '/home/' . $username, ], // Not a reserved key, so it is written to the option root. 'ip' => $this->server['ip'] ?? '', ]; } ``` ```php // Services::apply_module_result, simplified to the part that matters to you. if (is_array($result)) { if (isset($result['config']) && is_array($result['config'])) $options['config'] = array_replace_recursive($options['config'] ?? [], $result['config']); // Anything outside the reserved set lands on the option root. $reserved = ['config', 'login', 'creation_info', 'options', 'status']; foreach ($result as $rKey => $rVal) if (!in_array($rKey, $reserved, true)) $options[$rKey] = $rVal; } // ModuleQueue then decides the new service status. $moduleStatus = is_array($result) ? ($result['status'] ?? null) : null; $targetStatus = $moduleStatus ?: match ($action) { 'create', 'unsuspend', 'register' => 'active', 'suspend' => 'suspended', 'cancel' => 'cancelled', default => null, }; // And this is what every later verb reads: $username = $this->options['config']['user'] ?? ''; $password = $this->decode_str($this->options['config']['password'] ?? ''); ``` ### Pitfalls > **Report failure by throwing, not by returning false** > > Assigning to the error property and returning `false` is a leftover of the previous generation. cPanel throws in 41 places and assigns in none. Throw a translated exception and the caller surfaces the message. > **The queue will run create() twice** > > A failure after the account exists is retried from the top, and a provider that rejects duplicates then fails forever. Check for the account first, and persist the credentials with `save_options()` as soon as you have them. > **Store the plan name, not the provider id** > > In a load balanced group the order lands on whichever machine has room, and the same plan carries a different id there. The name is stable: save the name, resolve it at call time. > **Never test a switch value with empty()** > > Approval and switch fields post the strings `"0"` and `"1"`, and `empty("0")` is true. Write `(int) ($data['key'] ?? 0) === 1` instead. > **Do not decrypt the server password yourself** > > The base class already did it, in one place. A second decryption corrupts the plain text path, and the failure only shows up against a real provider. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [Server Module Tools](https://dev.wisecp.com/en/server-module-tools) - [Writing a Product Module](https://dev.wisecp.com/en/writing-a-product-module) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) ## Server Module Tools https://dev.wisecp.com/en/server-module-tools Tools are the pages a customer gets inside a service: file manager, databases, DNS, cron jobs. The base class owns everything around them; you supply two methods and a template. ### Overview `ServerModule` ships a catalogue of tool descriptors, all switched off. Turning one on means declaring it supported, then answering two questions: what the page shows, and what a button does. Everything between the browser and those two answers belongs to the base class. - **get_tool_data()**: The read path: validates the descriptor, calls your `tool_data()`, normalizes rows, runs the filter hook, caches per request. - **handle_tool_action()**: The write path: sanitizes and validates each field, checks the capability, calls your `tool_action()`, writes the service history with secrets masked. - **create_tool_table()**: Builds the listing table from the declared columns and the row callback. - **capability**: One verb the tool offers: permission gate and interface flag. ### Prerequisites - A working server module ([Writing a Server Module](https://dev.wisecp.com/en/writing-a-server-module)). Tools open only after `create()` stores the account identity. - Confirm on the live provider that the main entity supports both create and delete. - If the tool creates something the provider bills for, plan the quota. ### Structure Six layers, three of them yours. | Layer | Where | What it holds | | --- | --- | --- | | Catalogue | `coremio/classes/ServerModule.php` | Descriptor, filters, rules, columns, row callbacks | | Enabling the tool | Your `config.php` or `configure_features()` | Supported tools and capabilities | | Read | Your module, `tool_data()` | One case per tool, then a fetch method | | Write | Your module, `tool_action()` | One case per tool, then an action router | | Interface | `templates/system/module/service/hosting/tools` | Shared template per tool slug | | Text | `coremio/locale/en/cm/system/module.php` | Shared labels and messages | ### Walkthrough #### Enable the Tool Declaring the slug switches the tool on with its default capabilities. ```php // config.php: the declarative half. return [ // ... the rest of the module configuration 'supported' => [ 'tools' => ['file-manager', 'ftp-accounts', 'databases', 'cron-jobs'], // Per tool option overrides, the same keys set_tool() would write. 'tool_options' => [ 'file-manager' => ['allow_upload_overwrite' => true, 'allow_chmod_recursive' => true], ], ], ]; ``` ```php public function configure_features(): void { // Same effect as the config list, useful when the decision depends on live state. $this->support_tools(['cron-jobs' => ['list', 'create', 'delete']]); // The provider has no per account quota and no e-mail field on cron jobs, // so remove the capabilities we cannot serve, plus the column they feed. $this->remove_tool_capability('ftp-accounts', ['quota']); $this->remove_tool_column('ftp-accounts', ['quota']); $this->set_tool_order('databases', 5); } ``` #### Read Path `tool_data()` is a single dispatcher; keep the per tool work in private fetch methods. 1. Return `['items' => [...]]` keyed by column name, plus any extra key the template reads. #### Write Path `tool_action()` has the same shape, with a router matching on the action verb. 1. Throw on refusal; the message reaches the customer as a red alert. 2. Return `[]`, or a payload when the interface needs a value back. #### Columns and Text Column titles, labels and messages come from the shared language file. The template is shared, so a field name is a contract. Provider specific strings go in your module language file. ### Reference #### Tool Descriptor ```php $this->tools['ftp-accounts'] = [ 'name' => 'FTP Accounts', // Group names depend on the module type declared in config.php: // hosting: files, databases, domains, email, software, security // server: power, system, network, storage 'group' => 'files', 'order' => 20, // position inside the group 'icon' => 'bi bi-hdd-network', // a Bootstrap icon, or 'img:' for a remote glyph 'page' => 'ftp-accounts', // template file name under tools/ 'type' => 'page-loader', // 'page-loader' renders a page, 'action' is a single button 'supported' => false, // your module flips this on 'capabilities' => ['list', 'create', 'edit', 'delete', 'quota', 'directory'], ]; ``` - **supported**: Ships as `false`. A request for an unsupported tool is refused before your code runs. - **capabilities**: Both the permission list and the interface flag; checked before a button appears. - **type**: `'page-loader'` loads the template named by `page`. `'action'` is a single button in the tool grid, optionally with `'confirm' => true`. - **options**: Per tool switches the template reads. Set from the configuration or at runtime. - **capability aliases**: `get_content` and `save_content` count as `edit`; `update_email` counts as `email`. #### Module Signatures ```php // The two the base class calls. $params carries the sanitized query parameters, // $data carries the sanitized POST body. public function tool_data(string $tool, string $action = 'index', array $params = []): array; public function tool_action(string $tool, string $action, array $data = []): array; // Optional: adjust the catalogue for this module. Runs once the server record is // bound (from the constructor, through set_server) and again from set_service. public function configure_features(): void; // Optional: only needed when the reset-password tool must let the panel invent one. protected function panel_generated_password(): string; ``` #### Base Class Helpers ```php public function support_tools(array $tools): static; public function add_tool(string $key, array $config): static; public function set_tool(string $key, array $config): static; public function remove_tool(string $key): static; public function set_tool_order(string $key, int $order): static; public function add_tool_capability(string $key, array|string $capabilities): static; public function remove_tool_capability(string $key, array|string $capabilities): static; public function add_tool_column(string $tool, string $column, array $config): static; public function remove_tool_column(string $tool, array|string $columns): static; public function add_tool_group(string $key, array $config): static; public function set_tool_group_order(string $key, int $order): static; public function get_tool(string $key): ?array; public function get_tools(): array; public function get_effective_tools(): array; public function get_disabled_features(): array; ``` > **support_tools() reads its argument two ways** > > A plain list element turns the tool on with its default capabilities. A key pointing at an array **replaces** that list with what you passed. #### Validation Layers All three are declared in the base class and run before your action method. ```php // 1. Sanitizing. Any field not listed falls back to 'hclear'. protected function get_tool_action_data_filters(): array; // The shipped rule for this tool, verbatim. Note that an FTP user name is filtered // as an e-mail, because panels accept the user@domain form: // 'ftp-accounts' => ['username' => 'email', 'password' => null, // 'directory' => 'path', 'quota' => 'numeric'] // Filters: hclear, numeric, route, identifier, domain, subdomain, hostname, // email, email_list, ip, url, path, json_filenames, null (pass through) // 2. Required fields, per action. An empty string after trimming throws. protected function get_tool_action_rules(): array; // 'ftp-accounts' => ['create' => ['username', 'password'], // 'edit' => ['username'], 'delete' => ['username']] // 3. Format checks, run after the required check and skipped for empty values. protected function get_tool_action_field_validations(): array; // 'mx-entry' => ['create' => ['domain' => ['domain'], 'priority' => ['numeric']]] // Rules: url, email, email_list, email_local, ip, ipv4, ipv6, ip_or_wildcard, // domain, dns_label, numeric, cron_field, enum:a,b,c ``` > **A password field must be declared with the null filter** > > Every other filter strips the characters that make a password strong: the account then gets a secret the customer never typed. #### tool_action() Returns - **[]**: Ordinary success: the base class looks up `action-success-{tool}-{action}`, then `action-success-{action}`, then a generated label. - **['status' => 'successful', 'message' => '...']**: Success with your own wording, returned as is. - **['stream' => ...]**: A file download; the base class streams it and the request ends there. - **['redirect' => ...]**: Reserved: immediate full page navigation, same tab. - **throw**: Failure. The message reaches the customer, so write it translated. #### Template Variables ```php /** @var ServerModule $module the live instance, so $module->service is reachable */ /** @var string $tool the slug */ /** @var array $tool_config the descriptor, including capabilities and options */ /** @var string $tool_label the translated tool name */ /** @var string $action 'index' unless the page was opened on a sub action */ /** @var array $data exactly what your fetch method returned */ /** @var mixed $table the prepared listing table, or null when the tool has no columns */ /** @var array $tables sub tables, keyed by their own slug */ /** @var bool $admin_view true in the admin panel, false in the client area */ /** @var string|null $error set when the fetch failed */ ``` | Browser function | Purpose | | --- | --- | | `request_tool_action(tool, action, data, options)` | Posts one action, with spinner and toast | | `reload_module_content(tool)` | Reloads the tool page after a change | | `open_modal(id, {title, body, footer})` | Builds and opens a dialog | | `confirmDeleteModal({message, description, buttonText, onConfirm})` | The standard delete confirmation | | `watchRequired(selector)` | Enables submit once required inputs are filled | | `passwordInput(id, placeholder, options)` | Password field with generate, reveal and copy | ### Example One tool end to end: fetch, router, and the base class that consumes both. ```php public function tool_data(string $tool, string $action = 'index', array $params = []): array { $username = $this->options['config']['user'] ?? ''; $domain = $this->options['domain'] ?? ''; return match ($tool) { 'ftp-accounts' => $this->fetch_ftp_accounts($username, $domain), 'databases' => $this->fetch_databases($username), default => [], }; } private function fetch_ftp_accounts(string $username, string $domain): array { $response = $this->api->call('ftp/list', ['username' => $username]); $items = []; foreach ($response['data'] ?? [] as $row) $items[] = [ 'username' => $row['user'] ?? '', 'directory' => $row['dir'] ?? '/', 'quota' => (int) ($row['quota'] ?? 0), ]; // 'items' feeds the table; anything else is read by the template. return ['items' => $items, 'domain' => $domain]; } public function tool_action(string $tool, string $action, array $data = []): array { $username = $this->options['config']['user'] ?? ''; return match ($tool) { 'ftp-accounts' => $this->action_ftp_accounts($action, $data, $username), default => throw new Exception($this->lang['err-tool-unknown']), }; } private function action_ftp_accounts(string $action, array $data, string $username): array { return match ($action) { 'create' => $this->ftp_create($data, $username), 'delete' => $this->ftp_delete($data, $username), default => throw new Exception($this->lang['err-action-unknown']), }; } private function ftp_create(array $data, string $username): array { $this->api->call('ftp/create', [ 'account' => $username, // Already sanitized by the declared filters, and already checked for presence. 'user' => $data['username'] ?? '', 'password' => $data['password'] ?? '', 'directory' => $data['directory'] ?? '/', ], 'POST'); // Empty array: the base class writes the translated success message. return []; } ``` ```php // ServerModule::handle_tool_action, reduced to the sequence that matters. $tool = Filter::init("REQUEST/tool", "route"); $action = Filter::init("REQUEST/action", "route") ?: 'index'; $data = !empty($_POST) ? $_POST : $_GET; unset($data['operation'], $data['method'], $data['tool'], $data['action']); $data = $this->sanitize_tool_action_data($tool, $data); $tool_config = $this->get_tool($tool); if (!$tool_config) throw new \Exception('Tool not found'); if (empty($tool_config['supported'])) throw new \Exception('Tool not supported'); $this->check_tool_capability($tool_config, $action); $this->validate_tool_action($tool, $action, $data); $result = $this->tool_action($tool, $action, $data); // Secrets are masked before the action reaches the service history. foreach ($data as $k => $v) if (is_string($v) && $v !== '' && preg_match('/pass(word)?|secret|token/i', (string) $k)) $data[$k] = '***'; if (empty($result)) return $this->tool_action_success_response($tool, $action); return $result; ``` ```javascript var TOOL = 'ftp-accounts'; window.ftpCreateSubmit = function (btn) { request_tool_action(TOOL, 'create', { username: document.getElementById('ftpUser').value, password: document.getElementById('ftpPass').value, directory: document.getElementById('ftpDir').value, }, { button: btn, buttonLoader: creating_loader, successToast: true, afterDone: function () { close_modal(document.querySelector('.modal.show')); reload_module_content(TOOL); }, }); }; ``` ### Pitfalls > **An unimplemented capability is a button that throws** > > The interface draws a control for every default capability. Remove what your router does not handle, or the customer clicks Edit and gets "Action not available". > **A billable resource needs a quota gate** > > An open create button turns curiosity into your invoice. Tie the resource to an addon and refuse the action once the allowance is used up. > **Never name a form field action, operation, method or tool** > > The helper puts the tool action in the body, then merges your data over it. A field called `action` replaces the verb and the dispatcher throws. Prefix it (`task_action`) in the template, the required rules and your action method. > **redirect navigates away immediately** > > The helper handles that key in the same tab, before your callback finishes. Use a neutral name such as `console_url` for a URL you want in a dialog or a new tab. > **A listing only tool is not shipped** > > A tool whose main entity the provider cannot both create and delete is not shipped. Take it out of the configuration and delete the dead methods. ### Related Articles - [Writing a Server Module](https://dev.wisecp.com/en/writing-a-server-module) - [Interface Components](https://dev.wisecp.com/en/interface-components) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [The Client Area Bridge](https://dev.wisecp.com/en/the-client-area-bridge) - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) - [Product Module Client Management](https://dev.wisecp.com/en/product-module-client-management) ## Writing a Product Module https://dev.wisecp.com/en/writing-a-product-module A product module provisions what is neither a hosting account nor a domain: a licence, a subscription, a certificate. No server record behind it. ### Overview A service that is neither hosting nor server nor domain resolves to the Product type: `special` and `software` services, and SSL certificates. Four of the seven Product modules are SSL products, and SSL has its own base class beneath the generic one. - **ProductModule**: The generic base: module trait, client area contract, service importer. Not abstract. - **SslProductModule**: Abstract: certificate dashboard, product fields, validation parsing, seven customer actions. - **Services::module_type()**: Hosting and server go to Servers, domain to Registrars, everything else to Product. - **Services::run_module()**: The same single door; an unimplemented verb is skipped. ### Prerequisites - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration). - Certificates extend the SSL base, everything else the generic one. - Provider API credentials, read from `$this->config`. - A product of type `special` or `software` bound to the module. ### Structure The same shape as every module type, minus the server. ```bash coremio/modules/Product/Acme/ ├── Acme.php extends ProductModule, or SslProductModule for certificates ├── ApiClient.php your HTTP wrapper ├── config.php metadata and the settings form ├── logo.png ├── lang/en.php └── pages/ ├── configuration.php the module settings screen in the admin panel └── dashboard.php the management surface; its presence opens the client tab ``` | Difference | Server module | Product module | | --- | --- | --- | | Credentials | Server record, base class decrypts | Module configuration, you decrypt | | Connection test | `test_connect()` on the server form | `controller_test_connection()` | | Settings screen | From the configuration fields | Your `page_configuration()` + save controller | | Resource limits | `get_limit()`, base class resolves | From the product module data | | Client tools | Tool catalogue and shared templates | Your dashboard page + callable actions | | Client tab | Always, with a dashboard | Opt in: ship `pages/dashboard.php` | ### Walkthrough #### Pick the Base Class The SSL base is abstract: seven methods to implement, the certificate surface inherited. ```php namespace WISECP\Modules\Product; use Exception; use ProductModule; // A licence, a subscription, an application tenant. class Acme extends ProductModule { public function __construct() { parent::__construct(); // required: it runs initModule('Product') } } ``` ```php namespace WISECP\Modules\Product; use SslProductModule; class AcmeSSL extends SslProductModule { // Seven abstract methods must be implemented; the rest of the surface is inherited. protected function initApi(): void { /* ... */ } public function fetchRemoteStatus(): array { return []; } protected function sslProductOptions(): array { return []; } protected function apiReissue(string $csr, string $dcv_method, string $approver_email): array|bool { return true; } protected function apiResendValidation(string $domain = ''): array|bool { return true; } protected function apiRevalidate(string $domain = ''): array|bool { return true; } protected function apiChangeValidationMethod(string $domain, string $method, string $approver = 'admin'): array|bool { return true; } } ``` #### Settings Screen The module owns its settings page. Methods prefixed `controller_` are reachable from it, hyphens turned into underscores. 1. `page_configuration()` builds the screen, usually with the form builder. 2. `controller_save()` writes the posted fields with `save_config()`. Encrypt every secret first. 3. `controller_test_connection()` proves the credentials. #### Product Fields `product_configuration()` returns the field descriptors the admin fills per product. They become the product's module data and reach provisioning as `$this->options['creation_info']`. #### Lifecycle Same shape as a server module: read the choices, call the provider, hand back what to store. 1. Build the API client lazily inside the method, not in the constructor. 2. Make `create()` retry safe: the queue re-runs it, so check for an existing account and persist credentials early. #### Client Surface Nothing is exposed to the customer by default; see [Product Module Client Management](https://dev.wisecp.com/en/product-module-client-management). ### Reference #### The Generic Base ```php class ProductModule { public bool $client_area = false; // true only while rendering in the client area public string $area_link = ''; // client controller link carrying the service id public array $client_callable_methods = []; // handle_* names the customer may run public array $client_readonly_methods = []; // the subset served over GET, no CSRF token public function __construct(); // calls initModule('Product') public function get_page($page_file = '', $vars = []): string; public function use_controller($param = ''); public function service_management_page(): string; public function has_client_management(): bool; public function client_overview_data(): array; public function client_quick_actions(int $limit = 8): array; protected function import_service(array $data): int; } ``` - **use_controller($param)**: Dispatches to `controller_{param}`, hyphens to underscores. An absent method returns nothing, so an unknown page fails quietly. - **get_page($page_file, $vars)**: Loads a template from your `pages/` directory, injecting `$module`; falls back to the shared special product templates. - **has_client_management()**: True when `pages/dashboard.php` exists or `page_dashboard()` is defined; override to `false` for admin only. - **import_service(array $data): int**: Creates a service row for an account that already exists at the provider. Returns the id, or 0 when owner or product is missing. #### Lifecycle Signatures None exists on the base and none is abstract; the core probes each with `method_exists()`, so the signature is the contract. ```php // Settings screen. Reached through use_controller(). public function page_configuration(): string; public function controller_save(): array; public function controller_test_connection(): array; // Product and service forms. Both save methods take their values BY REFERENCE. public function product_configuration(array $data = []): array; public function save_product_configuration(array &$values): void; public function service_configuration(): array; public function save_service_configuration(array &$values): void; // Provisioning. Note the return type is array|bool here, wider than the server type. public function create(): array|bool; public function renew(): array|bool; public function suspend(): array|bool; public function unsuspend(): array|bool; public function cancel(): array|bool; public function upgrade(): array|bool; public function change_password(string $password): bool; // Addons, identical in shape to the server type. public function addon_create(array $addon = []): array|bool; public function addon_suspend(array $addon = []): array|bool; public function addon_unsuspend(array $addon = []): array|bool; public function addon_cancel(array $addon = []): array|bool; public function addon_upgrade(array $addon, array $new_addon): array|bool; // Live state and the admin dashboard. public function fetchRemoteStatus(): array; public function getDashboardData(): array; // Metered billing. public function metrics_usage(): array; public function metrics_usage_bulk(array $services): array; public function metric_enable(array $metric): void; public function metric_disable(array $metric): void; // Customer actions. The name here is the declared name with handle_ in front. public function handle_reset_usage(): array; ``` > **upgrade() takes no argument on this type** > > A server module receives the new product as `upgrade(array $new_product = [])`. Product modules declare `upgrade(): array|bool` and read the new state from the service. #### The SSL Contract ```php abstract protected function initApi(): void; abstract public function fetchRemoteStatus(): array; abstract protected function sslProductOptions(): array; abstract protected function apiReissue(string $csr, string $dcv_method, string $approver_email): array|bool; abstract protected function apiResendValidation(string $domain = ''): array|bool; abstract protected function apiRevalidate(string $domain = ''): array|bool; abstract protected function apiChangeValidationMethod(string $domain, string $method, string $approver = 'admin'): array|bool; ``` ```php // Suspension has no meaning for a certificate, so both are answered for you. public function suspend(): array|bool; public function unsuspend(): array|bool; // Product fields: the certificate dropdown plus the included SAN count. public function product_configuration(array $data = []): array; public function save_product_configuration(array &$values): void; // The seven customer actions, each already owner, CSRF and active-service guarded. public function handle_reissue(): array; public function handle_resend_validation(): array; public function handle_revalidate(): array; public function handle_change_validation_method(): array; public function handle_add_san(): array; public function handle_remove_san(): array; public function handle_download_certificate(): string; // Helpers around the certificate state. public function stagedSans(): array; public function certificateId(): string; public static function collectExpiringServices(string $module, int $maxDays = 30): array; ``` - **client_callable_methods**: All seven action names, already filled by the base. - **client_readonly_methods**: Only `download_certificate`: streamed over GET, so no token and no POST. - **dcv_methods**: `email`, `http`, `https`, `dns`. Anything else normalises back to e-mail. - **shared certificate strings**: Merged into `$this->lang` at construction; your file wins on a clash. - **fetchRemoteStatus() shape**: Reads `status`, `domain`, `ssl_type`, `sans`, `sans_included`, `sans_addon`, `sans_max`, `issued_at`, `expires_at`, `validation_method`, `approver_email`, `dcv_file`, `dcv_dns`, `serial_number`, `signature_algo`, `key_size`, `issuer`, `crt_code` and `ca_code`. #### import_service() Keys - **owner_id, product_id**: Both required integers; a missing or unknown one returns 0 without writing. - **cycle**: A key such as `monthly`: resolves period, duration and price, priced in the buyer's currency first. - **period, period_time, amount, amount_cid**: Explicit overrides: `period` skips the cycle lookup, `amount` skips the price lookup. - **options**: Merged into the service options. `established` is forced true; the product's module data goes to `creation_info`. - **name, status, cdate, duedate, renewaldate**: Name defaults to the product name, status to `active`, the three dates to now. ### Example A licence module: settings save, provisioning call, reading the result back. ```php public function controller_save(): array { $endpoint = Filter::init("POST/api_endpoint", "hclear"); if (!$endpoint) throw new Exception($this->lang['err-endpoint-required']); $this->save_config([ 'api_endpoint' => $endpoint, // Pass-through: any other filter strips what makes a key strong. 'api_key' => $this->encode_str(Filter::init("POST/api_key", "password")), 'mode' => Filter::init("POST/mode", "letters"), ]); return ['status' => 'successful']; } public function controller_test_connection(): array { $this->initApi(); $this->api->call('ping'); return ['status' => 'successful', 'message' => $this->lang['connection-ok']]; } private function initApi(): void { // Lazy: the instance is built in contexts that never reach the network. if (isset($this->api)) return; include_once __DIR__ . DS . 'ApiClient.php'; $this->api = new ApiClient( $this->config['settings']['api_endpoint'] ?? '', $this->decode_str($this->config['settings']['api_key'] ?? ''), ); } ``` ```php public function create(): array|bool { // What the admin configured on the product, with the order-time copy preferred. $module_data = ($this->options['creation_info'] ?? []) ?: ($this->product['module_data'] ?? []); $plan = $module_data['plan'] ?? 'starter'; $seats = (int) ($module_data['seats'] ?? 1); // Addons add to the base allowance; requirements are what the buyer typed. $seats += (int) ($this->addon_params['extra_seats'] ?? 0); $company = $this->requirement_params['company_name'] ?? ''; $this->initApi(); // Retry safety: the queue re-runs this method after a failure. $existing = $this->options['config']['id'] ?? ''; if ($existing) return true; $result = $this->api->call('licences', [ 'plan' => $plan, 'seats' => $seats, 'company' => $company, 'email' => $this->user['email'] ?? '', ], 'POST'); return [ 'config' => [ 'id' => $result['licence_id'] ?? '', 'key' => $this->encode_str($result['licence_key'] ?? ''), ], 'login' => [ 'username' => $result['username'] ?? '', 'password' => $this->encode_str($result['password'] ?? ''), ], ]; } ``` ```php // Every later verb starts from what create() returned. The core merged // 'config' and 'login' into the service options before this ran. public function cancel(): array|bool { $licenceId = $this->options['config']['id'] ?? ''; if (!$licenceId) return true; // nothing was ever provisioned $this->initApi(); $this->api->call('licences/' . $licenceId, [], 'DELETE'); return true; } // Live provider state for the admin dashboard and the client overview. public function fetchRemoteStatus(): array { $licenceId = $this->options['config']['id'] ?? ''; if (!$licenceId) return []; $this->initApi(); $remote = $this->api->call('licences/' . $licenceId); return [ 'status' => $remote['state'] ?? 'unknown', 'seats_used' => (int) ($remote['seats_used'] ?? 0), // Format before returning: the client area prints these values as they are. 'expires_at' => DateManager::format(Config::get("options/date-format"), $remote['expires'] ?? ''), ]; } ``` ### Pitfalls > **Do not copy a server module and delete the server parts** > > `upgrade()` has a different signature, and there is no resolved limit helper and no tool catalogue. Start from a Product sandbox archetype. > **Build the API client lazily, never in the constructor** > > The instance is built on pages that never call the provider. > **A secret in the module configuration must be encrypted** > > No server record does it for you: encrypt before saving, decrypt before use, and read the posted value with the pass-through filter. > **Report failure by throwing** > > Setting an error string and returning `false` is a leftover. Throw a translated exception; the queue records it and retries. > **Format dates and numbers before returning them** > > The client area prints values exactly as given, a raw provider timestamp included. ### Related Articles - [Product Module Client Management](https://dev.wisecp.com/en/product-module-client-management) - [Writing a Server Module](https://dev.wisecp.com/en/writing-a-server-module) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) ## Product Module Client Management https://dev.wisecp.com/en/product-module-client-management Five opt-in members decide what a customer sees and may do on a product service. They are the tab, the gauges, the shortcuts, the actions and the view flag. ### Overview A product module exposes nothing to the customer by default. The five members below are opt in and type agnostic, so filling them needs no core change. The **overview** tab shows usage rings and account rows from `client_overview_data()`; the **management** tab shows your own dashboard page. Both can carry gauges, so the module has to know which one it is filling. - **has_client_management()**: The tab gate. - **client_overview_data()**: Gauges and account rows for the overview tab. - **client_quick_actions()**: Shortcut buttons on the overview. - **client_callable_methods**: The allowlist of customer-runnable names. - **$client_area**: True only in the customer view of the dashboard, false in the admin view. ### Prerequisites - A working product module: [Writing a Product Module](https://dev.wisecp.com/en/writing-a-product-module). - A service of type `special` or `software`, `active`, with a module bound. Every other state, suspended included, is refused first. - Test as the customer; the admin view never sets the flag. ### Structure | What you want | What the module does | Default | | --- | --- | --- | | No customer screen at all | Nothing | The default | | A management tab | Ship `pages/dashboard.php` or define `page_dashboard()` | No tab | | No tab, dashboard for admins only | Override `has_client_management()` to return false | Tab appears | | Usage gauges on the overview | Fill `client_overview_data()` | Empty, no gauges | | Shortcuts on the overview | Fill `client_quick_actions()` | Empty, no shortcuts | | A runnable action | Declare it in `$client_callable_methods`, write `handle_{name}()` | Refused | | An action over GET (a download) | Also list it in `$client_readonly_methods` | Token and POST | | Hide a dashboard block from customers | Check `$client_area` around it | Shown to both | ```bash Management tab clicked └─ GET use_module_method-less request -> ClientServices::get_management_details ├─ owner + type + active-state check ├─ client_module_instance() -> $client_area = true, $area_link set ├─ ProductModule::service_management_page() -> get_page('dashboard') └─ inline script tags split out, evaluated separately by the theme bridge Overview tab └─ the services controller, for special and software services ├─ client_overview_data() -> usage rings + account rows └─ client_quick_actions() -> shortcut buttons A shortcut or a dashboard button └─ POST use_module_method -> allowlist -> handle_{name}() ``` ### Walkthrough #### Open the Management Tab Shipping a dashboard page is the opt in: the gate looks for the file. 1. Create `pages/dashboard.php`. Inside it, `$module` is the live instance. 2. For an admin-only dashboard, override the gate. The admin service page still shows it. 3. The theme bridge strips inline scripts out of the HTML and evaluates them separately. #### Fill the Overview `client_overview_data()` returns three lists. Return an empty array while nothing is provisioned: the overview then shows no gauges. 1. `gauges`: one entry per limited resource, zero or below meaning unlimited. 2. `resources`: optional counters — the used and total shape, or a single `value` that is not a ratio. 3. `account`: identity rows. The service password is injected for you. #### Expose an Action Two things are required, neither alone: the name in the allowlist and a `handle_` prefixed method. 1. Declare `public array $client_callable_methods = ['reset_usage'];`, without the prefix. 2. Write `public function handle_reset_usage(): array` — no arguments; read from the request and the service. 3. Build the API client inside the handler: a customer call has nothing set up for it. #### Avoid Showing Everything Twice The same file also serves the admin service page, which has no overview tab. Hide duplicated blocks behind `$client_area` rather than deleting them. ### Reference #### The Five Members ```php // Declared on ProductModule; override what you need. public bool $client_area = false; // set to true only by the client renderer public array $client_callable_methods = []; // names WITHOUT the handle_ prefix public array $client_readonly_methods = []; // a subset of the above, GET-safe public function has_client_management(): bool; public function client_overview_data(): array; public function client_quick_actions(int $limit = 8): array; // Your handler for a declared callable. No parameters; returns the JSON payload. public function handle_reset_usage(): array; ``` #### client_overview_data() Shape ```php return [ 'gauges' => [ [ 'key' => 'storage', 'label' => $this->lang['storage-usage'], 'icon' => 'bi bi-hdd', 'used' => 4.5, 'total' => 20, // 0 or below means unlimited; pass -1 for clarity 'unit' => 'GB', ], ], 'resources' => [ // Ratio form: same keys as a gauge. ['key' => 'seats', 'label' => 'Seats', 'icon' => 'bi bi-people', 'used' => 3, 'total' => 10, 'unit' => ''], // Value form: a single reading with no limit. ['key' => 'region', 'label' => 'Region', 'icon' => 'bi bi-globe', 'value' => 'eu-west'], ], 'account' => [ ['key' => 'account_id', 'label' => 'Account ID', 'type' => 'text', 'copyable' => true, 'value' => 'ac_1042'], ['key' => 'username', 'label' => 'Username', 'type' => 'text', 'copyable' => true, 'value' => 'acme'], ['key' => 'status', 'label' => 'Status', 'type' => 'badge', 'badge_color' => 'success', 'value' => 'Active'], ['key' => 'console', 'label' => 'Console', 'type' => 'link', 'value' => 'https://panel.example.com'], ], ]; ``` - **gauges: total**: Zero or below is unlimited: the gauge moves to the specification list with the infinity sign. - **gauges: no percentage**: Send raw numbers. The theme computes the ratio and the colour tier; a pre-computed percentage is ignored. - **resources: total of zero**: Zero is a real limit here, unlike a gauge. Only a negative total is unlimited. - **account: type**: `text`, `link`, `badge` or `password`. `copyable` adds a copy button; a link row takes the full width. - **account: badge_color**: `success`, `warning`, `danger` or the default. The icon comes from the colour. - **account: the password row**: Injected after the row keyed `username`, or appended when there is no such row. - **every value is shown as given**: Format dates and numbers first: a raw provider timestamp reaches the customer as one. #### client_quick_actions() Shape ```php public function client_quick_actions(int $limit = 8): array { $actions = [ // action => true: runs the handler in place, through the callable allowlist. ['key' => 'reset_usage', 'label' => $this->lang['action-reset-usage'], 'icon' => 'bi bi-arrow-counterclockwise', 'action' => true, 'method' => 'reset_usage'], // action => false: navigates to a page inside the management tab instead. ['key' => 'backups', 'label' => $this->lang['action-backups'], 'icon' => 'bi bi-archive', 'action' => false, 'method' => 'backups'], ]; // Honour the limit: the caller decides how many fit. return array_slice($actions, 0, $limit); } ``` #### Before Your Handler Runs Most "my action does nothing" reports are one of these refusals. ```php // ClientServices::use_module_method, reduced to the decisions. $service = $this->owned_managed_service($uid); // owner, type, module and active-state check $method = (string) Filter::init("REQUEST/method", "route"); $module = $this->client_module_instance($service); // sets $client_area and $area_link $handleMethod = 'handle_' . str_replace('-', '_', $method); $isTool = in_array($method, ['tool_action', 'tool_table', 'sso_panel_login'], true); // BOTH conditions: declared in the allowlist AND the handler actually exists. $isClientCallable = !$isTool && in_array($method, $module->client_callable_methods ?? [], true) && method_exists($module, $handleMethod); if (!$isTool && !$isClientCallable) throw new \Exception(Language::gc("website/services/err-invalid")); // Read-only callables skip the token and the active-service requirement. $isReadonly = $isClientCallable && in_array($method, $module->client_readonly_methods ?? [], true); $mutates = ($method === 'tool_action' || $isClientCallable) && !$isReadonly; if ($mutates && !\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "services")) throw new \Exception(Language::g("needs/csrf-failed")); if ($mutates && ($service['status'] ?? '') !== 'active') throw new \Exception(Language::gc("website/services/err-invalid")); ``` - **ownership, type and module**: It must belong to the signed-in account, be hosting, server, special or software, and name a module other than `none`. A service marked with Restrict Service Details is also not found. - **the allowlist is not optional**: An undeclared handler is refused even when it exists, and silently, so it reads like a broken button. That is what keeps `handle_create` unreachable. - **token and live service**: The owner lookup already demands `active`, so a suspended service is refused for reads too. Mutations also need a valid token. - **gate:service.client_tool**: Runs immediately before your handler. A listener returning a non empty string vetoes the call with that message. - **action:service.client_tool_ran**: Fires afterwards with the service, the requested method, the resolved method name and the result. - **filter:client.service_management_content**: Filters the finished dashboard HTML by reference, before the inline scripts are split out. ### Example The module half and the dashboard half; the second makes the `$client_area` decision. ```php // Declared without the handle_ prefix. Anything absent here is refused. public array $client_callable_methods = ['reset_usage', 'download_report']; // Served over a GET link, so exempt from the token and the POST requirement. public array $client_readonly_methods = ['download_report']; public function client_overview_data(): array { $remote = $this->fetchRemoteStatus(); if (!$remote) return []; // nothing provisioned yet: no gauges $gauges = []; if (isset($remote['storage_limit'])) $gauges[] = [ 'key' => 'storage', 'label' => $this->lang['storage-usage'], 'icon' => 'bi bi-hdd', 'used' => (float) ($remote['used_storage'] ?? 0), 'total' => (float) $remote['storage_limit'] > 0 ? (float) $remote['storage_limit'] : -1, 'unit' => 'GB', ]; $account = []; $username = (string) ($this->options['login']['username'] ?? ''); // The controller injects the password row right after this one. if ($username !== '') $account[] = ['key' => 'username', 'label' => $this->lang['username'], 'type' => 'text', 'copyable' => true, 'value' => $username]; $status = (string) ($remote['status'] ?? ''); if ($status !== '') $account[] = [ 'key' => 'status', 'label' => $this->lang['account-status'], 'type' => 'badge', 'badge_color' => $status === 'active' ? 'success' : 'secondary', 'value' => $this->lang['status-' . $status] ?? ucfirst($status), ]; if (!$gauges && !$account) return []; return ['gauges' => $gauges, 'resources' => [], 'account' => $account]; } public function handle_reset_usage(): array { // Client-triggered: nothing has prepared the API client for you. $this->initApi(); $accountId = $this->options['config']['id'] ?? ''; if (!$accountId) throw new Exception($this->lang['err-not-provisioned']); $this->api->call('accounts/' . $accountId . '/usage/reset', [], 'POST'); return ['status' => 'successful', 'message' => $this->lang['usage-reset-ok']]; } ``` ```php client_area ?? false)): ?>
    ``` ### Pitfalls > **One unguarded element lookup kills the whole panel** > > A block hidden by `$client_area` takes its element ids with it, and inline script that calls `addEventListener` on the missing node throws. The script bridge throws with it, so the customer gets a generic "could not load" message. Guard every lookup. > **A shortcut is a shortcut, not the home of an action** > > Put the action on the dashboard and let the shortcut point at it. > **Empty page under the scheduler** > > Template output is skipped in the scheduled-task context. A command line probe reports a zero length page while the real request returns the full panel. Check over real HTTP or impersonation. ### Related Articles - [Writing a Product Module](https://dev.wisecp.com/en/writing-a-product-module) - [The Client Area Bridge](https://dev.wisecp.com/en/the-client-area-bridge) - [Server Module Tools](https://dev.wisecp.com/en/server-module-tools) - [The Client Area](https://dev.wisecp.com/en/the-client-area) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Security Practices](https://dev.wisecp.com/en/security-practices) ## Writing an Addon Module https://dev.wisecp.com/en/writing-an-addon-module An addon is the module type with no fixed job. It gets a settings form, an admin page, a client page and a hook file. From there it reaches anywhere in the product, without a core edit. ### Overview Every other module type answers a defined question. A server module provisions accounts, a payment module takes money, a registrar registers domains. An addon answers none in particular, which makes it the broadest type and the one most often misused. The nine modules in `coremio/modules/Addons` share a base class and nothing else. They are a ticket assistant, a chat widget, identity verification, two accounting bridges, a virus scanner, a translation tool, a licence manager and the sandbox archetype. > **An addon module is not a product add-on** > > The words collide. A **product add-on** is a billing concept: an extra a customer buys alongside a service, invoiced and activated through the purchase flow. An **addon module** is a plugin you install. Nothing in this article concerns the first one. - **AddonModule**: The base class: configuration, language, encryption, the settings save path, the enable and disable switch. Nothing is abstract or required. - **hooks.php**: A file next to the class, loaded on every request, registering the listeners that put the addon into core flows. - **adminArea()**: An optional page in the admin panel, routed under the addon's own address with no route registration. - **clientArea()**: An optional page in the client area, with a menu entry and an optional pretty address. - **use_ methods**: The request bridge. A method whose name starts with `use_` is callable over the panel's own dispatcher; nothing else is. ### Prerequisites - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration); this article covers only the addon specific parts. - Know what you are extending. A new screen needs the admin page; a changed behaviour needs a hook that already exists. - Decide the audience early. An administrative addon must not expose a client page, because that also opens its request bridge to unauthenticated callers. ### Structure ```bash coremio/modules/Addons/Acme/ ├── Acme.php the class: extends AddonModule ├── config.php meta, status, access privileges, saved settings ├── hooks.php optional: listeners registered on every request ├── logo.png ├── lang/en.php $this->lang, including the meta block ├── views/ templates rendered by $this->view() │ ├── index.php the admin overview │ └── client.php the client page └── src/ your own helper classes, built with plain new ``` ```php return [ 'created_at' => 1561714288, 'meta' => [ 'name' => 'Acme', 'version' => '1.0', 'author' => 'Your name goes here', 'opening-type' => 'normal', // Client-area menu icon. 'font' means icon is a class, 'image' means a path or URL. 'icon_type' => 'font', 'icon' => 'bi bi-puzzle', // Optional pretty address: the page also opens at /{slug}, next to /addon/Acme. 'slug' => 'acme', ], 'show_on_adminArea' => true, // draw the admin page 'show_on_clientArea' => true, // draw the client page and its menu entry 'status' => true, // enabled; the panel switch writes this back 'access_ps' => [], // admin privileges allowed to open it 'settings' => [], // written by the settings form, read by your code ]; ``` | Reach | How | A module that does it | | --- | --- | --- | | A page in the admin panel | `adminArea()` plus a view | Every addon with a screen | | A page in the client area | `clientArea()` plus the configuration flag | The sandbox archetype | | Markup injected into a core screen | A `ui:` hook | The AI assistant, on the ticket reply editor | | A scheduled job | `register:cronjobs` plus a queue handler class | The accounting bridge, polling invoice state | | Reacting to a core event | An `action:` hook | The accounting bridge, on invoice formalization | | Its own API endpoints | `filter:api.routes` plus handler methods | The live chat widget | | An extra admin menu entry | `register:admin.menu` | The chat and the virus scanner | | An extra operation on a core controller | `register:admin.operations` | The virus scanner and the accounting bridge | ### Walkthrough #### Write the Class The constructor does the work. It derives the name from the class, resolves the directory and the public URL, then loads the configuration and the language file. It also fills `$this->admin` and `$this->user`. 1. Name the class exactly like the directory. The base derives the name by reflection, so a mismatch breaks every path it builds. 2. Define `fields()` to get a settings form, built by the same field engine as the other module types. 3. Define `enable()` if installing needs to do anything: create a table, seed a row, check a requirement. #### Ship the Settings Form You build neither the form nor the save path. `fields()` returns descriptors, the panel draws them, and the base class writes the values back under `settings` in your configuration file. 1. `fields()` returns the descriptor map, each entry reading its current value from `$this->config['settings']`. 2. `save_fields()` is optional: it receives the posted values, validates them, and returns the array to store. Encrypt secrets here. 3. `settings_notice()` is optional: return HTML and it appears as a banner above every field. #### Add the Pages Both page methods return the same descriptor: a title, breadcrumbs and content. Routing is automatic, so there is nothing to register. Sub-pages are driven by a request parameter naming a view file, with a fallback when the file is missing. #### Reach Into the Core This is what makes the type useful, and where the discipline lives. Put listeners in `hooks.php` and gate them on the addon being enabled. A hook point must never know your module's name. 1. Load the configuration cheaply at the top and wrap the listeners in a status check. Registering queue handlers is the deliberate exception and stays outside the check. 2. Register the listeners. Anything that produces markup goes in a closure, so nothing is built for a page that will not show it. 3. If the point you need does not exist, open it properly rather than editing the core in place. A generic hook plus your condition in `hooks.php` is the supported shape. ### Reference #### What the Base Class Provides ```php class AddonModule { public string|bool $error = ''; // legacy; new code throws instead public array $config = []; // config.php, already parsed public array $lang = []; // lang/{lang}.php for the active language public string $area_link = ''; // the addon's own page address public string $_name = ''; // the directory and class name public array $user = []; // the signed-in customer, when there is one public array $admin = []; // the signed-in administrator, when there is one public string $url; // public URL of the module directory public string $dir; // filesystem path of the module directory protected string $cryptKey = 'system'; public function __construct(); protected function view($file = '', $variables = []): string; public function privileges(); public function save_settings($pFields, $accessPs): bool; public function change_addon_status($arg = ''); public function save_config($data = []): bool; protected function encode_str(string $str = '', string $key = ''): string; protected function decode_str(string $str = '', string $key = ''): string; public function use_default_settings($formElements = null); public function isEnabled(); } ``` - **view($file, $variables)**: Loads `views/{$file}` from your module directory with the given variables extracted. Pass the file name including the extension. - **save_config(array $data): bool**: Replaces the whole configuration file: read it, change what you need, write the result. It goes through the managed file writer, which handles the compiled-file cache. - **encode_str(), decode_str()**: Encrypt and decrypt with the installation-bound key named by `$cryptKey`, the system key by default. Empty stays empty; a failed decryption returns an empty string, not the ciphertext. - **isEnabled()**: Reads the status flag out of the configuration. Every hook listener should consult it before doing anything. - **privileges()**: The full privilege list, for a settings screen where the operator picks which roles may open the addon. - **use_default_settings($formElements)**: Wraps the standard settings screen around your fields: status switch, privilege picker and save button. #### Optional Methods None exists on the base class. Each is probed with `method_exists()` and skipped when absent, so the signature is the contract. ```php // Settings form. public function fields(): array; public function save_fields($fields = []): array|bool; // return the array to store, or throw public function settings_notice(): string; // HTML banner above the fields public function edit_settings_tab(\WISECP\Components\Tab $tab): void; // add a tab to the settings page // Install lifecycle. Returning false aborts the state change. public function enable(): bool; public function disable(): bool; public function uninstall(): bool; // Pages. Each returns a page descriptor. public function adminArea(): array; public function clientArea(): array; public function main(): string; // a public page for visitors who are not signed in // Request bridge: only a use_-prefixed method is reachable. public function use_sample_method(): array|string; ``` | Method | Runs when | Returning false means | | --- | --- | --- | | fields | The settings screen opens, and again on save | not applicable | | save_fields | Settings are saved, before anything is written | Abort, with the message from the error property | | enable | The operator switches the addon on | It stays off | | disable | The operator switches it off | It stays on | | uninstall | The addon is removed | Removal is refused | | adminArea | The admin opens the addon page | not applicable | | clientArea | A signed-in customer opens the addon page | not applicable | | main | A visitor opens the public page | not applicable | #### The Page Descriptor ```php return [ 'page_title' => 'Acme', 'breadcrumbs' => [ ['link' => $this->area_link, 'title' => 'Acme'], ['link' => '', 'title' => 'Reports'], // an empty link marks the current page ], // Admin only. Buttons drawn next to the page title. 'page_title_buttons' => [ [ 'outerHTML' => '', // bypasses the three keys below when set 'element' => 'button', 'attributes' => ['class' => 'btn btn-primary', 'onclick' => "acmeRefresh();"], 'content' => 'Refresh', ], ], 'content' => $this->view('index.php', $variables), ]; ``` #### The Request Bridge Two dispatchers reach an addon: one behind the admin session, one on the public site. Both apply the same rule, the requested name is prefixed with `use_` and nothing else is callable. ```php $method = (string) Filter::init("REQUEST/method", "route"); // Spaces, hyphens and dots all normalise to an underscore, then the prefix is added. $method = "use_" . str_replace([' ', '-', '.'], '_', $method); if (!method_exists($instance, $method)) throw new Exception("Module does not have a method named {$method}."); $result = $instance->$method(); // A falsy return is treated as failure, so never return an empty array on success. if (!$result) throw new Exception($instance->error ?: "Unknown error"); ``` - **the admin bridge**: Behind the admin session and the addon privilege; the one an admin-only addon uses. - **the website bridge**: Open only to an addon with a web face. A client page requires a signed-in customer; a public page does not. - **no web face, no website bridge**: Without a client page or a public page the website dispatcher cannot reach the addon at all. - **a falsy return is an error**: Return a non empty array or a non empty string. `true`, `[]` and `''` are all read as failure and turn into an exception. - **no arguments**: The method takes none. Read the request yourself, through the input filter, exactly as an operation would. #### Hook Points Addons Actually Use - **register:cronjobs**: Register queue handler classes. It runs on every installation, because a disabled addon never has a job dispatched to it. Register from inside the listener; the return is ignored. - **filter:api.routes**: Append the addon's own endpoints. Check the audience argument first, then push route tuples pointing at your own methods. - **register:admin.menu**: Add an entry to the admin navigation, so an operator can find the addon. - **register:admin.operations**: Attach an extra operation to a core controller, so the addon can answer a request on a screen it does not own. - **action: hooks**: React to something that happened. This one fires when an invoice is formalized, where an accounting bridge starts. - **ui: hooks**: Inject markup into a core screen. The ticket detail page carries several, and that is where the assistant and the scanner appear. - **action:module.addon_settings_saved**: Fires after any addon's settings are written, with the module name and the new configuration. ### Example A minimal but complete addon: the settings, the hook file that makes it do something, the code that reads the settings back. A setting nobody reads is the most common defect in this type. ```php namespace WISECP\Modules\Addons; use AddonModule; use Exception; use Filter; class Acme extends AddonModule { public string $version = '1.0'; public function fields(): array { $settings = $this->config['settings'] ?? []; return [ 'api_key' => [ 'name' => $this->lang['api-key'], 'description' => $this->lang['api-key-desc'], 'type' => 'password', 'wrap_width' => 100, // Show the stored value only as a mask; the real key stays encrypted. 'value' => ($settings['api_key'] ?? '') !== '' ? '********' : '', ], 'notify' => [ 'name' => $this->lang['notify'], 'type' => 'switch', 'wrap_width' => 100, // A switch reads 'checked', not 'value'. 'checked' => (int) ($settings['notify'] ?? 0) === 1, ], 'threshold' => [ 'name' => $this->lang['threshold'], 'type' => 'text', 'wrap_width' => 100, 'value' => $settings['threshold'] ?? '100', // Only shown while the switch above is on. 'parent' => 'notify', 'parentEffect' => 'hide', ], ]; } public function save_fields($fields = []): array|bool { // The mask means "unchanged": keep whatever is already stored. if (($fields['api_key'] ?? '') === '********') $fields['api_key'] = $this->config['settings']['api_key'] ?? ''; elseif (($fields['api_key'] ?? '') !== '') $fields['api_key'] = $this->encode_str($fields['api_key']); if ((int) ($fields['notify'] ?? 0) === 1 && (int) ($fields['threshold'] ?? 0) <= 0) throw new Exception($this->lang['err-threshold']); return $fields; } public function enable(): bool { // Anything installation needs. Returning false leaves the addon off. return true; } public function adminArea(): array { $action = Filter::init("REQUEST/action", "route") ?: 'index'; if (!is_file($this->dir . 'views' . DS . $action . '.php')) $action = 'index'; return [ 'page_title' => $this->lang['meta']['name'], 'breadcrumbs' => [['link' => '', 'title' => $this->lang['meta']['name']]], 'content' => $this->view($action . '.php', [ 'link' => $this->area_link, 'name' => $this->lang['meta']['name'], 'version' => $this->config['meta']['version'], ]), ]; } // Reachable as ?operation=use_addon_method&method=refresh public function use_refresh(): array { $id = (int) Filter::init("POST/id", "rnumbers"); if (!$id) throw new Exception($this->lang['err-id-required']); // Never return an empty array on success: the bridge reads falsy as failure. return ['status' => 'successful', 'id' => $id]; } } ``` ```php queue_invoice((int) ($invoice['id'] ?? 0)); }); // Inject markup into a core screen. Hook::add('ui:admin.tickets_detail.bottom', 1, function ($ticket) { $m = Modules::getInstance('Addons', 'Acme'); return $m ? $m->ticket_panel($ticket) : ''; }); } ``` ```php public function queue_invoice(int $invoiceId): void { if (!$invoiceId) return; $settings = $this->config['settings'] ?? []; // Never empty(): a stored "0" would read as absent and silently flip the switch on. if ((int) ($settings['notify'] ?? 0) !== 1) return; // Decrypt only at the point of use, never into a property. $apiKey = $this->decode_str($settings['api_key'] ?? ''); if ($apiKey === '') throw new Exception($this->lang['err-not-configured']); $threshold = (int) ($settings['threshold'] ?? 0); // ... hand the invoice to the provider } ``` ### Pitfalls > **The hook file runs on every request, including the ones that ignore you** > > Load the configuration with the lightweight call that skips including the class. Build the instance inside each listener, not once at the top: an addon that constructs an API client at file scope taxes every page. > **save_config() replaces the whole file** > > It does not merge. Read the current configuration, change your keys, pass the complete array back. Passing only what you changed drops the meta block, the status flag and the privilege list. > **A client page also opens the website bridge** > > An addon with only an admin page cannot be reached from the public dispatcher. Adding a client page or a public page changes that for every one of its `use_` methods, not only the intended ones. > **A bridge method must not return something falsy** > > The dispatcher reads any falsy return as failure and raises an exception. A method with nothing to report still returns a non empty payload, a status key for example. > **The core must never know your module's name** > > When the behaviour you need is not reachable, open a generic hook point and put your condition in your own hook file. A core file that names your module, your flag or your table is a change the next upgrade overwrites. > **Throw, and do not copy the error property from the sandbox** > > The archetype still shows the old pattern of assigning to the error property and returning false. It is a leftover of the previous generation; production addons throw instead. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints) - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) # Module Development / Commerce Modules ## Writing a Payment Gateway https://dev.wisecp.com/en/writing-a-payment-gateway A Payment module turns a provider into a checkout option. It shows the pay screen, charges the card or forwards the client, and returns an array the core settles. ### Overview A gateway module extends `PaymentGatewayModule` and lives in `coremio/modules/Payment/{Name}/{Name}.php`. 164 ship, four the sandbox samples below. The payment line has four legs; the module owns two. ```text checkout snapshot core writes a `checkouts` row holding the exact figures the client saw | pay surface MODULE payment_screen() decides what the client meets | capture / callback MODULE capture($params) charges, or callback() receives the provider's return | settle core settle_checkout() books the payment onto the invoices ``` **A module never marks an invoice paid.** It returns `['status' => 'successful', ...]` and the core books it. Doing it yourself double-books: a retried callback runs the code twice. ### Prerequisites - The module skeleton: directory, class file, `config.php`, `lang/`. See [Module Anatomy](https://dev.wisecp.com/en/module-anatomy). - Four `Sample*` gateways: working modules, one per archetype. - Provider credentials for a test account, and a public host if the provider posts a callback. - Instances come from `Modules::getInstance('Payment', 'Acme')`, never `new Acme()`. ### Structure ```text Acme.php the class: extends PaymentGatewayModule config.php returns ['meta' => [...], 'settings' => [...]] lang/en.php returns a flat key => string map, read as $this->lang['key'] lang/tr.php logo.png shown in the admin module list and on the checkout method row ``` Pick the archetype first; it decides which methods you declare. The core finds them with `method_exists`. | Archetype | You declare | Client experience | Sandbox to copy | | --- | --- | --- | --- | | Merchant card form | `capture()` plus `standard_card = true` | Card fields inside the checkout page, charged by you | `SampleMerchant` | | Redirect / hosted page | `area()` and `callback()` | A forwarding pane, then the provider's own page | `SampleThirdParty` | | Card vault | `capture()`, `card_setup_result()`, meta `card-storage-supported` | Saved cards and off session auto pay | `SampleTokenized` | | Recurring agreement | `payment_screen()` override, `callback()`, `cancel_subscription()` | Pay once or subscribe, then the provider charges renewals | `SampleSubscription` | PayTR overrides `payment_screen()` for an iframe. Iyzico redirects with `area()`. Stripe vaults cards; PayPal pairs a one time charge with an agreement. ### Walkthrough #### The Module The base constructor fills `$this->config`, `$this->lang`, `$this->dir`, `$this->url`, callback links and capability settings; call `parent::__construct()`. ```php class Acme extends PaymentGatewayModule { public function __construct() { parent::__construct(); // $this->links['callback'] is now the public return address for this // module, signed with get_auth_token(); hand it to the provider. } } ``` #### Capabilities Capability settings come from `config.php` meta, read by the base constructor. ```php [ 'name' => 'Acme Pay', 'version' => '1.0', 'icon_type' => 'font', 'icon' => 'bi bi-credit-card', 'card-storage-supported' => false, // can vault a card token 'auto-payment-supported' => false, // can charge off session (auto pay) 'standard-card-form' => false, // renders the shared card entry form 'embedded-pane' => true, // pane may embed inside the checkout page 'subscription-mode' => 'amount', // items | amount | fixed, see below 'subscription-anchor' => false, // may start an agreement on a future date ], 'settings' => [ 'commission_rate' => 0, 'force_convert_to' => 0, 'min_amount' => 0, // accepted amount range; 0 leaves the bound off 'max_amount' => 0, 'amount_limit_cid' => 0, // currency the two bounds are written in 'accepted_countries' => [], 'unaccepted_countries' => [], ], ]; ``` #### Settings Form Declare `config_fields()`; the settings screen builds itself. `controller_settings()` saves every declared key into `config['settings']`, plus the shared fields: status, commission rate, forced currency, amount range, country lists. ```php public function config_fields() { return [ 'merchant_id' => [ 'name' => $this->lang['merchant-id'] ?? 'Merchant ID', 'description' => $this->lang['merchant-id-desc'] ?? '', 'type' => 'text', 'value' => $this->config['settings']['merchant_id'] ?? '', 'placeholder' => 'acme_12345', ], 'secret_key' => [ 'name' => $this->lang['secret-key'] ?? 'Secret Key', 'type' => 'password', 'value' => $this->config['settings']['secret_key'] ?? '', ], ]; } ``` #### Pay Surface For a redirect gateway declare `area($params)` and return markup. `pre_area()` assembles `$params`; `payment_screen()` reports `['mode' => 'html', ...]`. `embedded-pane` true puts it in the checkout page, false on the dedicated pay page. #### The Return Declare `callback()`. The dispatcher routes `payments/{Module}/{auth_token}/callback` to it and passes what the provider sent, resolving nothing. Identify the checkout, verify the signature, describe the outcome; the dispatcher calls `settle_checkout()`. > **The callback leg has no session** > > Carry the checkout id yourself, usually as a query parameter on the return address from `LinkGenerator::wQS()`. Read the owner from it. #### Charge a Card Declare `capture($params)`. `pre_capture()` takes the browser's card post and validates the CSRF token and the card fields. It also confirms the checkout belongs to the acting account. Then it resolves any installment plan and stashes the card metadata, never the CVC. Return a status array; success settles immediately. ### Reference #### The Base Class ```php // The checkout in play public function set_checkout($checkout): void; public function get_checkout($id = 0, $status = '', $type = '', $uid = 0); public function save_checkout($id = 0, $fields = []): bool; public function checkout_total(): float; // authoritative amount to charge public function checkout_currency(): int; // currency id, NOT the ISO code public function getItems(): array; // Currency public function cid_convert_code($id = 0); // 4 => "USD" public function currency($id = 0); // idempotent: id or code in, row out // Money and fees public function commission_fee_calculator($amount): float; public function get_commission_rate(); public function installment_plans(array $bin, float $base, int $cid): array; public function installment_plan_total(float $base, float $rate): float; // Cards public function get_stored_card($id = 0, $user_id = 0): array; public function checkSaveCard(): bool; public function checkAutoPay(): bool; public function card_setup_result(): array; public function generate_card_identification_checkout($pmethod = ''): int; // Subscriptions public function checkout_subscribable(): array; public function set_subscribed_items($arg = []): void; public function set_subscribed_sources(array $sources = []): void; // Plumbing public function get_auth_token(): string; public function define_function($name = '', $function_name = ''): void; public function controller_settings($extraFields = []): array; public function isEnabled(): bool; public function save_custom_data($data, $checkout_id = 0): void; public function get_custom_data($checkout_id = 0); // The three the core calls, overridable public function payment_screen(): array; public function pre_area(): string|false; public function pre_capture(): string; // The settlement, called for you. Never call it from module code. public static function settle_checkout(PaymentGatewayModule $module, array $checkout, array $result): array; ``` - **checkout_total()**: The only correct amount to send a provider. It already carries tax, commission fee and any installment surcharge. - **checkout_currency()**: The internal currency *id*. Providers want the ISO code, so pass it through `cid_convert_code()`. - **get_auth_token()**: The signature segment inside the callback address. A callback whose token does not match is rejected, so build the address from `$this->links`. - **define_function()**: Publishes one extra endpoint at `payments/{Module}/function/{name}` for a multi step pane. Use underscores; a hyphen or dot is folded into one. - **settle_checkout()**: Idempotent by design: it re-reads the row, short circuits when the checkout is already paid and de-duplicates by transaction id. A twin callback is harmless. #### What You Declare The core probes these with `method_exists`. Only `card_setup_result()` exists on the base, defaulting to `['status' => 'unsupported']`: an override, the rest additions. ```php public function capture($params = []); // card charge, returns the status array public function area($params = []); // redirect gateway, returns markup public function callback(); // provider return, returns the status array public function bin_check($number); // local BIN table, returns card metadata public function config_fields(); // admin settings fields public function refundInvoice($invoice = []); // refund, returns bool public function card_setup_result(): array; // OVERRIDE: 3-D return of a vaulting run // The sandboxes declare one parameter, but the core calls this with TWO: the // charge base is passed so a provider whose plans depend on the amount can ask. public function installment_rates($bin = [], $base = 0); // [count => deferred-interest percent] // Subscriptions public function cancel_subscription($params = []): bool; public function get_subscription($params = []): array|false; public function change_subscription_fee($params = [], $value = 0, $currency = 0): bool; public function remove_subscription_item($params = []): bool; // 'items' mode: drop one line ``` #### $params Keys Both receive a superset of the V3 contract. `area()` gets six keys: `checkout_id`, `amount`, `currency`, `currency_id`, `clientInfo`, `items`. The rest below, including the `id` mirror, is capture only. - **checkout_id**: The checkout row id. Also mirrored as `id` for V3 modules. - **amount**: Float. The full figure to charge, grown by the installment surcharge when a plan was resolved. - **currency**: The ISO **code** as a string, for example `"TRY"`. Casting it to int is the most common porting bug. - **currency_id**: The int currency id, for modules that price or convert. - **clientInfo**: The payer's name, e-mail and address, from the checkout's frozen user data. - **items**: The line rows. Their totals are **net**; they do not add up to `amount`. - **data**: The checkout data blob, including the frozen `user_data`. Capture only. - **type**: Card schema or type, from your own `bin_check()` or the stored card row. Capture only. - **installment**: The plan count the server approved, not what the browser asked for. Zero means a single charge. Capture only. - **num, holder_name, expiry_m, expiry_y, cvc**: A new card, already validated. Present only when no stored card was picked. - **card_storage**: **Absent unless a stored card is being charged.** The decrypted vault row, including `token` and `ln4`. - **save_card, auto_pay**: Booleans, both forced off when the payer is a sub user on someone else's account. #### What You Return | Method | Key | Meaning | | --- | --- | --- | | `capture()` | `status` | Settled: `successful`, `success`, `paid`, `pending`, `papproval`. Handed back to the browser: `redirect`, `3d`, `output`. Anything else is `error` | | `redirect` | Where to send the browser, for 3-D or a step up challenge. Read only under `redirect` or `3d` | | | `message` | A `label => value` map on success, a sentence on failure | | | `card` | The tokenized card package, handed to the vault when the client asked to save it | | | `output` | Raw markup to print full page, for a bank form that auto submits. Read only under `output` or `3d` | | | `callback()` | `status` | `successful`, `pending` or `error` | | `message` | Put the provider reference under `Transaction ID`; settle de-duplicates on it | | | `paid` | `['amount' => float, 'currency' => int]`, the figure truly charged when it differs | | | `callback_message` | Echoed verbatim instead of redirecting, for a provider wanting an acknowledgement | | | `payment_screen()` | `mode` | `card`, `html`, `redirect`, `choices`, `legacy`, `none` or `error` | | `html` | Server produced markup, printed unescaped | | | `redirect` | Target for the single forwarding button | | | `choices` | Rows of `['label' => string, 'url' => string, 'image' => string]`, as PayPal offers one time payment beside an agreement. `note` prints above them; an `error` mode carries `message` | | The base `payment_screen()` derives the mode from the methods you declared. Override it only when the provider needs its own markup. #### Commission Commission is a header field, never a line item. The operator sets `commission_rate` per gateway; the core prices it during recalculation. `checkout_total()` contains the fee, `commission_fee_calculator()` is display only. ```php $base = $this->checkout_total(); $fee = $this->commission_fee_calculator($base); // rate from config['settings']['commission_rate'] $note = Money::formatter_symbol($fee, $this->checkout_currency()); ``` #### Amount Range Three shared settings decide whether the gateway is offered at all, stored under `config['settings']`. - **min_amount**: Float. Smallest payable total; `0` leaves the lower bound off. - **max_amount**: Float. Largest payable total; `0` leaves the upper bound off. A maximum below the minimum is refused at save time. - **amount_limit_cid**: Currency id the bounds are written in. Empty falls back to the system currency. `Checkout::payment_methods($ucid, ['amount' => $total])` converts both bounds into the paying currency and drops a gateway whose range excludes the total. Checkout, invoice, bulk payment and Add Funds pass through it. The figure compared is the total *before* this gateway's commission and installment charge. Do not re-check the range inside `capture()`: the total already carries the fee. Dropping the field from the settings form (`'unusedFields' => ['amount_limits']`) posts neither key, and the stored range survives. A page without the total reads the bounds off the list entry. ### Example A complete redirect gateway. The pane builds the address the provider calls back; the callback finds that checkout. ```php class Acme extends PaymentGatewayModule { public function area($params = []) { $cid = (int) ($params['currency_id'] ?? 0); // The return address the provider will call. The auth token inside // $this->links['callback'] is what makes the dispatcher accept it, and // custom_id is how the callback finds this checkout without a session. $return = LinkGenerator::wQS($this->links['callback'], [ 'custom_id' => (int) ($params['checkout_id'] ?? $this->checkout_id), ]); $session = $this->open_provider_session([ 'merchant' => $this->config['settings']['merchant_id'] ?? '', 'amount' => $params['amount'] ?? 0, 'currency' => $params['currency'] ?? '', // ISO code, already converted 'reference' => 'chk_' . (int) $this->checkout_id, 'return_url' => $return, ]); if (($session['url'] ?? '') === '') return ''; // an empty return makes payment_screen() report mode "error" $label = htmlspecialchars((string) ($this->lang['pay-button'] ?? 'Continue'), ENT_QUOTES); $target = htmlspecialchars($session['url'], ENT_QUOTES); $amount = htmlspecialchars(Money::formatter_symbol((float) ($params['amount'] ?? 0), $cid), ENT_QUOTES); return '
    ' . '' . $amount . '' . '' . $label . '' . '
    '; } } ``` ```php public function callback() { $checkout_id = (int) Filter::init("REQUEST/custom_id", "numbers"); $checkout = $checkout_id ? $this->get_checkout($checkout_id) : false; if (!$checkout) return ['status' => "error", 'message' => "checkout-not-found"]; // set_checkout() populates $this->checkout, $this->checkout_id and the // client info, so checkout_total() and checkout_currency() answer below. $this->set_checkout($checkout); $reference = (string) Filter::init("REQUEST/reference", "letters_numbers"); $signature = (string) Filter::init("REQUEST/signature", "letters_numbers"); // Verify BEFORE trusting anything: the callback address is public. if (!$this->signature_matches($reference, $signature)) return ['status' => "error", 'checkout_id' => $checkout_id, 'message' => $this->pay_lang("error-verification")]; $remote = $this->fetch_provider_charge($reference); if (($remote['state'] ?? '') !== 'captured') return [ 'status' => "error", 'checkout_id' => $checkout_id, 'message' => (string) ($remote['reason'] ?? ($this->lang['error-declined'] ?? 'Declined.')), ]; return [ 'status' => "successful", 'checkout_id' => $checkout_id, 'message' => ['Transaction ID' => $reference], // Report what was ACTUALLY taken when it differs from the snapshot, // for example after an installment plan chosen on the provider page. 'paid' => [ 'amount' => (float) ($remote['amount'] ?? $this->checkout_total()), 'currency' => $this->checkout_currency(), ], ]; } ``` ```php // coremio/classes/PaymentGatewayModule.php, settle_checkout() $result = $module->callback(); // 1. already paid? -> short circuit, no second booking // 2. status not successful -> log, send the client to links['failed'] // 3. deferred order? -> build the order and the invoice from the blueprint // 4. charged more than the snapshot? -> book the installment surcharge first // 5. book each invoice: foreach (\Checkout::invoice_ids($fresh) as $invoiceId) { $due = round((float) Invoices::balance($invoiceId), 2); if ($due <= 0.005) continue; Invoices::add_payment($invoiceId, [ 'amount' => $due, 'currency' => (int) (Invoices::get($invoiceId, ['select' => "currency"])["currency"] ?? 0), 'pmethod' => $module->name, 'transaction_id' => $tx, // from message['Transaction ID'] 'description' => $module->lang["name"] ?? $module->name, ]); } ``` A full payment flips the invoice to paid. The linked order activates, the service is provisioned, the income row written, the notification sent. ### Pitfalls > **params['currency'] is an ISO code, not a number** > > `(int) $params['currency']` yields nonsense such as `4` where the provider expected `USD`. For the id, read `currency_id`. > **Line items do not add up to the total** > > Item totals are net; there is no `data.tax` key and no `amount_including_discount`, both V3. For a line breakdown, reconcile the remainder against `checkout_total()` with a "tax and fees" row. > **An empty card_storage array reads as "a stored card is present"** > > Modules gate that branch with `is_array($params['card_storage'] ?? null)`, so the key is absent for a new card. Never default it to `[]`. > **A server to server callback wants an acknowledgement, not a redirect** > > Return `callback_message` and the dispatcher echoes it. Redirect a machine caller and the provider treats the notification as failed, then retries. > **The CVC is never persisted** > > It is passed to `capture()` and forgotten. Writing it into checkout data or the vault is a compliance failure. The stored card keeps only the provider's token and display metadata. > **Report failure through the return array** > > The contract is `['status' => 'error', 'message' => ...]`, which the settlement logs and shows. `$this->error` plus false is a legacy fallback, read only when the array is empty. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Currency Module](https://dev.wisecp.com/en/writing-a-currency-module) - [Writing a Fraud Module](https://dev.wisecp.com/en/writing-a-fraud-module) - [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) ## Writing a Registrar Module https://dev.wisecp.com/en/writing-a-registrar-module A Registrar module connects a domain provider to the automation. It registers, transfers and renews names, and answers every management screen the client area offers for a domain. ### Overview A registrar module extends `RegistrarModule` and lives in `coremio/modules/Registrars/{Name}/{Name}.php`. Of the 21 shipped, `ExampleRegistrarModule` is not a provider but a commented template. Start there: it documents every optional method with its exact return shape. A domain service is not a hosting service: no server row, no control panel session. The provider is reached over its own API and the core mirrors what it said. Two consequences shape the contract: - **Everything is keyed off `$this->options`**, not passed as arguments. - **Almost every method is optional.** The core probes with `method_exists`; a screen whose method is missing is not offered. Ship four methods and grow. ### Prerequisites - A reseller or test account plus its API credentials. Registrations cost money even in most sandboxes; check what the provider's test mode does before a create. - The module skeleton: see [Module Anatomy](https://dev.wisecp.com/en/module-anatomy). - Instances come from `Modules::getInstance('Registrars', 'Acme')`, never from `new`. - Copy `ExampleRegistrarModule` to your own directory and rename the class, the file and the config name together. ### Structure ```text Acme.php the class: extends RegistrarModule ApiClient.php the HTTP client, included by hand from initApi() config.php ['meta' => [...], 'settings' => ['whois-types' => true, 'dns-record-types' => [...]]] lang/en.php lang/tr.php logo.png ``` The template splits transport from contract. `ApiClient.php` speaks HTTP and knows nothing about services; the module class maps the core's vocabulary onto the provider's. > **Never build the API client in the constructor** > > `$this->config` and `$this->service` are filled *after* construction. A client built in `__construct()` reads empty credentials and fails like a wrong API key. Use a lazy `initApi()`, called first in every method that talks to the provider. ### Walkthrough #### Wire the API Client Lazily ```php private function initApi(): void { if ($this->api) return; include_once __DIR__ . DS . 'ApiClient.php'; $this->api = new ApiClient($this->config['settings'] ?? []); // Every provider call lands in the module log, which is the only place an // operator can see what was actually sent when a registration fails. $this->api->logger = fn ($action, $req, $resp) => $this->save_log($action, $req, $resp); } ``` #### Declare Settings and a Connection Test Declare `config_fields($data)` and the settings screen builds itself; `$data` is the saved `config['settings']`. Add `testConnection($config)` and the screen grows a button that proves the credentials. Field keys must match `config.php`. #### Implement the Lifecycle Four methods make a usable module: `check()`, `register()`, `renew()` and `sync()`. You never write `create()`: the base owns it and dispatches on the transfer code. ```php // coremio/classes/RegistrarModule.php $hasTcode = !empty($this->options['tcode']); $method = $hasTcode ? 'transfer' : 'register'; // A transfer already submitted is not submitted twice: a pending event makes // create() re-check with Services::check_transfer_status() instead. foreach (Hook::run('gate:domain.create', $this->service, $this->options, $method) as $veto) if (is_string($veto) && $veto !== '') throw new \Exception($veto); $result = $this->{$method}(); // your method, no arguments Hook::run('action:domain.created', $this->service, $result, $method); // A successful transfer does NOT mean an active domain: the base records the // transfer event and reports the service as still in process. if ($hasTcode && $result !== false) return ['status' => 'inprocess']; ``` #### Publish the TLD Catalogue Declare `tlds()` so the operator can import the provider's list with costs, year limits and per-TLD options. `cost_prices()` is the older, price-only twin, used when `tlds()` is absent. #### Add the Management Surfaces Each is one method, and each one that exists turns on a control in the client area. ### Reference #### What the Base Class Gives You ```php // Orchestration: you do NOT override these two public function create(): array|bool; // dispatches to register() or transfer() public function renew(): array|bool; // wraps renewal() when you declare that instead // Context public function set_service(array|int $service = []): void; public function set_product(array|int $product = []): void; public function set_order(array|int $order = []): void; public static function get_doc_lang($param, $lang = ''); // Import and settings screens public function import_domain($data = []): array; public function apply_import_tlds($data = []): bool; public function controller_settings($extraFields = []): array; public function controller_test_connection(): array; public function controller_domains(): string; public function controller_tlds(): string; public function controller_import(): array; public function controller_import_tld(): array; // From ModuleBaseTrait protected function save_log($action = '', $request = '', $response = '', $processed = ''): int|bool; protected function encode_str(string $str = '', string $key = ''): string; protected function decode_str(string $str = '', string $key = ''): string; public function logo(): string; ``` - **create()**: The entry point the provisioning engine calls. It picks register or transfer, runs the veto hook, and turns a submitted transfer into `['status' => 'inprocess']`. - **renew()**: Two shapes: override `renew()`, as every current module does, or declare `renewal()` and let the base call it. Not both. - **import_domain()**: Turns one row from `domains()` into a local service. Your side is `domains()` and `get_info()`. - **save_log()**: Writes request and response to the module log. Route the API client through it. - **$sample_data**: Set `true` on a demo module answering from mock data. The status sync skips those modules, so invented expiry dates never overwrite a real due date. #### What You Declare ```php public function check($sld = null, $tlds = []): array; // availability, keyed BY TLD public function register(): array|bool; public function transfer(): array|bool; public function renew(): array|bool; public function sync(): array|false; // status + dates from the provider // Legacy alternative to renew(): declare renewal() and the base calls it instead. // Zero parameters means it is called bare. With parameters the order is NOT the // register() one: the options array comes FIRST and there is no $dns/$whois pair. public function renewal($options, $domain, $sld, $tld, $year, $oduedate, $nduedate); ``` ```php private function initApi(): void; // your own helper, not a core hook public function config_fields($data = []): array; public function testConnection($config = []): bool; public function suspend(): bool; public function unsuspend(): bool; public function cancel(): bool; public function restore(): bool; public function is_inactive(): bool; public function transfer_sync(): array|false; public function get_info(): array|false; public function domains(); // provider's domain list, for import public function tlds(); // catalogue with pricing, wins over cost_prices() public function cost_prices($type = 'domain'); public function save_nameservers(array $dns): true; public function get_contacts(): array|false; public function save_contacts(array $whois): bool; public function get_transfer_lock(): string; // 'active' | 'passive' public function toggle_transfer_lock(string $status): bool; public function get_whois_privacy(): string; public function toggle_whois_privacy(string $status): bool; public function get_auth_code(): string|true; // true = the registrar mailed it public function get_child_nameservers(): array|false; public function add_child_nameserver(string $ns, string $ip): array; public function save_child_nameserver(array $old, string $new_ns, string $new_ip): array; public function delete_child_nameserver(string $ns, string $ip): bool; public function get_dns_records(): array|false; public function add_dns_record($type, $name, $value, $ttl, $priority); public function update_dns_record($type = '', $name = '', $value = '', $identity = '', $ttl = '', $priority = ''); public function delete_dns_record($type = '', $name = '', $value = '', $identity = ''); public function get_dnssec_records(): array|false; public function add_dnssec_record($digest, $key_tag, $digest_type, $algorithm); public function delete_dnssec_record($digest, $key_tag, $digest_type, $algorithm, $identity = ''); public function get_forwarding(); public function set_forwarding($protocol = '', $method = '', $domain = ''); public function cancel_forwarding(): bool; public function get_email_forwards(); public function add_email_forward($prefix = '', $target = ''); public function update_email_forward($prefix = '', $target = '', $target_new = '', $identity = ''); public function delete_email_forward($prefix = '', $target = '', $identity = ''); public function addon_create(array $addon): bool; public function addon_suspend(array $addon): bool; public function addon_unsuspend(array $addon): bool; public function addon_cancel(array $addon): bool; ``` > **The legacy argument shape is easy to mix up** > > The base inspects your method with Reflection. Zero parameters means it is called bare. With parameters, `register()` and `transfer()` get `($domain, $sld, $tld, $year, $dns, $whois, $wprivacy, $eppCode)`; `renewal()` gets a *different* order of seven, options first and no contacts. Every current module uses the zero parameter form and reads `$this->options`. #### What Is on $this->options - **domain**: The full name, in Unicode. Convert with `idn_to_ascii()` before sending. - **name, sld, tld**: The parts. `name` is the second level label and `sld` its legacy alias, so read `$this->options['sld'] ?? $this->options['name'] ?? ''`. - **year**: The period in years. Fall back to `$this->service['period_time']`, then to 1. - **dns**: The nameserver list. Pass `array_values()` of it; providers reject a JSON object where they expect an array. - **whois**: Contacts, keyed `registrant`, `administrative`, `technical`, `billing`. Shape below. - **tcode**: The transfer authorisation code. Its presence makes the base call `transfer()` instead of `register()`. - **$this->docs**: Extra documents a TLD demands, declared under `settings['doc-fields'][$tld]`. A `file` field holds a path, so base64 the contents. Labels come from `get_doc_lang()`. - **$this->addon_params**: Resolved paid add-ons, for example `whois-privacy`. Read it to decide whether to buy the provider's equivalent. ```php $contact = $this->options['whois']['registrant'] ?? []; // FirstName LastName Name Company EMail // Country City State AddressLine1 AddressLine2 ZipCode // PhoneCountryCode Phone FaxCountryCode Fax // // Country is the two letter ISO code. Name is the joined display form and is // filled on read; on write, set FirstName and LastName. ``` #### What You Return | Method | Shape | Notes | | --- | --- | --- | | `check()` | `[tld => ['status' => 'available'\|'unavailable']]` | Keyed by TLD. A premium name adds `premium => true` and `premium_price => ['amount' => float, 'currency' => string]` | | `register()` | `true` on success | An array may carry extra service fields; `false` triggers the failure hook | | `transfer()` | `true` on submission | The base converts it to `['status' => 'inprocess']`; `transfer_sync()` decides completion | | `sync()` | `['creationtime', 'endtime', 'status']` | Dates as `Y-m-d`; status is `active`, `expired`, `transferred` or `unknown` | | `transfer_sync()` | same keys | Status is only `active` or `pending` | | `get_info()` | `['creation_time', 'end_time', 'ns1'..'ns4', 'whois', 'privacy', 'transferlock']` | Underscore names differ from `sync()`. Used by the import | | `get_transfer_lock()` | `'active'` or `'passive'` | Locked is `active`; the setter takes `'enable'`/`'disable'` | | `get_contacts()` | `[type => contact]` | Same four types and keys as the write side | #### The TLD Catalogue Shape `tlds()` and `cost_prices()` share three formats, all normalised to the extended one on import. The extended one carries the year limits and the options the order form may offer. ```php // Format 1, price only. Priced in the module's own cost currency, which is the // integer currency id under settings['cost-currency'] (default 4, USD). return [ 'com' => ['register' => 9.90, 'transfer' => 9.90, 'renewal' => 9.90], ]; // Format 1.5, one amount per type with its own currency return [ 'com' => ['price' => ['register' => ['amount' => 9.90, 'currency' => 'USD']]], ]; // Format 2, recommended: per-year, per-currency, with capabilities return [ 'com' => [ 'min_years' => 1, 'max_years' => 10, // the renewal term cap the client area enforces 'whois_privacy' => true, 'epp_code' => true, 'dns_manage' => true, 'paperwork' => false, // The inner key is a currency: an ISO code or the integer id, both resolved. // There is no per-TLD currency key; the fallback is settings['cost-currency']. 'pricing' => [ 'register' => [ 1 => ['USD' => ['cost' => 9.90, 'promo' => 7.90]], 2 => ['USD' => ['cost' => 19.00]], ], 'renewal' => [1 => ['USD' => ['cost' => 9.90]]], 'transfer' => [1 => ['USD' => ['cost' => 9.90]]], ], ], ]; ``` ### Example A registration, then the sync that keeps the local record honest. `register()` is fire and forget; `sync()` later proves the domain exists with the provider's dates. ```php public function register(): array|bool { $this->initApi(); // Unicode in, punycode out. Every provider wants ASCII. $domain = idn_to_ascii($this->options['domain'] ?? $this->service['name'], 0, INTL_IDNA_VARIANT_UTS46); $tld = (string) ($this->options['tld'] ?? ''); $year = (int) ($this->options['year'] ?? $this->service['period_time']) ?: 1; $whois = $this->options['whois'] ?? []; $dns = $this->options['dns'] ?? []; $params = [ 'domain' => $domain, 'year' => $year, 'dns' => array_values($dns), ]; // The paid whois-privacy add-on, if this order carried one. if ((bool) ($this->addon_params['whois-privacy'] ?? false)) $params['privacy_protection'] = true; // TLD paperwork declared in config settings['doc-fields'][$tld]. foreach ($this->config['settings']['doc-fields'][$tld] ?? [] as $docId => $doc) { if (($doc['required'] ?? false) && strlen((string) ($this->docs[$docId] ?? '')) < 1) throw new Exception('The document "' . self::get_doc_lang($doc['name']) . '" is not specified!'); $value = $this->docs[$docId] ?? ''; // A file field holds a PATH, not the bytes. if (($doc['type'] ?? '') === 'file') $value = base64_encode((string) file_get_contents($value)); $params['documents'][$docId] = $value; } // Our four contact types, mapped onto the provider's names. $map = ['registrant' => 'owner', 'administrative' => 'admin', 'technical' => 'tech', 'billing' => 'billing']; foreach ($map as $ours => $theirs) $params['contacts'][$theirs] = [ 'first_name' => $whois[$ours]['FirstName'] ?? '', 'last_name' => $whois[$ours]['LastName'] ?? '', 'company' => $whois[$ours]['Company'] ?? '', 'email' => $whois[$ours]['EMail'] ?? '', 'address1' => $whois[$ours]['AddressLine1'] ?? '', 'city' => $whois[$ours]['City'] ?? '', 'state' => $whois[$ours]['State'] ?? '', 'zip' => $whois[$ours]['ZipCode'] ?? '', 'country' => $whois[$ours]['Country'] ?? '', 'phone_cc' => $whois[$ours]['PhoneCountryCode'] ?? '', 'phone' => $whois[$ours]['Phone'] ?? '', ]; $this->api->call('domain/register', $params); return true; } ``` ```php public function sync(): array|false { $this->initApi(); $domain = idn_to_ascii($this->options['domain'] ?? $this->service['name'], 0, INTL_IDNA_VARIANT_UTS46); $details = $this->api->call('domain/info', ['domain' => $domain]); if (!$details) return false; // false means "could not ask", NOT "gone" $map = [ 'active' => 'active', 'expired' => 'expired', 'transferred-elsewhere' => 'transferred', ]; return [ 'creationtime' => DateManager::format('Y-m-d', $details['creation_date'] ?? ''), 'endtime' => DateManager::format('Y-m-d', $details['expiration_date'] ?? ''), // Anything you cannot map becomes 'unknown'. Never guess 'active': // the sync writes the due date, and a wrong guess renews nothing. 'status' => $map[strtolower((string) ($details['status'] ?? ''))] ?? 'unknown', ]; } ``` The scheduled status sync calls it, compares the answer with the local service and moves the due date. `false` and `'unknown'` both tell the core to leave the local record alone. ### Pitfalls > **An XML provider needs its own parser** > > `Utility::xdecode()` produces no `@attributes` entry and mangles repeated elements. A response whose meaning lives in attributes or a repeated list comes back silently wrong; parse XML inside the module. > **A submitted transfer is not an owned domain** > > `transfer()` returning true only means the request was accepted; the domain becomes active when `transfer_sync()` says so. > **Convert to punycode at every boundary** > > Nameservers can be internationalised too. The template runs `idn_to_ascii()` over each entry before saving them. > **A helper class next to the module is not autoloaded** > > The autoloader maps module type directories, not the files inside them; `include_once` the client from `initApi()`. A fatal from a missing class inside a hook is swallowed and takes the rest of that hook with it. > **Log every call, and test the expensive ones last** > > Wire the client's logger to `save_log()` on day one, then work in cost order: connection test, availability check, read-only screens, and only then a real registration. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway) - [Writing a Server Module](https://dev.wisecp.com/en/writing-a-server-module) - [Error Handling](https://dev.wisecp.com/en/error-handling) ## Writing a Currency Module https://dev.wisecp.com/en/writing-a-currency-module A Currency module is a rate source. It answers one question: what is one unit of my currency worth in these others? The scheduled sync writes the answer onto the currency table. ### Overview Currency is the smallest module type, and the only one with **no base class**. The contract is duck typed: the core resolves your class, checks for a method by name, and calls it. Seven modules ship, including the free default `OpenRates` and the platform's own `WAtlas`. Without a base class the contract is only visible at the call sites. There are three, and they agree on one required method. ```php // 1. coremio/helpers/Money.php: the only rate path, used by the sync task $instance = self::currency_module(); // configured module, else OpenRates if (!$instance) return "Currency module not available."; $rates = $instance->exchange_rates($from, $to); if (!$rates) return $instance->error; // the module's own message reaches the operator // 2. coremio/operations/AdminFinancialCurrencies.php: settings save, then the test button $instance = Modules::getInstance("Currency", $module); if ($instance && method_exists($instance, "save_config")) $instance->save_config($module_data[$module]); // ... if (!method_exists($instance, "exchange_rates")) throw new Exception("Module does not implement exchange_rates()."); $instance->config = array_replace_recursive($instance->config, $module_data[$module]); // unsaved form values $rates = $instance->exchange_rates($localCode, $targets); // 3. coremio/api/Resources/Admin/Concerns/FinancialCurrencies.php: the same two, over the API ``` `exchange_rates()` is required, and `save_config()` as soon as your module has a setting. `page_settings()` draws it. `$config` and `$error` are read directly by the caller, so they must exist and be public. ### Prerequisites - A rate provider that returns a base currency and a set of quotes. Watch its free tier: the sync runs on a schedule. - The system's own local currency must be configured; every rate is expressed against it. - Instances come from `Modules::getInstance('Currency', 'Acme')`. - Read `coremio/modules/Currency/OpenRates` first: under a hundred lines, no credentials, the whole contract in one file. `ExchangeRateAPI` adds an API key and a settings field. ### Structure ```text Acme.php namespace WISECP\Modules\Currency; class Acme (no extends) config.php a FLAT array, not the meta/settings split other module types use lang/en.php lang/tr.php ``` > **The config file is flat here** > > Server, Payment and Registrar modules return `['meta' => [...], 'settings' => [...]]`. A Currency module returns the settings themselves and reads them as `$this->config['api_key']`. The nested shape gives you a key that is always empty. The class extends nothing, so it declares its own state. Those four properties are the interface the callers touch. ### Walkthrough #### Declare the Class and Its State ```php config = \Modules::Config("Currency", $this->name); $this->lang = \Modules::Lang("Currency", $this->name); } } ``` #### Fetch the Rates Ask the provider for everything it has against `$from` and return an uppercase code map. Do not filter down to `$to`. #### Draw the Setting and Persist It `page_settings()` returns raw markup for the currency settings screen. Name every input `module_data[{ModuleName}][{key}]`: that is the array the save operation hands to your `save_config()`. The test button posts the same array and overlays it on `$this->config` in memory. A key can be tested before it is saved. #### Verify Before You Trust It 1. Select the module in the currency settings screen and click the test button. It calls `exchange_rates()` against the local currency with two targets. 2. Run the sync, then compare `currencies.rate` for one currency against the provider's own site. 3. Convert something. An inverted rate produces prices that look plausible and are wrong. ### Reference #### The Contract ```php // REQUIRED public function exchange_rates(string $from = '', array $to = []): array|false; // REQUIRED once the module has any setting public function save_config(array $data = []): bool; // OPTIONAL: markup for the currency settings screen public function page_settings(): string; // Public state the callers read directly public string $name; public ?array $config; // overwritten in memory by the test button public ?array $lang; public ?string $error; // surfaced to the operator when exchange_rates() returns false ``` - **$from**: The uppercase ISO code of the system's local currency, resolved from `general/currency`. Lowercase it if your provider wants that. - **$to**: Advisory, and **its shape differs by caller**. The sync passes `[currency_id => 'CODE']`; the test button passes a plain list of codes. Never use it as the keys of your result. - **return value**: `['USD' => 32.15, 'EUR' => 35.02]`, uppercase codes, float values, or `false`. An empty array counts as failure at the test site. - **rate direction**: One unit of `$from` expressed in the target. The stored column is used as `amount / rate_from * rate_to`, so inverting it distorts every price. - **Money::currency_module()**: Resolves the configured module and falls back to `OpenRates` when it is missing or uninstalled. Only a failure of OpenRates returns null. - **Money::get_exchange_rates()**: The single path to a provider. On success an array. On failure it hands back your `$error` verbatim. The caller receives a **string** (or null when you set none) and never `false`, which is why every caller tests with `is_array()`. #### The Daily Call Cap Before reaching your module the helper counts calls for the day in `coremio/storage/currency-overload-limit.php`. At 48 calls it returns the string `"Currency API exceeds over limit."` without touching the provider, which protects a free tier from a misconfigured schedule. #### What the Sync Does With Your Answer ```php $rates = Money::get_exchange_rates($localCode, $targets); if (!is_array($rates)) return ['success' => false, 'result' => ['error' => is_string($rates) ? $rates : 'invalid-response']]; // Extension point: inject a currency the provider cannot quote, or override one. Hook::runRefs('filter:money.exchange_rates_fetch', $rates, $localCode, $targets); // Uppercase code => currency id, so a provider that answered with everything // still updates only the currencies this installation actually has. $codeToId = []; foreach ($targets as $cid => $code) $codeToId[strtoupper($code)] = (int) $cid; foreach ($rates as $code => $rate) { $codeUp = strtoupper((string) $code); $rate = (float) $rate; if (!isset($codeToId[$codeUp])) continue; // not a currency here: ignored if ($rate <= 0 || $rate > 999999999999.99999999) { $skipped++; continue; } // sanity guard WDB::update('currencies', ['rate' => $rate])->where('id', '=', $codeToId[$codeUp])->save(); } Hook::run('action:money.exchange_rates_updated', $changes, $localCode); ``` - **filter:money.exchange_rates_fetch**: Runs on the fetched map before anything is written. Inject a currency the provider cannot quote here. - **action:money.exchange_rates_updated**: Runs after persistence with only the rates that actually changed. - **filter:money.exchange_rate**: Runs inside every conversion, not at sync time. Reserve it for policy such as a margin, never for fetching. Both surfaces have API twins: `POST /financial/currency-modules/test` for the live provider call, `POST /financial/currencies/sync` to dispatch the task inline. ### Example A complete module with one credential. The field name connects the three: what `page_settings()` prints is what `save_config()` receives is what `exchange_rates()` reads. ```php public function exchange_rates(string $from = '', array $to = []): array|false { $apiKey = (string) ($this->config["api_key"] ?? ''); if ($apiKey === '') { $this->error = "Acme api_key is not configured."; return false; } $url = "https://api.example.com/v1/" . urlencode($apiKey) . "/latest/" . urlencode(strtoupper($from)); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 15); $response = curl_exec($ch); if ($response === false) $this->error = curl_error($ch) ?: "Currency provider request failed."; curl_close($ch); // Log BEFORE the early returns: an operator debugging a bad key needs to // see the request that produced the failure, not only the failure. \Modules::save_log("Currency", $this->name, "exchange", [ 'api_url' => $url, 'from' => $from, 'to' => $to, ], $response, $this->error); if ($this->error) return false; $decoded = \Utility::jdecode(trim((string) $response), true); if (!is_array($decoded) || ($decoded["result"] ?? '') !== 'success') { $this->error = (string) ($decoded["error-type"] ?? 'Acme returned an error.'); return false; } // Uppercase keys, float values. $to is NOT used as a filter: the sync // keeps what it recognises and ignores the rest. $result = []; foreach ((array) ($decoded["conversion_rates"] ?? []) as $code => $rate) $result[strtoupper((string) $code)] = (float) $rate; return $result; } ``` ```php public function page_settings(): string { $apiKey = htmlspecialchars((string) ($this->config["api_key"] ?? ''), ENT_QUOTES); // The input NAME is the contract with the save operation: // module_data[Acme][api_key] arrives as $data['api_key'] in save_config(). return '
    ' . '' . '
    ' . '' . '
    '; } public function save_config(array $data = []): bool { // Merge, do not replace: a partial post must not wipe the other keys. $merged = array_replace_recursive($this->config ?: [], $data); // file_write() invalidates the opcode cache for a .php target, which is why // the saved value is readable on the very next request. return (bool) \FileManager::file_write( __DIR__ . DS . "config.php", \Utility::array_export($merged, ['pwith' => true]) ); } ``` ```php '', 'help-link' => 'https://example.com', ]; ``` ### Pitfalls > **Do not filter your answer down to $to** > > Its keys are currency ids from the sync and plain integers from the test button. Filtering on it gives the two callers different results. Return everything and let the core match by code. > **A failure must set $error and return false** > > The caller prints `$instance->error` verbatim, and an empty message becomes an operator ticket that says nothing. > **Forty-eight calls a day, counted before your code runs** > > The counter is incremented per call, not per successful call, so a test loop burns the same budget as the scheduled run. Check the cap before you suspect the provider. > **Use the leading backslash for global classes** > > The file declares `namespace WISECP\Modules\Currency`, so an unqualified `Modules::Config()` resolves inside that namespace and fatals at runtime. > **A missing module does not break pricing** > > When the configured module cannot be loaded the resolver falls back to `OpenRates`, which needs no credentials. Rates go stale rather than disappearing. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Writing a Fraud Module https://dev.wisecp.com/en/writing-a-fraud-module A Fraud module inspects an order attempt before anything is written and answers one question: may it proceed. ### Overview A fraud module extends `FraudModule` and lives in `coremio/modules/Fraud/{Name}/{Name}.php`. Two ship, one per shape: `WFraud` scores locally, `MaxMind` forwards the attempt to a service. The gate runs once, from the checkout, before an order, invoice or service exists. ```php // Nothing has been persisted at this point: a blocked attempt leaves no order, // no invoice and no service behind. if ($fraudError = \FraudModule::run_checks($this->fraud_payload($pricing, $pmethod, (float) $taxCalc['total'], $member))) throw new \Exception($fraudError); ``` Two properties of that gate shape your module: - **A throwing module counts as a pass**, logged as a warning. - **Blocking is a returned false plus a message.** No score, no review state, no queue. ### Prerequisites - Know what the payload contains; anything else you fetch yourself. - A provider account if you score remotely; nothing extra if you score locally. - The module skeleton: see [Module Anatomy](https://dev.wisecp.com/en/module-anatomy). - A test order you can place repeatedly. A rule that blocks too much shows only in the records table. ### Structure ```text Acme.php namespace WISECP\Modules\Fraud; class Acme extends FraudModule config.php ['meta' => [...], 'status' => false, 'settings' => []] lang/en.php lang/tr.php logo.png ``` > **status is a top level key, not part of meta** > > `run_checks()` skips a module unless `$row['config']['status']` is true, and the settings screen writes it there. Under `meta` it saves cleanly and is never called. You do not build the settings screen; the base assembles it. Fields are disabled while the status switch is off. ### Walkthrough #### Declare the Class ```php config['settings']`. #### Reject Bad Credentials at Save Time Declare `save_fields()` and the save runs it before persisting. Declare `activate()` and `deactivate()` if switching on has to do real work. #### Write the Rule Read the payload and decide. On a block, in this order: set `$this->error` to a translated sentence, call `insert_record()`, return false. #### Verify Both Directions 1. Place an order that should pass. It completes and the records table stays empty. 2. Place one that should be blocked. The checkout shows your message; nothing is created. 3. Break the provider with a wrong key. The order still completes and the log carries a warning naming your module. ### Reference #### What the Base Class Gives You ```php // The gate. Called by the checkout, never by a module. public static function run_checks(array $params = []): string; // '' = proceed, else the message // Records public function insert_record($user_id = 0, $message = '', $ip = ''); public function records($rCount = false, $filters = [], $orders = [], $start = 0, $end = -1); public function record_table(): WISECP\Components\Table|string; // Settings screen, assembled for you public function page_settings(): string; public function save_config($data = []): int|bool; // Admin controllers, reached as operation=module_controller&controller={name} public function controller_records(): string; public function controller_save_settings(): array; public function controller_clear_records(): array; // Public state public ?string $error; // the message the client sees when you return false public ?array $config; // ['status' => bool, 'settings' => [...], 'meta' => [...]] public ?array $lang; public ?array $user; // the logged-in member, resolved in the constructor public ?array $admin; public ?string $dir; public string $url; // note: not nullable, unlike its neighbours public ?string $area_link; ``` - **run_checks()**: Walks every module whose `config['status']` is true, in registry order, skips any without `check`, returns on the first false. An empty string means proceed. - **insert_record()**: Writes one row to `fraud_detected_records` with your module name, the user id, your reason and the IP. Omit the IP and the request IP is used. - **records()**: Reads them back, scoped to your module and joined to the customer. - **page_settings()**: Builds the settings screen: status switch, your fields, the records tab, plus the tabs `setConfigureTab()` adds. Use the callback rather than overriding it. - **$this->error**: Blocking with an empty error still blocks: the gate falls back to `website/checkout/error-fraud` with your module name. #### What You Declare ```php // REQUIRED. true = let it through, false = block (with $this->error set). public function check($params = []): bool; // OPTIONAL public function fields(): array; // settings form descriptors public function save_fields($fields = []): array; // validate before persisting public function activate(): bool; // switching the module on public function deactivate(): bool; // switching it off public function setConfigureTab(\WISECP\Components\Tab $obj): void; // extra settings tabs ``` > **save_fields returns the fields, or an error envelope** > > On success return the (possibly cleaned) field array; it becomes `config['settings']`. On failure return `['status' => 'error', 'message' => '...']` and the save throws. #### What Is Inside $params Built by the checkout immediately before the gate; it is the complete input. - **user_data**: `id`, `email`, `name`, `surname`, `full_name`, `phone`, `country`, `blacklist`, `company_name`, `identity`, `gsm_cc`, `gsm_number`, `ip`, `user_agent`, plus `address`. The mobile number is `gsm_number`, not `gsm`. - **user_data.address**: `address`, `city`, `country_code`, `zipcode`. The country code is resolved from the country id when the form sent only that. - **a guest attempt**: `user_data.id` is `0` and the identity comes from the posted form, so only `email`, `name`, `surname` and `full_name` are filled. Profile fields are absent, not empty. - **currency**: The currency *id*. Resolve it with `Money::Currency()` when the provider wants the ISO code. - **total**: Float, in that currency, tax and fees included. - **pmethod**: The payment module name, for example `PayTR`. MaxMind maps it to its own vocabulary through a `payment-gateways` table. - **items**: The cart rows, as priced. - **discounts**: `['items' => ['coupon' => [['name' => 'CODE'], ...]]]`. Nested three deep, absent when no coupon was used. #### The Two Shapes | Aspect | Local scoring | Remote scoring | | --- | --- | --- | | Reference | `WFraud` | `MaxMind` | | Rules | Blacklist, IP country against billing country, proxy or VPN | One provider score against a threshold | | Settings | A switch per rule | Credentials, service tier, risk score, provider toggles | | Credentials | None | Required, validated in `save_fields()` | | Cost per order | Zero | One billable API call | - **action:module.fraud_settings_saved**: Runs after a fraud module's settings are written, with the module name and the config. ### Example A rule with a switch, a threshold and a record. The field key is the contract: `fields()` declares it, `save_fields()` validates it, `check()` reads it. ```php public function fields(): array { $settings = $this->config['settings'] ?? []; return [ 'api-key' => [ 'wrap_width' => 100, 'type' => 'text', 'name' => $this->lang['api-key'] ?? 'API Key', 'value' => $settings['api-key'] ?? '', ], 'risk-score' => [ 'wrap_width' => 100, 'width' => 15, 'type' => 'number', 'name' => $this->lang['risk-score'] ?? 'Risk Score', 'description' => $this->lang['risk-score-desc'] ?? '', 'value' => $settings['risk-score'] ?? '20', ], 'block-country-mismatch' => [ 'wrap_width' => 100, 'type' => 'approval', // a switch: 'value' is what a ticked box submits 'name' => $this->lang['country-mismatch'] ?? 'Block country mismatch', 'value' => 1, 'checked' => $settings['block-country-mismatch'] ?? false, ], ]; } public function save_fields($fields = []): array { // Refusing here is how the module declines to be switched on half-configured. if (\Validation::isEmpty($fields['api-key'] ?? '')) return ['status' => "error", 'message' => $this->lang['error-is-empty'] ?? 'API key is required.']; return $fields; } ``` ```php public function check($params = []): bool { $settings = $this->config['settings'] ?? []; $user = (array) ($params['user_data'] ?? $this->user ?? []); $ip = (string) ($user['ip'] ?? ''); $uid = (int) ($user['id'] ?? 0); // 0 on a guest attempt // Each rule is independently switchable, so read the flag, not the value. if ((int) ($settings['block-country-mismatch'] ?? 0) === 1) { $billing = strtoupper((string) ($user['address']['country_code'] ?? '')); $origin = strtoupper((string) (UserManager::ip_info($ip)['countryCode'] ?? '')); // Only a CONFIDENT mismatch blocks. An unresolved IP or a missing // billing country would otherwise turn every thin profile into a // false positive, and a false positive here is a lost sale. if ($billing !== '' && $origin !== '' && $billing !== $origin) { // A core string exists for this one; WFraud uses the same key. $this->error = Language::gc("website/checkout/error-fraud-country"); $this->insert_record($uid, "IP country ({$origin}) does not match billing country ({$billing})", $ip); return false; } } // A remote score. Any failure here must NOT block: the gate treats a thrown // exception as a pass, and this early return does the same for a bad answer. $score = $this->remote_score($params); if ($score === null) return true; if ($score >= (int) ($settings['risk-score'] ?? 20)) { // Your own wording belongs in YOUR lang file. Language::gc() answers false // for a key the core does not have, which would leave $error empty and // fall back to the generic "did not pass our security checks" sentence. $this->error = $this->lang['error-risk-score'] ?? 'Your order could not be approved automatically.'; $this->insert_record($uid, "Risk score {$score} reached the configured threshold", $ip); return false; } return true; } ``` ```php // coremio/classes/FraudModule.php, run_checks() foreach ($modules as $name => $row) { if (!(bool) ($row['config']['status'] ?? false)) continue; // inactive: skipped try { $module = Modules::getInstance("Fraud", (string) $name); if (!$module || !method_exists($module, 'check')) continue; if ($module->check($params) !== false) continue; // passed: next module $message = trim((string) ($module->error ?? '')); return $message !== '' ? $message : Language::gc("website/checkout/error-fraud", ['{module}' => (string) $name]); } catch (\Throwable $e) { // A provider being down must not take checkout down with it. Logger::warning("Fraud module '{$name}' check failed: " . $e->getMessage()); } } return ''; // every active module passed ``` The returned string becomes the exception the checkout throws, so `$this->error` is what the customer reads. Keep detail in the record. ### Pitfalls > **A false positive is a refused customer** > > Require positive evidence: block on a confirmed mismatch, never on an absent value. A bug here shows up as orders never placed. > **Never fail closed on a provider error** > > A timeout is not evidence of fraud. Let the exception escape, or return true; the gate logs a warning and the order proceeds. > **Import your global classes** > > Fraud modules are namespaced, so a bare `UserManager::ip_info()` resolves to `WISECP\Modules\Fraud\UserManager` and fatals. Import global classes, or prefix with a backslash. > **Handle the guest attempt** > > A rule keyed on `user_data.id` crashes, or silently never fires, when the attempt is a guest. > **Record every block, with the reason** > > The records tab is the operator's only window into the gate. A block with no record is an order that vanished. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway) - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms) - [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) # Module Development / Integration Modules ## Writing a Mail Module https://dev.wisecp.com/en/writing-a-mail-module A Mail module is the driver that delivers e-mail: it implements the methods the notification helper calls on every message. ### Overview There is no `MailModule` base class: the type is duck typed. Core loads the class named after the module directory and calls a fixed set of methods on it. Exactly one Mail module is active, named in `modules/mail`. The module writes that key when the operator ticks enable. Start from SampleMail: it implements the whole contract and writes messages to disk. ### Prerequisites - Write access to `coremio/modules/Mail/`. - A transport reachable from the server: SMTP, or an HTTP API with a key. - The settings form: [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder). - Background: [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work). ### Structure The directory name, the file name and the class name are the same string. That is the whole registration mechanism. ```bash coremio/modules/Mail/Acme/ ├── Acme.php the driver class, named Acme, no namespace ├── config.php returns an array: meta + the saved settings ├── lang/en.php returns a flat key => string array ├── lang/tr.php ├── logo.png optional, referenced from config meta.logo └── pages/ optional, settings.php as an alternative to page_settings() ``` The class carries no namespace. The first line is the direct access guard. ### Walkthrough #### Building the Driver Class 1. Create `coremio/modules/Mail/Acme/Acme.php`, open it with the access guard and declare `class Acme`. 2. In the constructor, load the module's settings and strings, then merge the optional override array. 3. Implement `subject`, `body`, `AddAddress` and `addAttachment` so each returns `$this`. 4. Implement `getSubject`, `getBody` and `getAddresses` for the delivery log. 5. Implement `submit()`: truthy on success, or set `$this->error` and return false. #### The Settings Page 1. Add `page_settings()` returning the form. Core prefers it over `pages/settings.php`. 2. Post three hidden fields: the operation, the controller name and the module name. 3. Add the enable checkbox, ticked when the stored active driver equals your class. 4. Add `controller_save()`: write changed fields into `config.php`, then flip `modules/mail`. 5. Optionally add `controller_test_connection()` and a button that reposts the form with that controller. #### Activating and Verifying 1. Open `{admin}/modules/mail`, pick your module, fill in credentials and tick enable. 2. Saving writes the class name into `modules/mail`, which disables the previous driver. 3. Trigger a notification. During development use SampleMail and read the captured file. ### Reference #### What Core Calls, and From Where These are the only methods the helper uses. | Method | When core calls it | Must return | | --- | --- | --- | | `__construct($external_config = [])` | Once per message | nothing | | `body($text, $template, $variables, $lang, $user)` | First | `$this` | | `subject($arg)` | After body, if the caller overrides it | `$this` | | `addAttachment($path, $name)` | Once per attachment | `$this` | | `AddAddress($address, $name)` | Last, once per recipient | `$this` | | `submit($isthis = false)` | After the recipient is added | truthy on success, false on failure | | `getSubject()` | After a successful submit (log row) | string | | `getBody()` | After a successful submit (log row) | string | | `getAddresses()` | After a successful submit (log row) | flat array of addresses | | `$error` (public property) | After a falsy submit | the failure text | Two methods are optional: `set_credentials(array $data)` overrides the saved credentials, and `setFromEmail()` plus `setFromName()` override the sender. #### Driver Method Signatures Copy these signatures literally: core calls some of them with fewer arguments than they accept. ```php public function __construct($external_config = []); // $text the raw body, used as is when $template is false // $template "group/name", e.g. "invoice/invoice-created"; false skips rendering // $variables the placeholder map handed to the template // $lang the recipient's language code, not the operator's // $user the recipient's user id, or 0 for an address with no account public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0); // Resets the recipient list before setting the subject. That is deliberate, see Pitfalls. public function subject($arg = ''); // $arg1 is either an address string, or a map of address => name. public function AddAddress($arg1 = '', $arg2 = ''); public function addAttachment($path = '', $name = ''); public function setFromEmail($email = ''); public function setFromName($name = ''); public function set_credentials($data = []); // $isthis = true returns the driver instead of the boolean, so the call can chain. public function submit($isthis = false); public function getSubject(); public function getBody(); public function getAddresses(); public function address_reset(); ``` When `$template` is truthy, the driver builds the message itself. The call and its return shape are fixed: ```php // View::notifications($type, $template_name, $content, $variables, $lang, $user): array $look = View::notifications("mail", $template, $text, $variables, $lang, $user); // Returns ['subject' => '...', 'content' => '...'], or false when the template is missing. if ($look !== false && isset($look["subject"]) && isset($look["content"])) { $this->subject($look["subject"]); $text = $look["content"]; } ``` #### The Config File and Its Keys `config.php` returns a plain array. `meta` is read by the module list; other keys are yours. - **meta.name**: Display name in the module list. The language file's `name` key wins. - **meta.version**: Version string shown beside the module. Free form. - **meta.logo**: Logo file name in the module directory. Left out, the list probes `logo.svg`, `logo.webp`, `logo.png`. - **fname**: Sender display name. Every shipped driver uses this exact key. - **from**: Sender address. Mailjet calls it `femail`, which is why `setFromEmail()` exists. - **Crypt::encode()**: Secrets are stored encrypted with the install key: write with `Crypt::encode($v, Config::get("crypt/user"))`, read back with `Crypt::decode()`. Never paste a plaintext key into `config.php`. #### How a Settings Submission Reaches You The form posts `operation=module_controller`. That operation loads the module, then resolves the controller name in two steps. | Step | Looked for | Result | | --- | --- | --- | | 1 | `controllers/{controller}.php` in the module, larger than 5 bytes | included; its return becomes the response | | 2 | `controller_{controller}()` on the instance, hyphens as underscores | runs in a try/catch; its return becomes the response | | fallback | neither exists | `['status' => 'error', 'message' => 'Module controller not found']` | So `controller=test-connection` reaches `controller_test_connection()`. It may throw; the resolver catches it. Return `['status' => 'successful', 'message' => '...']`. ### Example A complete driver, then the core code that drives it. ```php lang = Modules::Lang('Mail', __CLASS__); $this->config = array_merge($config ?: [], $external_config); } public function set_credentials($data = []) { $this->credentials = $data; return $this; } public function subject($arg = '') { // Clearing here is what keeps the dispatcher's recipient loop from accumulating. $this->address_reset(); $this->subject = (string) $arg; return $this; } public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0) { if ($template) { $look = View::notifications('mail', $template, $text, $variables, $lang, $user); if ($look !== false && isset($look['subject']) && isset($look['content'])) { $this->subject($look['subject']); $text = $look['content']; } } $this->body = (string) $text; return $this; } public function setFromEmail($email = '') { $this->config['from'] = $email; return $this; } public function setFromName($name = '') { $this->config['fname'] = $name; return $this; } public function AddAddress($arg1 = '', $arg2 = '') { if (is_array($arg1)) foreach ($arg1 as $address => $name) $this->addresses[$address] = $name; else $this->addresses[$arg1] = $arg2; return $this; } public function addAttachment($path = '', $name = '') { $this->attachments[] = ['path' => $path, 'name' => $name]; return $this; } public function getAddresses() { return array_keys($this->addresses); } public function getSubject() { return $this->subject; } public function getBody() { return $this->body; } public function address_reset() { $this->addresses = []; return true; } public function submit($isthis = false) { $config = $this->credentials ?: $this->config; $key = Crypt::decode($config['api_key'] ?? '', Config::get("crypt/user")); $payload = [ 'from' => ['email' => $config['from'] ?? '', 'name' => $config['fname'] ?? ''], 'to' => array_map(fn ($a, $n) => ['email' => $a, 'name' => $n], array_keys($this->addresses), $this->addresses), 'subject' => $this->subject, 'html' => $this->body, ]; $response = Utility::HttpRequest([ 'url' => 'https://api.example.com/v1/send', 'type' => 'POST', 'data' => Utility::jencode($payload), 'header' => ['Authorization: Bearer ' . $key, 'Content-Type: application/json'], ]); $decoded = Utility::jdecode((string) $response, true) ?: []; $sent = (string) ($decoded['status'] ?? '') === 'queued'; // The dispatcher reads $this->error after a falsy return; it does not catch throws here. if (!$sent) $this->error = $decoded['message'] ?? 'Acme refused the message.'; return $isthis ? $this : $sent; } public function controller_save(): array { $from = (string) Filter::init("POST/from", "email"); $fname = (string) Filter::init("POST/fname", "hclear"); $apiKey = (string) Filter::init("POST/api_key", "password"); if (!$from) throw new Exception($this->lang['error-from-required'] ?? 'Sender address is required.'); $sets = []; if ($from !== ($this->config['from'] ?? '')) $sets['from'] = $from; if ($fname !== ($this->config['fname'] ?? '')) $sets['fname'] = $fname; // The form shows a mask for a stored key; the mask must never overwrite the real value. if ($apiKey !== '*****' && $apiKey !== Crypt::decode($this->config['api_key'] ?? '', Config::get("crypt/user"))) $sets['api_key'] = Crypt::encode($apiKey, Config::get("crypt/user")); if ($sets) { $merged = array_replace_recursive($this->config, $sets); $write = FileManager::file_write(__DIR__ . DS . "config.php", Utility::array_export($merged, ['pwith' => true])); if (!$write) throw new Exception('Failed to save settings'); } $status = (bool) (int) Filter::init("POST/status", "numbers"); $current = Config::get("modules/mail") == __CLASS__; if ($current != $status) { $modules = Config::get("modules"); $modules['mail'] = $status ? __CLASS__ : 'none'; Config::save("modules", Config::set("modules", $modules)); } return ['status' => "successful", 'message' => $this->lang['settings-save-successful'] ?? 'Saved']; } } ``` ```php // Called with no module name, Load resolves the ACTIVE driver from modules/mail. Modules::Load("Mail"); $mailModule = Config::get("modules/mail"); $mail = $mailModule && $mailModule !== 'none' ? new $mailModule() : false; // One pass per recipient. Note the order: body, subject, attachments, address, submit. foreach ($adminContacts['emails'] as $address => $nameStr) { $parse = explode("|", (string) $nameStr); $aLang = $parse[1] ?? $localLang; $sendMail = $mail->body($body, $templatePath, $variables, $aLang); if ($subject) $mail->subject($subject); if ($attachments) foreach ($attachments as $fn => $fname) $sendMail->addAttachment($fn, $fname); $sendMail = $sendMail->addAddress($address, $parse[0] ?? '')->submit(); if ($sendMail) LogManager::Mail_Log(0, $reason, $mail->getSubject(), $mail->getBody(), implode(",", $mail->getAddresses())); else $errors['mail'][$address] = $mail->error; } ``` Three call sites use this shape: the template dispatcher, the queue worker and the bulk sender. ### Pitfalls > **subject() clears the recipient list, and that is required** > > All four shipped drivers call `address_reset()` first inside `subject()`. The dispatcher reuses one instance per recipient, so without it the second recipient also receives the first address. > **submit() reports failure by returning false, not by throwing** > > The dispatch loop has no try/catch and reads `$mail->error` straight after a falsy return. A throw from `submit()` aborts the loop and drops the remaining recipients. `controller_*` methods may throw. > **Read the secret with the pass through filter** > > Any other filter strips exactly the characters that make an API key strong. The form shows a stored key as five asterisks, so a save that ignores that literal overwrites the key with the mask. > **Enabling your driver disables the previous one** > > `modules/mail` holds a single class name. SampleMail accepts everything, writes each message to `temp/sample-mail/` as an EML file, and fails for any recipient whose local part starts with `fail`. ### Related Articles - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) - [Writing an Email Template](https://dev.wisecp.com/en/writing-an-email-template) - [Writing an SMS Module](https://dev.wisecp.com/en/writing-an-sms-module) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) ## Writing an SMS Module https://dev.wisecp.com/en/writing-an-sms-module An SMS module hands a text message to a gateway. Unlike mail it does three jobs: notifications, international sending and, sometimes, price and delivery reporting. ### Overview There is no base class. The contract is duck typed like Mail: core loads the class named after the module directory and calls a known set of methods. Two config keys point at drivers: `modules/sms` for notifications, `modules/sms-intl` for international traffic. A domestic gateway passes foreign numbers to the second, so every driver keeps two buckets. A third path is module local: your `controllers/` files may build the driver with unsaved credentials. ### Prerequisites - Write access to `coremio/modules/SMS/`. - Gateway credentials and usually a registered sender ID. - Is your gateway domestic only, or international? That drives the capability flags. - [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module). ### Structure Directory, file and class name are the same string, no namespace. ```bash coremio/modules/SMS/Acme/ ├── Acme.php the driver class, named Acme, no namespace ├── config.php meta (with the international flag) + saved credentials ├── Source/class.php optional gateway client, included by the constructor ├── lang/en.php ├── lang/tr.php └── logo.png optional, referenced from config meta.logo ``` ### Walkthrough #### Building the Driver Class 1. Create `coremio/modules/SMS/Acme/Acme.php` with the guard and `class Acme`. 2. Declare the capability flags as public properties. 3. In the constructor, merge the saved config with the passed array, which wins. 4. Implement `body()`, `title()`, `AddNumber()`, each returning `$this`. 5. Implement `submit()`: send the domestic bucket, pass the international one on. 6. Implement `getTitle()`, `getBody()`, `getNumbers()`, `getError()`. #### Sender ID and Numbers 1. Seed the sender from config in the constructor. 2. In `AddNumber()`, accept three shapes: a number, a number plus country code, an array of either. 3. A value with a pipe is `countryCode|number`. 4. Route into a bucket by country code, unless the caller forbids the handoff. 5. Reset both buckets inside `body()`. #### Settings and Reports 1. Add `page_settings()` with the hidden fields, enable checkbox and credentials. 2. Add `controller_save()`: write changed values into `config.php`, flip `modules/sms`. 3. Implement `getBalance()` for a credit balance; core never calls it. 4. Implement `getReportID()` and `getReport()` for a batch id. 5. Implement `get_prices()` only for an international gateway. It feeds the country price table and the pricing cron. ### Reference #### What Core Calls The dispatcher composes the message, then walks the recipients. | Method | When core calls it | Must return | | --- | --- | --- | | `__construct($external_config = [])` | Once per dispatch | nothing | | `body($text, $template, $variables, $lang, $user)` | First; resets buckets | `$this` | | `title($arg)` | On a sender ID override | `$this` | | `AddNumber($arg, $cc)` | Per recipient, or with an array | `$this` | | `submit($isthis = false)` | After the recipients are in | truthy, or false on failure | | `getTitle()` | After a good submit | string | | `getBody()` | After a good submit | string | | `getNumbers()` | After a good submit | both buckets, one array | | `getError()` | After a falsy submit | the failure text | | `numbers_reset()` | By your `body()` | not read | | `getReportID()`, `getReport($id)`, `get_prices()` | Where offered | see below | | `getBalance()` | Never by core | your own shape | Note the casing: the dispatcher writes `addNumber()`, drivers declare `AddNumber()`. PHP resolves both. #### Method Signatures ```php public function __construct($external_config = []); // Resets BOTH recipient buckets, then renders the template when one is given. // $template is "group/name", e.g. "user/gsm-activation"; false means $text is final. public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0); // The sender ID / originator shown on the handset. public function title($arg = ''); // ORDER TRAP: the number is FIRST, the country code is SECOND. // $arg accepts "5551234567", "90|5551234567", or an array of either shape. // $cc is only consulted when $arg is not an array. public function AddNumber($arg = 0, $cc = null); // $isthis = true returns the driver instead of the boolean. public function submit($isthis = false); public function getTitle(); public function getBody(); public function getNumbers(); public function getError(); public function numbers_reset(); // Optional, feature by feature. Core probes each with method_exists() before calling. public function getReportID(); // the batch id produced by the last submit public function getReport($id = 0); // delivery report for a batch public function get_prices(); // international price list, see below // Module-local convention: core never calls this. It is reached only from your own // controllers/*.php and pages/*.php, so the argument list is yours. public function getBalance(); // remaining credit, or false with $error set ``` Two return shapes are read by name: ```php // getReport(): three named buckets, each with the raw rows and a count. // The report reader also accepts 'delivered' / 'sending' / 'failed' as aliases. return [ 'waiting' => ['data' => $waitingRows, 'count' => count($waitingRows)], 'conducted' => ['data' => $deliveredRows, 'count' => count($deliveredRows)], 'erroneous' => ['data' => $failedRows, 'count' => count($failedRows)], ]; // get_prices(): country code => currency code => cost per message. // The first usable currency wins unless config's supported-currencies names one. return [ 'TR' => ['EUR' => 0.0180], 'DE' => ['EUR' => 0.0640, 'USD' => 0.0700], 'US' => ['USD' => 0.0075], ]; ``` Costs from `get_prices()` are exchanged into the primary currency, the `sms/profit-rate` margin is added, and the country map is rewritten. #### Flags Public properties, not methods. Core reads them on the instance and in the config, so both must agree. - **$international**: True when the gateway delivers abroad. - **$prevent_transmission_to_intl**: Keeps foreign numbers domestic. Honour it in `AddNumber()` and `submit()`. - **$otp**: Marks a one time password batch, which some gateways route faster. Raise it from `body()`. - **$error**: Read directly even though `getError()` exists. - **meta.international**: The same answer inside `config.php`, used by the international driver picker. Property and meta must agree. #### Config Keys - **meta.name**: Display name; the language file's `name` key wins. - **meta.poweredby**: The gateway brand, as attribution. - **origin**: The registered sender ID; every driver seeds `title()` from it. - **supported-currencies**: Currency codes the importer prefers when `get_prices()` offers several. Empty means the first. - **Crypt::encode()**: Encrypted: `Crypt::encode($v, Config::get("crypt/user"))` on save, `Crypt::decode()` on read. #### Which Driver Runs Getting the third wrong sends with the operator's account, not the customer's. | Path | How the instance is built | Credentials used | | --- | --- | --- | | Notifications | `Modules::Load("SMS")`, then `new $smsModule()` | saved config | | International sending | `Modules::getInstance("SMS", Config::get("modules/sms-intl"))` | saved config | | Your own module pages | `new YourModule($external)` | posted values over saved config | That path is why the constructor takes `$external_config`; core never passes it. The factory's third argument is a positional argument list, not an options array. ### Example A gateway that covers the home country and hands foreign numbers on. "Home country" is the country your gateway sells in; read it from config. Strip a leading `+` before comparing, and mind the operator: `!=` treats `'90'` and `'+90'` as equal, `!==` does not. ```php lang = Modules::Lang('SMS', __CLASS__); $this->config = array_merge(Modules::Config('SMS', __CLASS__) ?: [], $external_config); $this->title = (string) ($this->config['origin'] ?? ''); } public function title($arg = '') { $this->title = (string) $arg; return $this; } public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0) { // The dispatcher reuses one instance per recipient; without this the buckets grow. $this->numbers_reset(); if ($template) { if ($template === "user/gsm-activation" && ($this->config['otp'] ?? false)) $this->otp = true; $look = View::notifications('sms', $template, $text, $variables, $lang, $user); if ($look !== false && isset($look['content'])) { if (isset($look['title'])) $this->title($look['title']); $text = $look['content']; } } $this->body = (string) $text; return $this; } public function AddNumber($arg = 0, $cc = null) { if (!is_array($arg)) $arg = $cc ? [$cc . '|' . $arg] : [$arg]; foreach ($arg as $num) { if (!str_contains((string) $num, '|')) { $this->numbers[] = Filter::numbers((string) $num); continue; } // Your gateway's own country, from its config — never a literal. $home = ltrim((string) ($this->config['home_cc'] ?? ''), '+'); [$ccPart, $numPart] = explode('|', (string) $num, 2); $ccPart = ltrim($ccPart, '+') ?: $home; $full = $ccPart . Filter::numbers($numPart); if (!$this->prevent_transmission_to_intl && $home !== '' && $ccPart !== $home) $this->numbers_intl[] = $full; else $this->numbers[] = $full; } return $this; } public function getTitle() { return $this->title; } public function getBody() { return $this->body; } public function getNumbers() { return array_merge($this->numbers, $this->numbers_intl); } public function getError() { return $this->error; } public function numbers_reset() { $this->otp = false; $this->numbers = []; $this->numbers_intl = []; return true; } public function submit($isthis = false) { if (Validation::isEmpty($this->body)) { $this->error = 'Message content can not be left blank!'; return false; } if (!$this->numbers && !$this->numbers_intl) { $this->error = 'Enter the phone number to be sent.'; return false; } $send = false; // Foreign numbers go to whichever driver holds modules/sms-intl. if (!$this->prevent_transmission_to_intl && $this->numbers_intl) { $intl = (string) Config::get("modules/sms-intl"); if ($intl !== '' && $intl !== 'none') { $peer = Modules::getInstance('SMS', $intl); if ($peer) { $send = $peer->body($this->getBody())->AddNumber($this->numbers_intl)->submit(); // No domestic numbers left, so the peer's outcome is the whole outcome. if (!$this->numbers) { $this->error = $peer->getError(); return $isthis ? $this : $send; } } } } if ($this->numbers) { $response = Utility::HttpRequest([ 'url' => 'https://api.example.com/sms/send', 'type' => 'POST', 'data' => [ 'user' => $this->config['username'] ?? '', 'pass' => Crypt::decode($this->config['password'] ?? '', Config::get("crypt/user")), 'header' => $this->title, 'message' => $this->body, 'to' => implode(',', $this->numbers), 'otp' => $this->otp ? 1 : 0, ], ]); $decoded = Utility::jdecode((string) $response, true) ?: []; $send = (string) ($decoded['code'] ?? '') === '00'; // The dispatcher reads the error after a falsy return; it does not catch throws. if (!$send) $this->error = $decoded['message'] ?? 'Acme refused the batch.'; } return $isthis ? $this : $send; } } ``` ```php Modules::Load("SMS"); $smsModule = Config::get("modules/sms"); $sms = $smsModule && $smsModule !== 'none' ? new $smsModule() : false; // $phone arrives as "number|countryCode|lang", so the pieces are unpacked by position. foreach ($adminContacts['phones'] as $phone) { $parse = explode("|", (string) $phone); $aLang = $parse[2] ?? $localLang; $sendSms = $sms->body($body, $templatePath, $variables, $aLang); if (isset($parse[1])) $sendSms->addNumber($parse[0], $parse[1]); else $sendSms->addNumber($parse[0]); $sendSms = $sendSms->submit(); if ($sendSms) LogManager::Sms_Log(0, $reason, $sms->getTitle(), $sms->getBody(), implode(",", $sms->getNumbers())); else $errors['sms'][$phone] = $sms->getError(); } ``` ### Pitfalls > **AddNumber: number first, country code second** > > The joined form is the other way round, `countryCode|number`, and a stored contact unpacks as `number|countryCode|lang`. A swap produces a plausible number that fails. > **body() resets the buckets, and it must** > > Mail resets recipients inside `subject()`; SMS resets inside `body()`. Without it, recipient two gets a batch still holding recipient one. > **The flag is checked before money moves** > > The client panel prices the batch, refuses unless the driver's `$international` property is true, then debits the balance. Claiming support only in `config.php` fails the send. > **submit() reports failure by returning false** > > The notification loop has no try/catch and reads `getError()` after a falsy return. Settings controllers may throw. > **Test with the sandbox driver** > > SampleSMS accepts everything and writes each message to `temp/sample-sms/`. ### Related Articles - [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module) - [Writing an SMS Template](https://dev.wisecp.com/en/writing-an-sms-template) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) ## Writing a Pipe Module https://dev.wisecp.com/en/writing-a-pipe-module A Pipe module reads a support mailbox and returns a normalised message array, which the ticket cron chain turns into tickets and replies. ### Overview Pipe is the ticket e-mail ingestion type. Three modules ship: Google and Microsoft over OAuth, Pop3 with host and password. There is no base class. The unit of configuration is a department, not the module. Every method that touches connection state takes a department id. In the request cycle only the settings screen and the OAuth callback touch your module. A cron chain reads: discover dispatches a fetch job per department, fetch calls `inbox()`, a third job makes the ticket. ### Prerequisites - Write access to `coremio/modules/Pipe/`. - A reachable mailbox, plus the PHP `imap` extension or an OAuth application. - Ticket departments already created. - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task). ### Structure Pipe classes are namespaced: `WISECP\Modules\Pipe`. The cron builds that name from the provider string. ```bash coremio/modules/Pipe/Acme/ ├── Acme.php namespace WISECP\Modules\Pipe; class Acme ├── config.php global provider block + one block per department id ├── lang/en.php field labels, description, setup-guide ├── lang/tr.php ├── logo.svg └── views/ ├── credentialsForm.php the per department fields, rendered into the settings tab └── providerForm.php OAuth applications only: the global client id and secret ``` The OAuth redirect router is shared; `coremio/modules/Pipe/router.php` maps each slug to a module. ### Walkthrough #### The Module Class 1. Create `coremio/modules/Pipe/Acme/Acme.php` with `namespace WISECP\Modules\Pipe;` and `class Acme`. 2. Give it a public `$name` matching the directory. 3. Implement `save_config(array $data)` as a recursive merge. 4. Implement `is_connected(int $did)`; false means skipped. 5. Implement `inbox(int $did)`. 6. Implement `test_connection(int $did)`. #### The Department Form 1. Create `views/credentialsForm.php`. It receives `$mv` (your instance under `init`), `$mk` and `$did`. 2. Name fields `module[{moduleKey}][{did}][{field}]`. 3. Read current values from `$mv["init"]->config[$did]` and labels from `$mv["lang"]`. 4. A longer walkthrough goes in the `setup-guide` key. #### Adding OAuth 1. Declare `is_configured()`. Its existence is the switch for the provider card. 2. Add `get_global_config()`, `save_provider_config()`, `views/providerForm.php`. 3. Build the redirect URI from the shared route key: `LinkGenerator::client('pipe-oauth-callback', [strtolower($this->name)])`. 4. Add `oAuth2(int $did)`, returning the authorisation URL, plus `callback_handle()`. 5. Register your slug in the router. 6. Add `clear_tokens(int $did)`. ### Reference #### Method Contract Each is probed with `method_exists` before it is called. | Method | Called by | If it is missing | | --- | --- | --- | | `inbox(int $did): array` | fetch cron | the job fails, reason `inbox-missing` | | `is_connected(int $did): bool` | discover cron | dispatched regardless | | `save_config(array $data)` | settings save | fields are not stored | | `test_connection($did): array` | settings screen | test button unavailable | | `is_configured(): bool` | settings screen | no provider card | | `get_global_config(): array` | settings screen | empty provider card | | `save_provider_config($id, $secret): bool` | provider save | saving throws | | `oAuth2($did): array` | settings screen | connect button unavailable | | `callback_handle(): array` | shared OAuth route | no handler | | `clear_tokens($did)` | settings screen | cannot disconnect | | `get_connected_email($did): string` | credentials form | mailbox not shown | Only `oAuth2`, `test_connection` and `clear_tokens` are reachable from the panel. #### Signatures ```php public function __construct(); // Recursive merge, then rewrite config.php. A plain replace erases other departments. public function save_config($data = []); // Readiness for ONE department. Tokens present, or hostname+username+password filled. public function is_connected(int $did): bool; // The one method the pipeline cannot do without. Return shape below. public function inbox(int $did): array; // ['status' => 'successful'] or ['status' => 'error', 'message' => '...']. public function test_connection($did = 0): array; // OAuth modules only. public function is_configured(): bool; public function get_global_config(): array; // client_id, client_secret, redirect_uri public function save_provider_config(string $client_id, string $client_secret_plain): bool; public function oAuth2($did = 0): array; // ['status' => 'successful', 'redirect' => $url] public function callback_handle(): array; // ['status' => 'connected', 'did' => 4, 'email' => '...'] public function clear_tokens($did = 0); public function get_connected_email(int $did): string; ``` The message array is the real contract. Every key is read by name, and a missing one degrades silently. ```php return [ 'status' => 'successful', // anything else is treated as a soft error 'data' => [ [ 'ip' => '203.0.113.9', // sender IP if the headers carry one, else '' 'date' => '2026-08-03 09:41:00', // Y-m-d H:i:s, used in the duplicate hash 'subject' => 'Cannot reach my panel', // becomes the ticket subject 'spam' => false, // true makes the handler drop the message 'msgid' => '', // optional; a stable hash is synthesised when empty 'from' => ['name' => 'Ada L.', 'address' => 'ada@example.com'], 'to' => ['name' => 'Support', 'address' => 'support@example.com'], 'message' => '

    The panel times out.

    ', 'attachments' => [ [ 'file_name' => 'screenshot.png', // the name the sender used 'name' => 'a1b2c3d4e5f6.png', // the randomised stored name 'file_ext' => 'png', 'size' => 20481, 'content' => 'iVBORw0KGgoAAAANS', // base64 of the raw bytes ], ], ], ], ]; // On failure, either throw or return the soft error form: return ['status' => 'error', 'message' => 'Cannot connect to server', 'data' => []]; ``` A throw sets the breaker, notifies and records the job failed; a returned `error` marks it cancelled. Return for configuration, throw for transport. #### Config `config.php` mixes string keys for module wide settings with integer keys for department state. That is why `save_config()` must merge. ```php 3, // module wide 'provider' => [ // module wide, OAuth applications only 'client_id' => '...', 'client_secret' => '...', // Crypt::encode with crypt/system ], 4 => [ // department 4 'tokens' => '...', // Crypt::encode of the token JSON 'email' => 'support@example.com', ], 7 => [ // department 7, credential based 'protocol' => 'imap', 'hostname' => 'mail.example.com', 'port' => 993, 'username' => 'support@example.com', 'password' => '...', 'ssl' => true, ], ]; ``` The mailbox to department mapping lives in the shared options file: - **options/ticket-pipe/status**: The master switch; off, discover cancels with `pipe-disabled`. - **options/ticket-pipe/mail**: Department id to a row with `provider`, `from`, `fname`. An incomplete row is dropped. - **options/ticket-pipe/prefix**: The reference tag in outgoing subjects, matched on the way back. Defaults to `REF`. - **options/ticket-pipe/existing-client**: Reject or create when the sender matches no account. - **options/ticket-pipe/spam-control**: When on, spam checks run first. - **Config::save()**: Nested: write the whole subtree with `Config::save("options", Config::set("options", ['ticket-pipe' => $ticketPipe]))`. #### The Cron Chain Three queue handlers: | Handler | What it does | Touches your module | | --- | --- | --- | | discover | Walks the departments, dispatches one fetch job each | `is_connected()` and a class check | | fetch | Collects messages, dispatches one message job each | `inbox()` | | message | Turns a message into a ticket or reply | nothing; it reads your array | Four conditions make discover skip a department without an error: no class, `is_connected()` false, a fetch in flight, or cooldown. Cooldown lasts three hours; a clean fetch clears it. ### Example A credential based module, then the cron that calls it. ```php config = \Modules::Config("Pipe", $this->name); $this->lang = \Modules::Lang("Pipe", $this->name); } // Recursive merge: the settings screen posts one department at a time. public function save_config($data = []) { $merged = array_replace_recursive($this->config ?: [], $data); $this->config = $merged; return \FileManager::file_write(__DIR__ . DS . "config.php", \Utility::array_export($merged, ['pwith' => true])); } public function is_connected(int $did): bool { $cfg = $this->config[$did] ?? null; if (!is_array($cfg)) return false; return !empty($cfg['hostname']) && !empty($cfg['username']) && !empty($cfg['password']); } public function test_connection($did = 0): array { try { // The form posts module[Acme][{did}][field], so that is the path to read. $host = trim((string) \Filter::init("POST/module/" . $this->name . "/" . $did . "/hostname")); $user = trim((string) \Filter::init("POST/module/" . $this->name . "/" . $did . "/username")); $pass = (string) \Filter::init("POST/module/" . $this->name . "/" . $did . "/password", "password"); if ($host === '' || $user === '' || $pass === '') throw new \Exception($this->lang["credentials-required"] ?? 'Hostname, username and password are required.'); $this->open($host, $user, $pass); } catch (\Exception $e) { return ['status' => "error", 'message' => $e->getMessage()]; } return ['status' => "successful"]; } public function inbox(int $did): array { $cfg = $this->config[$did] ?? null; // A configuration problem is a soft error: the job is cancelled, not failed. if (!$cfg) return ['status' => 'error', 'message' => 'Department config not found', 'data' => []]; // A transport problem throws: the breaker opens and the job is recorded as failed. $session = $this->open($cfg['hostname'] ?? '', $cfg['username'] ?? '', $cfg['password'] ?? ''); $lookback = max(1, (int) ($this->config['lookback_days'] ?? 3)); $since = date('Y-m-d', strtotime('-' . $lookback . ' days')); $messages = []; foreach ($this->unread($session, $since) as $raw) { $messages[] = [ 'ip' => (string) ($raw['sender_ip'] ?? ''), 'date' => \DateManager::format("Y-m-d H:i:s", $raw['date'] ?? ''), 'subject' => (string) ($raw['subject'] ?? ''), 'spam' => false, 'from' => ['name' => (string) ($raw['from_name'] ?? ''), 'address' => (string) ($raw['from'] ?? '')], 'to' => ['name' => (string) ($raw['to_name'] ?? ''), 'address' => (string) ($raw['to'] ?? '')], 'message' => (string) ($raw['html'] ?? ($raw['text'] ?? '')), 'attachments' => $this->attachments($raw['parts'] ?? []), ]; } return ['status' => "successful", 'data' => $messages]; } } ``` ```php // getInstance, not new: it runs Modules::Load first and fills the module config cache. // With a bare new, the constructor reads null from Modules::Config and inbox() bails // out with "Department config not found". $module = \Modules::getInstance("Pipe", $provider); if (!$module) return ['success' => false, 'result' => ['reason' => 'module-missing']]; if (!method_exists($module, 'inbox')) return ['success' => false, 'result' => ['reason' => 'inbox-missing']]; try { $response = $module->inbox($did); } catch (\Throwable $e) { self::set_cooldown($did, $e->getMessage()); Admin::notify('ticket-pipe-failure', self::failure_payload($did, $provider, $depName, $depFrom, $e->getMessage()), 'error', [ 'dedupe_keys' => ['did', 'provider'], 'recurring' => true, ]); throw $e; } $status = (string) ($response['status'] ?? ''); $data = (array) ($response['data'] ?? []); if ($status !== 'successful') { self::set_cooldown($did, (string) ($response['message'] ?? 'Unknown module error')); // ... notify, then return a cancelled job } // Clean run: drop the pending failure notice and release the breaker. Admin::notify_resolve('ticket-pipe-failure', ['did' => $did, 'provider' => $provider]); self::clear_cooldown($did); ``` Modules do not return a message id, so the fetch handler hashes department, sender, date and subject. A real `msgid` makes deduplication exact. ### Pitfalls > **A flat key never reaches the cron** > > Everything lives under `options/ticket-pipe`. A similarly named top level key saves and reads back fine while the cron keeps the old value. > **save_config must merge, not replace** > > The settings post carries only the edited department. A `save_config()` that assigns instead of merging wipes the other departments' credentials, and the only symptom is that those mailboxes stop being polled. > **Build the instance with the factory** > > `Modules::getInstance()` runs the loader first, filling the config cache your constructor reads. A bare `new` leaves the config null and fails later inside `inbox()`. > **A failure sleeps the department for three hours** > > The circuit breaker keeps a broken mailbox from notifying every minute. While testing, your fix appears dead: the department is skipped with `cooldown`. Re run the fetch job directly. > **Tokens go through the system key** > > OAuth tokens and client secrets use `Crypt::encode($value, Config::get("crypt/system"))`: the system key, not the user one. Store the token JSON encoded. ### Related Articles - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) - [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module) - [Writing a Social Login Provider](https://dev.wisecp.com/en/writing-a-social-login-provider) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Reading and Writing Configuration](https://dev.wisecp.com/en/reading-and-writing-configuration) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) ## Writing an Authentication Module https://dev.wisecp.com/en/writing-an-authentication-module An Authentication module is a second factor method. It implements the methods the login core calls to enrol, challenge and verify a user. ### Overview Three modules ship: Email, Sms and Totp. There is no base class. The core resolves a class from the method name and probes each method with `method_exists`. There are two families. Does enrolment produce a secret shown to the user? If yes, implement `setup()`. If no, implement `verification()` to deliver a code. Admin and client login run through the same core, so one module serves both. ### Prerequisites - Write access to `coremio/modules/Authentication/`. - A delivery channel or a shared secret scheme. - A working mail or SMS driver, for a code method. - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work). ### Structure Three files are enough. There is no settings page and no controller: the operator only switches the method on. ```bash coremio/modules/Authentication/Acme/ ├── Acme.php namespace WISECP\modules\Authentication; class Acme ├── config.php meta + settings (setup, method, attempts, attempts_penalty_minute) └── lang/ ├── en.php name + description, shown on the method card └── tr.php ``` The class is namespaced. The shipped modules write `namespace WISECP\modules\Authentication;`, while the core builds `WISECP\Modules\Authentication\{Method}`. Both resolve. ### Walkthrough #### Building the Module Class 1. Create `coremio/modules/Authentication/Acme/Acme.php` with a class named after the directory. 2. Load `config.php` in the constructor. It takes no arguments. 3. Prefix global classes with a backslash: `\Session`, `\Filter`, `\Notification`. 4. Implement your family's set: `setup()` plus `install()`, or `verification()`. 5. Implement `verify()` in both cases. #### Enrolment 1. For a setup method, `setup(array $user_data)` returns the secret and everything the wizard shows. It receives the plain user array. 2. The core generates setup data once and stashes it in the session, so the wizard and the enable step share a copy. 3. `install(array $data)` checks the typed code against the stashed data. An error status aborts enrolment. 4. What `setup()` returns is stored encrypted under the preference's `data` key and handed back later. 5. Implement `uninstall(array $data)` for the disable path. #### The Login Challenge 1. `verification(array $params)` runs when the challenge screen appears. Generate the code, deliver it, stash it, return the screen payload. 2. Rate limit delivery yourself and report the remaining seconds through `retry_delay`. 3. `verify(array $params, $code)` receives the submitted code. Return the successful or error status; the core applies the lockout. 4. For a recovery key, add `verify_recovery(array $params, $key)` and set `recovery` to true in the payload. 5. Test both screens: they keep separate sessions and counters. ### Reference #### The Method Contract Every call is guarded, so each method is optional. The combination is not: without `setup()` or `verification()` a method cannot be enrolled. | Method | Called when | Family | | --- | --- | --- | | `setup(array $user_data): array` | Account panel, no method active | secret based | | `install(array $data = []): array` | Enrolment code submitted | secret based | | `uninstall(array $data = []): array` | Method disabled | both | | `verification(array $params = []): array` | Challenge screen, or before a sensitive change | code based | | `verify(array $params = [], $code = ''): array` | Code submitted (login or step up) | both | | `verify_recovery(array $params = [], $key = ''): array` | Recovery key submitted | optional | The presence of `setup()` is a family marker read in four places. It decides whether the panel shows a wizard and whether a code is delivered before a sensitive change. The config key `settings.setup` says the same and is read first. #### Signatures and Payload Shapes ```php public function __construct(); // SHAPE TRAP: $user_data is the plain user row (id, email, full_name), // NOT the ['user' => ..., 'data' => ...] wrapper the verify methods receive. public function setup(array $user_data): array; // $data is exactly what setup() returned, replayed from the session stash. public function install(array $data = []): array; // $data is the stored preference data, i.e. what setup() returned. public function uninstall(array $data = []): array; // ORDER: params first, the submitted value second. // $params = ['user' => ['id' => 5, 'email' => '...'], 'data' => [ /* what setup() returned */ ]] public function verification(array $params = []): array; public function verify(array $params = [], string|int|null $code = ''): array; public function verify_recovery(array $params = [], string|int|null $key = ''): array; ``` ```php // setup(): everything the enrolment wizard renders, plus the secret to persist. // The _preview keys exist so the screen can group the characters without // the template having to know the format. return [ 'secret_key' => 'JBSWY3DPEHPK3PXP', 'recovery_key' => 'K7Q2M9XR4TZB6WVA', 'secret_key_preview' => 'JBSW Y3DP EHPK 3PXP', 'recovery_key_preview' => 'K7Q2 M9XR 4TZB 6WVA', 'qr_code' => 'data:image/png;base64,iVBORw0KG', // a data URI, not a path 'content' => null, // extra HTML for the wizard, or null ]; // verification(): how the challenge screen should behave. return [ 'digit' => 6, // how many input boxes to draw 'retry_delay' => 118, // seconds until a resend is allowed; 0 means immediately 'recovery' => true, // offer the recovery key field (omit or false to hide it) 'content' => null, // extra HTML above the input, or null ]; // A delivery failure is reported through 'error', which the core surfaces verbatim. return ['error' => 'Email could not be sent']; // install(), uninstall(), verify(), verify_recovery(): a status, optionally a message. return ['status' => 'successful']; return ['status' => 'error', 'message' => 'That code did not match.']; ``` Only the literal string `successful` counts as success. Anything else, including a non array return, consumes an attempt. #### Config Keys and the Registry `config.php` carries the family marker and the lockout policy. The core reads that block through the module metadata. - **settings.setup**: True for a secret based method, false for a delivered code. Read first, `setup()` is the fallback. - **settings.method**: A lower case channel tag (`email`, `sms`, `totp`) picking the card's wording and icon. - **settings.attempts**: Wrong codes tolerated before the account is blocked. Zero disables counting. - **settings.attempts_penalty_minute**: Block length in minutes. Defaults to five. - **modules/authentications**: The operator's list of switched on method names (plural). A method absent from it is never offered. - **Modules::Load()**: Returns **metadata**, not an instance: `['lang' => [...], 'config' => [...]]`. The instance is built with `new`. #### Where the Enrolment Is Stored One method per user, held as an encrypted blob on the user's information record. You receive the decoded `data` half. ```php // Written by enableTwoFactor, after install() approved the code. $store = ['method' => $method]; if (method_exists($module, 'setup')) $store['data'] = $setupData; User::setInfo($userId, ['authentication' => Crypt::encode(Utility::jencode($store), Config::get('crypt/user'))]); // Read back on every challenge. A method the operator has since switched off // returns false here, so the login proceeds without a second factor. $raw = User::getInfo($userId, ['authentication'])['authentication'] ?? ''; $pref = Utility::jdecode(Crypt::decode($raw, Config::get('crypt/user')), true); // And this is the wrapper your verify() receives. $params = ['user' => $userRow, 'data' => $pref['data'] ?? []]; ``` Two hooks fire around this. `gate:user.two_factor_disable` vetoes a disable by returning a message; `action:user.two_factor_changed` is notified of every enable and disable. ### Example A code based method that delivers through the notification system. ```php config = include __DIR__ . DS . 'config.php'; } public function verification(array $params = []): array { $userId = (int) ($params['user']['id'] ?? 0); $stash = $this->stash(); $remaining = self::DELAY; $mayResend = true; // Blocked users must not be able to burn deliveries while they wait out the penalty. if (\User::CheckBlocked("member-login-authentication-attempt", $userId)) $mayResend = false; if ($stash) { $remaining = (int) $stash['expire'] - time(); if ($remaining > 0) $mayResend = false; } if ($mayResend) { $expire = \DateManager::next_date(['second' => self::DELAY]); $code = random_int(100000, 999999); $sent = \Notification::dispatch('user', 'two-factor-verification', [ 'user_id' => $userId, 'code' => $code, '_sync' => true, ]); // 'error' is the delivery failure channel; the core prints this message as is. if (!$sent) return ['error' => 'Verification code could not be delivered.']; $this->stash(['code' => $code, 'expire' => \DateManager::strtotime($expire)]); $remaining = self::DELAY; } return [ 'digit' => (int) ($this->config['settings']['digits'] ?? 6), 'retry_delay' => max(0, $remaining), 'content' => null, ]; } public function verify(array $params = [], string|int|null $code = ''): array { $stash = $this->stash(); if (empty($code) || !$stash || (string) $stash['code'] !== (string) $code) return ['status' => 'error']; // Single use: clear it so a replay of the same code cannot pass. \Session::delete('AcmeAuthData'); return ['status' => 'successful']; } public function uninstall(array $data = []): array { \Session::delete('AcmeAuthData'); return ['status' => 'successful']; } private function stash(?array $write = null): array { if ($write !== null) { \Session::set('AcmeAuthData', \Utility::jencode($write), true); return $write; } $raw = \Utility::jdecode((string) \Session::get('AcmeAuthData', true), true); if (!$raw) return []; // Expired stash is no stash, otherwise a stale code stays valid forever. if ((int) ($raw['expire'] ?? 0) < time()) { \Session::delete('AcmeAuthData'); return []; } return $raw; } } ``` ```php // The instance: Load first, then a bare new on the resolved class name. $class = 'WISECP\\Modules\\Authentication\\' . $method; $module = Modules::Load('Authentication', $method) && class_exists($class) ? new $class() : false; $params = ['user' => $state['user'] ?? [], 'data' => $state['authentication']['data'] ?? []]; $recoveryKey = trim(str_replace(' ', '', $recoveryKey)); if ($recoveryKey !== '') $verify = method_exists($module, 'verify_recovery') ? $module->verify_recovery($params, $recoveryKey) : ['status' => 'error']; else $verify = method_exists($module, 'verify') ? $module->verify($params, $code) : ['status' => 'error']; if (!is_array($verify) || ($verify['status'] ?? 'error') !== 'successful') { // Load returns METADATA here, which is where the attempt policy comes from. $meta = Modules::Load('Authentication', $method) ?: []; $total = (int) ($meta['config']['settings']['attempts'] ?? 0); if ($total) { $penalty = (int) ($meta['config']['settings']['attempts_penalty_minute'] ?? 5); $attempts = (int) ($state['attempts'] ?? 0) + 1; if ($total - $attempts < 1) User::addBlocked($reason, $userId, [], DateManager::next_date(['minute' => $penalty])); else $state['attempts'] = $attempts; } return ['status' => 'error', 'message' => $message]; } ``` ### Pitfalls > **setup() gets the user row, the verify methods get a wrapper** > > `setup()` reads `$user_data['email']`. Everything else gets `['user' => [...], 'data' => [...]]`. The wrong shape yields an empty value, not an error: the code never matches. > **A recovery key succeeds once and then turns 2FA off** > > A successful `verify_recovery()` makes the core delete the stored preference, so the user enrols again. The key is used once. > **Rate limit delivery inside your module** > > The core counts wrong answers, not resends. A reloaded challenge screen calls `verification()` again, so an unconditional send can spam an inbox. Hold the code and return `retry_delay`. > **Return a status array, do not throw** > > The core reads a status rather than catching. Report a wrong code as `['status' => 'error']`, a delivery failure as `['error' => '...']`. An uncaught throw breaks the login screen. > **Enrolments survive a method being switched off** > > The stored preference is checked against the active list on every read. Switching your method off does not lock out enrolled users; they stop being challenged. Switching it back on resumes. ### Related Articles - [Writing a Social Login Provider](https://dev.wisecp.com/en/writing-a-social-login-provider) - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Security Practices](https://dev.wisecp.com/en/security-practices) - [Reading and Writing Configuration](https://dev.wisecp.com/en/reading-and-writing-configuration) ## Writing a Social Login Provider https://dev.wisecp.com/en/writing-a-social-login-provider Add a "sign in with" button to the login, registration and admin screens. One class names the provider's endpoints and maps its claims to an account. ### Overview SocialAuth has a real base class. `SocialAuthProvider` owns the authorize URL, the CSRF state, the code exchange, token verification and the popup button. Your module declares five endpoints, a credential schema and one mapping method. Three providers ship: Apple, Google, Microsoft. Discovery is a registry, not a hook: `Auth::activeProviders()` keeps the enabled modules, so a new folder appears on every sign screen. The flow: button, popup, consent, one callback, code exchange, verification, account resolution, sign in. ### Prerequisites - A provider speaking OAuth 2.0 code flow and OpenID Connect, with an RS256 signed `id_token` and a JWKS document. - A client id and secret, plus a registered redirect URI. - Outbound HTTPS to the token and JWKS endpoints. - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy), [Module Configuration](https://dev.wisecp.com/en/module-configuration). ### Structure ```bash coremio/modules/SocialAuth/Acme/ ├── Acme.php class Acme extends \SocialAuthProvider ├── config.php meta + status + empty settings keys ├── acme.svg brand logo, optional (fall back to a bi-* icon) └── lang/ ├── en.php └── tr.php ``` - **SocialAuthProvider**: The shared base. - **Auth::activeProviders()**: The registry sign screens read. - **Auth::handleProviderCallback()**: The callback entry point; consumes the state, calls `feedback()`. - **Auth::connectProvider()**: Resolves the account (provider id, then email) and signs in. - **coremio/modules/SocialAuth**: Your folder goes here. ### Walkthrough #### 1. Scaffold the Module 1. Create `coremio/modules/SocialAuth/Acme/`. 2. Write `config.php`: `meta`, `status => false`, an empty `settings` map. 3. Write `lang/en.php` and `lang/tr.php`: `button-label`, a `field-*` pair per credential, `error-*`, setup steps. 4. Add a colour logo as `acme.svg`, or return a `bi-*` icon name. #### 2. Endpoints and Credentials 1. Extend the base and implement the five endpoint methods. 2. Implement `configFields()`; a `'type' => 'password'` field is encrypted and masked. 3. Implement `testConnection()` so it asks the provider something real. 4. Write the setup steps as `setup-step-1`, `setup-step-2`, contiguously. #### 3. Map the Identity 1. Implement `feedback()`: `exchange_code()`, then `verify_id_token()`. 2. Reject an unverified email by throwing. 3. Pick an identifier that is per account, not per application: Google and Apple use `sub`, Microsoft `oid`. 4. Return the two-part array below. #### 4. Enable 1. Find your provider in the Settings Social Auth list. 2. Copy the Redirect URI the panel shows into the provider console. 3. Paste the credentials, press Test Connection, switch it on and save. 4. Reload the login page. ### Reference #### Abstract Contract Nine abstract methods. ```php // Presentation. ['label' => string, 'icon' => 'bi-*', 'logo' => absolute url (optional)] abstract public function provider_meta(): array; // Credential schema for the Settings accordion. See the key table below. abstract public function configFields(): array; // The [Test Connection] button. Return true, or throw with a message the operator can act on. abstract public function testConnection(): bool; abstract protected function auth_endpoint(): string; // where the popup sends the user abstract protected function token_endpoint(): string; // where the code is exchanged abstract protected function scopes(): string; // e.g. 'openid email profile' abstract protected function jwks_url(): string; // signature verification keys abstract protected function issuers(): array; // accepted `iss` values // $ctx = ['code' => string, 'redirect_uri' => string, 'nonce' => string] abstract public function feedback(array $ctx): array; ``` #### feedback() The context array carries three keys. - **code**: The authorization code, from the query string or POST body. - **redirect_uri**: The URI the authorize step used. - **nonce**: Planted in the authorize URL; pass it to `verify_id_token()`. ```php return [ // The account link. `name` becomes a user info key, `value` is the encrypted stable id. 'field_info' => [ 'name' => "acme_uid", 'value' => Crypt::encode($sub, Config::get("crypt/user")), ], // What the account is built from / matched by. 'data' => [ 'name' => "Ada", // given name 'surname' => "Lovelace", // family name 'email' => "ada@example.com", // MUST be provider-verified 'picture' => "", // avatar url, or an empty string 'provider_uid' => $sub, // the raw id, unencrypted ], ]; ``` #### configFields() The returned map is `field name => descriptor`; the name is the POST name and the settings key. - **type**: Absent means text; `password` is masked and encrypted, `textarea` is multi line. - **label**: The visible label. - **required**: Feeds the enabled state, not validation. - **secret**: Encrypt and mask a non `password` field. - **placeholder**: Hint text. - **description**: Help text. - **rows**: Height of a `textarea`. #### Four Override Points The defaults suit a plain OpenID Connect provider. | Method | Default | Override when | | --- | --- | --- | | `client_id()` | the stored `client_id` | it lives under another key; it is also the audience. | | `client_secret()` | the decrypted `client_secret` | no static secret; one is minted per request. | | `extra_auth_params()` | `['prompt' => 'select_account']` | extra parameters are needed. Merge, do not replace. | | `accept_issuer($iss, $issuers, $payload)` | exact match against `issuers()` | the issuer is not fixed, for example a tenant id. | > **Call the accessor, not the raw setting** > > The authorize URL, the code exchange and the audience check go through `client_id()`. Reading the setting directly makes an override ineffective. #### Base Helpers - **exchange_code()**: Posts the code. Throws without an `id_token`. - **verify_id_token()**: Verifies signature, issuer, audience, expiry and nonce locally; returns the claims. - **setting()**: One stored setting, raw; a secret stays encrypted. - **callback_url()**: The single redirect URI; the starting side travels in the CSRF state. - **enabled()**: True when the switch is on and every required credential is filled. - **authorize_url($context, $mode)**: Builds the authorize URL and registers the state. `$context`: admin or client, `$mode`: login or register. - **setup_guide()**: Collects `setup-step-N` to the first gap. - **save_settings($fields)**: Persists the accordion; keeps the stored secret when the posted one is the mask. #### Core Call Sites | Call site | Calls | Why | | --- | --- | --- | | `Auth::activeProviders()` | `provider_meta()`, `enabled()`, `connection_button()` | Registry. | | `Auth::handleProviderCallback()` | `enabled()`, `consume_state()`, `feedback()` | Callback; your message goes to the popup. | | `controllers/admin/settings.php` | `configFields()`, `callback_url()`, `setup_guide()` | Settings accordion. | | `save_social_provider()` | `save_settings()` | Save. | | `test_social_provider()` | `testConnection()` | Test; typed values apply first. | ### Example A complete provider, then the core code that reads it. ```php $this->lang["button-label"] ?? "Continue with Acme", 'icon' => 'bi-box-arrow-in-right', 'logo' => $this->url . 'acme.svg', ]; } public function configFields(): array { return [ 'client_id' => [ 'type' => 'text', 'label' => $this->lang["field-client-id"] ?? "Client ID", 'required' => true, 'placeholder' => "acme-0000-0000", 'description' => $this->lang["field-client-id-desc"] ?? '', ], 'client_secret' => [ 'type' => 'password', 'label' => $this->lang["field-client-secret"] ?? "Client Secret", 'required' => true, 'description' => $this->lang["field-client-secret-desc"] ?? '', ], ]; } protected function auth_endpoint(): string { return "https://id.acme.example/oauth2/authorize"; } protected function token_endpoint(): string { return "https://id.acme.example/oauth2/token"; } protected function scopes(): string { return "openid email profile"; } protected function jwks_url(): string { return "https://id.acme.example/.well-known/jwks.json"; } protected function issuers(): array { return ["https://id.acme.example"]; } public function testConnection(): bool { $clientId = $this->client_id(); if ($clientId === '') throw new Exception($this->lang["error-invalid-client-id"] ?? "Please enter a Client ID."); // Ask the provider whether this id exists: an unknown one answers invalid_client, // a real one answers redirect_uri_mismatch. A format check would pass either way. $probe = $this->auth_endpoint() . '?' . http_build_query([ 'client_id' => $clientId, 'response_type' => 'code', 'scope' => $this->scopes(), 'redirect_uri' => $this->callback_url(), 'state' => 'wisecp-connectivity-check', ]); $resp = (string) Utility::HttpRequest($probe, ['timeout' => 8]); if ($resp === '') throw new Exception($this->lang["error-unreachable"] ?? "Could not reach Acme."); if (str_contains($resp, 'invalid_client')) throw new Exception($this->lang["error-client-not-found"] ?? "Acme does not recognize this Client ID."); return true; } public function feedback(array $ctx): array { $code = (string) ($ctx['code'] ?? ''); if ($code === '') throw new Exception("Missing authorization code."); $token = $this->exchange_code($code, (string) ($ctx['redirect_uri'] ?? '')); $payload = $this->verify_id_token((string) $token['id_token'], (string) ($ctx['nonce'] ?? '')); $email = (string) ($payload["email"] ?? ''); if (!$email) throw new Exception($this->lang["error-no-email"] ?? "Could not read your email address."); // Fail closed: the resolver matches an existing account by email, so an // unverified address would let anyone claim someone else's account. if (($payload["email_verified"] ?? false) !== true) throw new Exception($this->lang["error-email-unverified"] ?? "Acme has not verified this email address."); $fullName = trim((string) ($payload["given_name"] ?? '') . ' ' . (string) ($payload["family_name"] ?? '')); $smash = Filter::name_smash(Utility::ucfirst_space(Utility::substr($fullName, 0, 255))); $sub = (string) ($payload["sub"] ?? ''); if ($sub === '') throw new Exception($this->lang["error-no-account"] ?? "Could not identify your Acme account."); return [ 'field_info' => [ 'name' => "acme_uid", 'value' => Crypt::encode($sub, Config::get("crypt/user")), ], 'data' => [ 'name' => $smash["first"] ?? '', 'surname' => $smash["last"] ?? '', 'email' => $email, 'picture' => (string) ($payload["picture"] ?? ''), 'provider_uid' => $sub, ], ]; } } ``` ```php // classes/Auth.php - handleProviderCallback(), reduced to the part your class touches. $st = $provider->consume_state($state); // mode + context + nonce, one shot if (!$st) return ['status' => 'error', 'message' => "Sign-in could not be completed."]; $result = $provider->feedback([ 'code' => $code, 'redirect_uri' => $provider->callback_url(), 'nonce' => (string) ($st['nonce'] ?? ''), ]); // connectProvider() then does, in this order: // 1. look the account up by field_info (survives an email change), // 2. fall back to data.email, // 3. on mode=register and member context only, create the account, // 4. persist field_info so the next sign-in resolves by step 1. return Auth::connectProvider('member', 'Acme', $result, (string) ($st['mode'] ?? 'login')); ``` The sign screen asks the registry: ```php // controllers/website/sign.php - the login page, and the same call with "register" on sign-up. $this->addData("social_providers", \Auth::activeProviders("login", "client")); // The theme then loops the array; each entry is ['meta' => [...], 'button' => '']. ``` ### Pitfalls > **An unverified email is an account takeover** > > The resolver matches by email, so an unverified address lets an attacker sign in as somebody else. > **The wrong stable id breaks the second sign-in** > > Pick a claim that is immutable and identical across applications. A pairwise identifier links the account to a value that never comes back. > **Report failure by throwing** > > `$this->error = '...'; return false;` is a leftover. The callback handler and the Settings operations catch the exception and show its message. > **Do not put a secret in config.php by hand** > > Secrets are written encrypted and read through the matching decode; a hand typed value becomes an empty string. > **Build the instance with the factory** > > Every core call site uses `Modules::getInstance("SocialAuth", $name)`, which fills config and language. A direct constructor skips that. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Module Language Files](https://dev.wisecp.com/en/module-language-files) - [Writing an Authentication Module](https://dev.wisecp.com/en/writing-an-authentication-module) - [Login and Registration](https://dev.wisecp.com/en/login-and-registration) - [Security Practices](https://dev.wisecp.com/en/security-practices) ## Writing a Captcha Module https://dev.wisecp.com/en/writing-a-captcha-module A Captcha module supplies two halves of one challenge: the markup a public form shows, and the verification that runs on submit. ### Overview Four modules ship. DefaultCaptcha builds a code image and compares the typed answer against the session. Turnstile, hCaptcha and reCaptcha embed a widget and verify its token. There is no base class. One provider is active at a time, named in `options/captcha/type`. Which forms are protected is decided by the shared helper. You supply the challenge; the helper decides when to show it. A widget that never appears is usually a configuration problem, not a module problem. ### Prerequisites - Write access to `coremio/modules/Captcha/`. - A provider account with a site key and a secret key. - A form to test against. - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms): captcha is one of five layers there. ### Structure Three files, no namespace, no views. The settings screen comes from a field descriptor your module returns. ```bash coremio/modules/Captcha/Acme/ ├── Acme.php class Acme, no namespace ├── config.php returns the saved keys, e.g. site-key and secret-key └── lang/ ├── en.php error strings and any button labels your markup prints └── tr.php ``` A provider that makes its own image uses the shared endpoint `/captcha.jpg`, which calls `generateDisplay()` on the active provider. ### Walkthrough #### The Module Class 1. Create `coremio/modules/Captcha/Acme/Acme.php`, class named after the directory, no namespace. 2. In the constructor, read config and language. Guard the config with an array check: an unsaved module returns nothing. 3. Implement `getMarkup()`, returning a fragment; the helper wraps it. 4. Implement `check()`, returning a plain boolean. 5. Add `headJS()` for a script tag and `refreshJS()` to reset a spent token. #### The Settings Fields 1. Return a field descriptor from `config_fields()`; those keys are what you read back. 2. Give the secret field the password type. 3. Implement `save_fields()`, returning the array to persist; false with `$this->error` shows your message. 4. Merge rather than replace. 5. The caller writes the returned array to `config.php`. #### Gating a Form 1. Print the widget from the theme with the captcha tag, naming the area. 2. In the submit handler, work out whether a challenge is required, then verify. 3. Reply with a distinct status when the challenge is required but unanswered. 4. Call the refresh function after every submit. 5. Register a new area name through the areas hook. ### Reference #### Method Contract Only `check()` is called without a guard, so it is the one method you cannot leave out. Everything else is probed with `method_exists`. | Method | Called by | Returns | | --- | --- | --- | | `check()` | The submit handler | bool; nothing else is inspected | | `getMarkup()` | The widget builder | the widget HTML fragment | | `headJS()` | The widget builder, once per request | a script tag, or empty | | `refreshJS()` | The widget builder | a JavaScript statement, or empty | | `getInputName()` | The helper constructor | the answer field's name | | `generateDisplay()` | The shared image endpoint | writes the image, returns empty | | `config_fields()` | The settings screen | the field descriptor map | | `save_fields($fields)` | The settings save | the array to persist, or false | The presence of `getInputName()` is the family marker. With it the module is a code challenge: the helper adds a box and a text input with that name. Without it, the markup goes straight into the slot. #### Signatures ```php public function __construct(); // The verdict. A bool, not a status array: the helper returns it straight to the caller. public function check(): bool; // The widget fragment. No form tag, no wrapper: the helper supplies the slot. public function getMarkup(): string; // Emitted once per request per provider, above the slot. public function headJS(): string; // A statement, not a function: the helper wraps it into window.wcpCaptchaRefresh. public function refreshJS(): string; // Code providers only. Its PRESENCE switches the slot into code mode. public function getInputName(): string; // Code providers only. Writes the image itself and returns ''. public function generateDisplay(): string; // Settings screen. public function config_fields(): array; public function save_fields($fields = []): array|bool; ``` ```php // config_fields(): the array key IS the config key. Whatever you name here is // what arrives in save_fields() and what you read back from $this->config. public function config_fields(): array { return [ 'site-key' => [ 'wrap_width' => 100, // percentage width of the field row 'name' => "Site Key", // the label 'description' => "", // help text under the field 'type' => "text", // text | password | select | switch 'value' => $this->config['site-key'] ?? '', ], 'secret-key' => [ 'wrap_width' => 100, 'name' => "Secret Key", 'description' => "", 'type' => "password", 'value' => $this->config['secret-key'] ?? '', ], ]; } // save_fields(): validate, then return the array to persist. The CALLER writes the file. public function save_fields($fields = []): array|bool { if (!isset($fields['site-key']) || !$fields['secret-key']) { $this->error = $this->lang['error1']; return false; } return $this->config ? array_replace_recursive($this->config, $fields) : $fields; } ``` `$error` must exist on your class: the settings save reads it after a false return and raises the message from there. #### The Operator's Choices None of these live in your module's config; they sit in the shared options file. - **options/captcha/status**: The master switch. Off, the widget is an empty string everywhere. - **options/captcha/type**: The active provider's class name. An unknown value falls back to the built in module. - **options/captcha/{area}**: One flag per protected form: sign in, sign up, password reset, contact, feedback, newsletter, domain lookup, licence check. - **register:admin.captcha_protected_areas**: Adds an area to the settings list. The list is by reference: push your key, the return is ignored. - **Captcha::enabled()**: The gate a submit handler asks: master switch on, area flag on. - **Captcha::widget()**: Builds the whole slot. Accepts `tray` (a collapse id), `class` and `force` (always visible). #### The Three States The same widget call produces three results. Knowing which one you have answers most "why is it not showing" questions. | State | When | Result | | --- | --- | --- | | Static | The area is switched on | The slot, visible, with the submit gate marker | | Adaptive | The area is off but the bot shield watches it | The slot in a collapsed tray, no gate marker | | Absent | Neither applies, and not forced | An empty string | The adaptive state omits the gate marker on purpose. Otherwise the theme would block the first submit and the bot shield would never see an attempt. Once an address has tripped the shield, the box is shown up front. ### Example A token based provider, then the theme and the handler that consume it. ```php config = is_array($config) ? $config : []; $this->lang = Modules::Lang("Captcha", __CLASS__); } public function config_fields(): array { return [ 'site-key' => [ 'wrap_width' => 100, 'name' => "Site Key", 'description' => "", 'type' => "text", 'value' => $this->config['site-key'] ?? '', ], 'secret-key' => [ 'wrap_width' => 100, 'name' => "Secret Key", 'description' => "", 'type' => "password", 'value' => $this->config['secret-key'] ?? '', ], ]; } public function save_fields($fields = []): array|bool { if (!isset($fields['site-key']) || !$fields['secret-key']) { $this->error = $this->lang['error1'] ?? 'Both keys are required.'; return false; } return $this->config ? array_replace_recursive($this->config, $fields) : $fields; } // A fragment. The helper adds the slot wrapper and the spacing utilities. public function getMarkup(): string { return '
    '; } public function headJS(): string { return ''; } // A statement, not a function body: the helper wraps it in window.wcpCaptchaRefresh. public function refreshJS(): string { return 'if (typeof acmeCaptcha !== "undefined") acmeCaptcha.reset();'; } public function check(): bool { // The provider names its own POST field, so this one is read raw rather than // through the input name the code family declares. $token = (string) Filter::init("POST/acme-captcha-response", "hclear"); if ($token === '') return false; $response = Utility::HttpRequest([ 'url' => 'https://challenges.example.com/v1/siteverify', 'type' => 'POST', 'data' => [ 'secret' => $this->config['secret-key'] ?? '', 'response' => $token, 'remoteip' => UserManager::GetIP(), ], ]); $decoded = Utility::jdecode((string) $response, true) ?: []; // A bool, nothing else. A transport failure is a failed challenge, not an exception: // throwing here would turn an unreachable provider into a broken login form. return (bool) ($decoded['success'] ?? false); } } ``` ```twig {csrf form='domain-check'} {captcha area='domain-check' tray='domainCaptchaTray'} ``` ```php // The order matters: forgery check, then hard block, then the challenge. if (!\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "domain-check")) return $operation->output(['status' => "error", 'message' => Language::g("needs/csrf-failed")]); if (\ProcessRestriction::blocked("domain-check")) return $operation->output(['status' => "error", 'message' => Language::g("needs/too-many-requests")]); // Two independent reasons to ask: the operator turned the area on, or this // visitor tripped the shield. Either one makes the answer mandatory. $needCaptcha = \Captcha::enabled("domain-check") || \BotShield::triggered("domain-check"); if ($needCaptcha && !(new \Captcha())->check()) { \BotShield::record("domain-check"); // A distinct status, not a generic error: the form JS reveals the tray on this one. return $operation->output([ 'status' => "captcha_required", 'message' => Language::g("needs/captcha-failed"), ]); } // ... the actual lookup ... \ProcessRestriction::hit("domain-check"); if ($needCaptcha) \BotShield::clear("domain-check"); else \BotShield::record("domain-check"); ``` ### Pitfalls > **An unknown provider name falls back silently** > > The helper loads the configured class and, if that fails, builds the built in module. A misspelled directory, a mismatched class name or a fatal in your constructor give the same symptom. > **Tokens are single use: refresh after every submit** > > A verified token is rejected on the second attempt. Return a reset statement from `refreshJS()` and call the global refresh in the form's finally block. > **One page, several widgets, one session slot** > > A self drawing provider keeps its phrase in one session slot, so every image request replaces it. With more than one widget on a page, stamp all their URLs with the same value in one pass. > **Do not decide visibility yourself** > > `getMarkup()` is only called once the helper has decided the challenge should exist. Your own config check breaks the adaptive path: that slot is meant to be hidden and revealed later. > **A new protected form needs a new area name** > > The area string is the key in both the config and the gate call. Register yours through the areas hook, then use the same string in the theme tag and the handler. ### Related Articles - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms) - [Writing a Fraud Module](https://dev.wisecp.com/en/writing-a-fraud-module) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Security Practices](https://dev.wisecp.com/en/security-practices) - [Login and Registration](https://dev.wisecp.com/en/login-and-registration) ## Writing an IP Module https://dev.wisecp.com/en/writing-an-ip-module Plug a geolocation and proxy-detection provider into the one place the product asks where a visitor is, and whether the address is risky. ### Overview An IP module answers two questions about an address. Where is it, and does it look like a proxy, a VPN or a datacentre? Exactly one module is active, stored as `modules/ip`. Everything else reads it through `UserManager::ip_info()` and `UserManager::is_proxy()`. Those answers reach far. The visitor's currency comes from the country code, the login flow challenges a session whose country or city changed, and forms can refuse a proxy. Three modules ship: WAtlas, WiseIP and ip_api. There is no base class; the contract is the call site. ### Prerequisites - A provider that resolves IPv4 or IPv6 to at least an ISO country code. That field has no fallback. - Outbound HTTP to that provider, plus a plan for its rate limit. - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration). ### Structure ```bash coremio/modules/IP/AcmeGeo/ ├── AcmeGeo.php class AcmeGeo (no base class, no namespace required) ├── config.php ['website' => 'AcmeGeo', 'key' => ''] └── pages/ └── settings.php the credential fields, rendered inside Settings ``` - **UserManager::ip_info()**: Geolocation entry point. Calls `info()` and caches the array. - **UserManager::is_proxy()**: Risk entry point. Calls `proxy()` when declared, applies the whitelist. - **Modules::getInstance()**: Builds your object; `new` is never used. - **Modules::getPage()**: Puts `pages/settings.php` into the Settings screen. - **coremio/modules/IP**: Your folder goes here. ### Walkthrough #### 1. Scaffold the Module 1. Create `coremio/modules/IP/AcmeGeo/` with `AcmeGeo.php`. The class name must match the folder. 2. Write `config.php`. The dropdown uses the `website` key. 3. Add your settings fields as further keys, empty. #### 2. Implement info() 1. Call your provider with the address you were handed; core already resolved it. 2. Normalise the answer. Two rules are mandatory: `countryCode` lower case, and `city` filled even when the provider gives only a region. 3. On failure, set `$this->error` and return false. #### 3. Implement proxy(), or Do Not 1. The method is optional. A module that only geolocates works without it; proxy blocking then gives no verdict. 2. Two keys look alike: `proxy` means "looks like a proxy", `result` means "block this". Only `result` gates anything. 3. Return the autonomous system as `as`, shaped `AS15169 Example Org`; the whitelist matches the first token. #### 4. Add the Settings Page 1. Create `pages/settings.php`, a plain fragment injected into the Settings form. 2. Name every input `ip_api_config[yourkey]`; the name is the config key. 3. Read current values from `$module->config`. 4. Choose your module in Settings and save. The selection lands in `modules/ip`. ### Reference #### The Two Methods There is no interface to implement. These are the exact call sites. ```php public $error; // read by core after a false return public $config = []; // filled in the constructor from the module's config.php public function __construct(); // Geolocation. REQUIRED. Return the array below, or false with $this->error set. // Called from UserManager::ip_info() in classes/UserManager.php. public function info($ip = ''); // Risk scoring. OPTIONAL - core probes with method_exists() before calling. // Called from UserManager::is_proxy() in classes/UserManager.php. public function proxy($ip = ''); ``` ```php // classes/UserManager.php, ip_info() $ip_module = Config::get("modules/ip"); $obj = Modules::getInstance("IP", $ip_module); if (!$obj) return ['status' => "error", 'message' => "IP module '{$ip_module}' could not be loaded."]; $result = $obj->info($ip); if (!$result) { // A timeout is swallowed as a plain false; anything else is logged and surfaced. if (stristr($obj->error, 'timed out')) return false; Modules::save_log("IP", $ip_module, "check", $ip, $obj->error); return ['status' => "error", 'message' => $obj->error]; } // classes/UserManager.php, is_proxy() $proxy_obj = Modules::getInstance("IP", $ip_module); if ($proxy_obj && method_exists($proxy_obj, 'proxy')) { $pdata = $proxy_obj->proxy($ip); if ($pdata === false) $error = $proxy_obj->error; } ``` #### What info() Returns The keys consumers read, in the form they expect. | Key | Shape | Who reads it | | --- | --- | --- | | `countryCode` | **lower case** ISO code, e.g. `nl` | Currency and login location check. Missing means failure. | | `city` | city, or region when there is no city | Login location check, city precision. | | `regionName` | region or state name | Display; usually copied into `city`. | | `country` | country name in English | Display. | | `as` | `AS15169 Example Org` | Proxy whitelist, first token. | | `query` | the looked-up address | Echo of the input. | | `zip`, `lat`, `lon`, `timezone`, `isp` | strings or numbers | Optional; stored, not required. | > **No country code means return false** > > Callers treat an empty `countryCode` as "unknown", but a truthy array still counts as a successful lookup and gets cached. The failure then sticks to that address until the cache file is removed. #### What proxy() Returns - **result**: Boolean. The blocking verdict; the only key that stops anything. - **proxy**: Boolean. "Looks like a proxy or VPN." Informational. - **hosting**: Boolean. Datacentre range. Also informational. - **as**: The autonomous system. Clears a risky verdict when whitelisted. - **score, verdict**: Free-form extras. Not read by core, but written into the cache. The detailed form returns only the three booleans: ```php // Third argument true asks for the breakdown instead of the bare verdict. $verdict = UserManager::is_proxy($ip, false, true); // ['proxy' => bool, 'hosting' => bool, 'risky' => bool] // 'risky' is your 'result', after the operator's whitelist has been applied. // The common form, used by the login and registration gates: if (Config::get('options/proxy-block') && UserManager::is_proxy() === true) throw new Exception(Language::g('errors/error9')); ``` #### Caching and Quota Core protects your provider before your code runs. These files are also why a fix can look dead. - **temp/ip-log-{ip}.json**: A successful `info()` result, per address, no expiry. - **temp/{ip}-proxy.json**: The same for `proxy()`. Delete both when retesting an address. - **coremio/storage/ip-overload-limit.php**: Date plus counter. Past the cap, `ip_info()` returns an error array and never calls you. `options/ip-overload-limit`, default 100. - **coremio/storage/proxy-overload-limit.php**: Same for risk lookups: `options/proxy-overload-limit`, default 100. - **in-request memo**: A static per-address map; repeat lookups in one request are free. #### Settings Page Contract - **config.php: website**: The dropdown label. A missing key yields a blank option. - **input name: ip_api_config[key]**: The name in brackets is the config key. - **$module**: The only variable the fragment receives: your live instance. - **get_ip_api_configs()**: Fetches your fragment (`operations/AdminGeneralSettings.php`). - **recursive merge**: The posted array is merged over the stored config, so an omitted key keeps its value. ### Example A complete module, its settings fragment and the consumer side. ```php config = Modules::Config("IP", __CLASS__); } public function info($ip = '') { $this->error = null; $key = (string) ($this->config["key"] ?? ''); $url = "https://api.acmegeo.example/v1/lookup/" . rawurlencode($ip); $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: " . $key]); $response = curl_exec($ch); if (curl_errno($ch)) { $this->error = curl_error($ch); $response = false; } curl_close($ch); if ($response === false) return false; $data = Utility::jdecode((string) $response, true); if (!is_array($data)) { $this->error = "Invalid response from AcmeGeo."; return false; } // No country means no usable answer. Returning a truthy array here would be // cached as a success and the address would stay broken until the file is removed. $iso = (string) ($data["country_code"] ?? ''); if (!$iso) { $this->error = "Country code not found : " . $ip; return false; } $region = (string) ($data["region"] ?? ''); $city = (string) ($data["city"] ?? ''); return [ 'status' => "success", 'query' => (string) ($data["ip"] ?? $ip), 'countryCode' => strtolower($iso), // lower case is required 'country' => (string) ($data["country_name"] ?? ''), 'regionName' => $region ?: $city, 'city' => $city ?: $region, // never leave city empty 'zip' => (string) ($data["postal"] ?? ''), 'lat' => $data["latitude"] ?? '', 'lon' => $data["longitude"] ?? '', 'timezone' => (string) ($data["time_zone"] ?? ''), 'as' => isset($data["asn"]) ? trim("AS" . $data["asn"] . " " . ($data["asn_org"] ?? '')) : '', ]; } public function proxy($ip = '') { $this->error = null; $ch = curl_init("https://api.acmegeo.example/v1/risk/" . rawurlencode($ip)); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: " . (string) ($this->config["key"] ?? '')]); $response = curl_exec($ch); if (curl_errno($ch)) { $this->error = curl_error($ch); $response = false; } curl_close($ch); if ($response === false) return false; $data = Utility::jdecode((string) $response, true); if (!is_array($data)) { $this->error = "AcmeGeo risk lookup failed."; return false; } $score = (int) ($data["score"] ?? 0); return [ // 'result' blocks, 'proxy' only describes. Do not collapse the two. 'result' => $score >= 60, 'proxy' => $score >= 30, 'hosting' => (bool) ($data["datacenter"] ?? false), 'score' => $score, 'as' => isset($data["asn"]) ? trim("AS" . $data["asn"] . " " . ($data["asn_org"] ?? '')) : '', ]; } } ``` ```php "AcmeGeo", // the label the Settings dropdown shows 'key' => '', // written back by the save path, never by hand ]; ``` ```html
    ">
    ``` ```php // helpers/Money.php - the visitor's currency comes from the country code. $info = UserManager::ip_info(); // ip_info() also answers ['status' => 'error', 'message' => ...] when the daily quota is // spent or the module cannot load: truthy, but with no countryCode. Read it null-safely. $needle = strtoupper($info["countryCode"] ?? ''); // classes/Auth.php - the login location check, at country or city precision. $country = is_array($info) ? (string) ($info['countryCode'] ?? '') : ''; $city = is_array($info) ? (string) ($info['city'] ?? '') : ''; if ($country === '') return false; // an unavailable lookup is never treated as a change ``` ### Pitfalls > **Report failure with the error property, not by throwing** > > Provisioning, payment and registrar modules throw and their callers catch. The two entry points here do not: they check for a falsy return, then read `$this->error`. An exception inside `info()` escapes into the global handler. Set the property, return false. > **Keep the timeout short: you run while a page loads** > > These lookups run inside ordinary requests. The shipped modules use two to ten seconds. A message containing "timed out" or "timeout" is deliberately swallowed as a plain false, so a slow provider degrades quietly. > **A cached lookup hides your change** > > The per-address cache files have no expiry, so after the first call your new code is never reached for that address. Delete both files before you measure. > **Only one module is active** > > Unlike payment methods, this is a single choice stored in `modules/ip`. Installing your module switches nothing on: an operator has to pick it first. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Writing a Fraud Module](https://dev.wisecp.com/en/writing-a-fraud-module) - [Writing a Currency Module](https://dev.wisecp.com/en/writing-a-currency-module) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Security Practices](https://dev.wisecp.com/en/security-practices) ## Writing a Storage Module https://dev.wisecp.com/en/writing-a-storage-module Add a backup destination by implementing six transfer methods. The operator can register as many accounts on it as they like. ### Overview A Storage module is where backup archives go. It is not a global choice: an operator registers destinations as rows. Credentials do not live in `config.php`; they arrive in the constructor, per destination, decrypted. There are two base classes. `StorageModule` connects with credentials the operator typed; `CloudStorageModule` adds the OAuth authorization code flow. Six modules ship. Failure is a thrown `StorageException`; success returns nothing. ### Prerequisites - A destination that can store a large file, read it back, list a directory and report a byte size. - For a cloud provider, a client id and secret plus the redirect address. - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) and [Module Configuration](https://dev.wisecp.com/en/module-configuration). ### Structure ```bash coremio/modules/Storage/AcmeVault/ ├── AcmeVault.php class AcmeVault extends StorageModule (or CloudStorageModule) ├── config.php ['meta' => [...], 'defaults' => [...]] no credentials here └── lang/ ├── en.php └── tr.php ``` - **StorageModule**: The plain base: five abstract transfer methods plus overridable defaults. - **CloudStorageModule**: Adds three abstract OAuth methods, a callback handler and state helpers. - **StorageException**: The failure channel. Narrowed by `StorageConnectionException` and `StorageAuthException`. - **Backup::buildStorage()**: Destination row to instance. - **Backup::decodeStorageConfig()**: Decrypts the fields `encryptedFields()` named; `encodeStorageConfig()` encrypts on save. - **coremio/modules/Storage**: Your folder goes here. ### Walkthrough #### 1. Pick the Base Class 1. Credentials the operator types: extend `StorageModule`, like FTP. 2. The operator signs in and you hold a refresh token: extend `CloudStorageModule`, like GoogleDrive. 3. The destinations screen shows the connect button only for a cloud subclass. #### 2. Declare Form and Secrets 1. Implement `configuration()`. Each field name becomes a key of the config array you receive later. 2. Implement the static `encryptedFields()` and name every secret. A forgotten field is stored in clear text. 3. Put non-secret defaults in `config.php` under `defaults`; the row is merged on top. #### 3. The Transfer Methods 1. Write `test()` so it proves writability: upload a probe, read its size, delete it. 2. Write `upload()`, `download()`, `delete()`, `list()`, `ensureDirectory()`. All return void, throw on failure. 3. Override `remoteSize()` if your provider has a cheap size call; the default derives it from `list()`. 4. Attach the progress callback in long transfers, or the watchdog reaps the job. #### 4. Add the OAuth Half 1. Implement `authorizationUrl()`, `exchangeCode()`, `refreshToken()` and `revokeTokens()`. 2. Do not write a callback route. The shared one validates the state, calls `exchangeCode()` and posts the result back. 3. Declare the token fields as hidden entries in `configuration()`: access token, refresh token, expiry, account. 4. Refresh lazily: check the expiry before each API call. ### Reference #### StorageModule ```php // Config arrives per destination: defaults merged with the row's decrypted credentials. public function __construct(array $storageConfig = []); // ── abstract: you must implement all five ────────────────────────────── abstract public function test(): void; abstract public function upload(string $localPath, string $remoteName): void; abstract public function download(string $remoteName, string $localPath): void; abstract public function delete(string $remoteName): void; abstract public function ensureDirectory(string $path): void; // Each entry: ['name' => string, 'size' => int, 'mtime' => int|null] abstract public function list(string $prefix = ''): array; // ── virtual: sensible defaults, override when you can do better ──────── public function remoteSize(string $remoteName): ?int; // derived from list() public function downloadStream(string $remoteName, $outputStream): void; // via a temp file public function getPresignedDownloadUrl(string $remoteName, int $ttlSeconds = 300): ?string; // null public static function encryptedFields(): array; // [] public function configuration(): array; // [] public function controller_test(): array; // wraps test() in try/catch // ── inherited plumbing, do not reimplement ───────────────────────────── public function setProgressCallback(?callable $cb): void; public function getProgressCallback(): ?callable; protected function attachProgressCallback(\CurlHandle $ch, ?int $intervalSec = null): void; const DEFAULT_FOLDER_PATH = '/wisecp-backup'; const HEARTBEAT_INTERVAL_SEC = 5; ``` - **remoteSize() returning null**: Means "the provider could not answer", not a failure. The backup is kept as unverified; zero would fail verification. - **getPresignedDownloadUrl()**: A short-lived direct URL; the download redirects to it, so the archive never transits this installation. Null when unavailable. - **downloadStream()**: Override when your client can stream; otherwise the archive is written to a temp file first. - **controller_test()**: Already written: calls `test()`, returns what the form expects. #### CloudStorageModule ```php // $state is produced by generateSignedState() and must be echoed to the provider. abstract public function authorizationUrl(string $state): string; // Returns ['access_token' => string, 'refresh_token' => string, // 'expires_at' => int, 'account_email' => ?string] // Throws StorageAuthException on failure. abstract public function exchangeCode(string $code): array; // Mutates $this->config['access_token'] and ['expires_at'] in place. abstract public function refreshToken(): void; // Default is a no-op. Override to hit the provider's revoke endpoint. public function revokeTokens(string $refreshToken): void; // Already implemented: validates state, calls exchangeCode(), builds the popup payload. public function callback_handle(): array; // HMAC-signed state that carries the provisional credentials, so the callback needs no // session and no database lookup. Default lifetime is ten minutes. public static function generateSignedState(array $cfg = [], int $ttlSeconds = 600): string; public static function decodeSignedState(string $state): array|false; ``` The callback route exists at `coremio/modules/Storage/router.php`. It maps a slug onto a module name, so a new provider needs one entry: ```php // api/system/backup/{slug}/callback $providers = ['google-drive' => 'GoogleDrive', 'onedrive' => 'OneDrive', 'yandex-disk' => 'YandexDisk']; $module = Modules::getInstance("Storage", $providers[$slug], [[]]); $result = $module->callback_handle(); // {status: connected|error, access_token, refresh_token, ...} // The result is posted back to the window that opened the popup. ``` #### configuration() Descriptor A flat list of arrays; only `type` and `name` are required. | Key | Accepts | What it does | | --- | --- | --- | | `type` | `text`, `number`, `password`, `checkbox`, `select`, `hidden`, `section`, `redirect_uri`, `oauth_connect` | The control. The last three are layout: heading, address, sign-in button. | | `name` | string | The config key, read as `$this->config['name']`. | | `label` | string | Visible label, from `$this->lang` with a literal fallback. | | `width` | 1 to 12 | Grid columns. | | `required` | bool | Marks the field mandatory. | | `encrypted` | bool | Marks a secret for the interface. It does **not** encrypt: `encryptedFields()` does, and both must agree. | | `value`, `checked`, `placeholder`, `description` | mixed | Initial value, tick state, hint, help. | | `step`, `doc_url`, `doc_label` | int, string, string | On a `section`: its number and a console link. | #### Where Credentials Live - **backup_storage row**: One row per destination: name, module name as `type`, `config` as encrypted JSON. - **the constructor merge**: Reads `meta` and `defaults`, then merges the passed config over them into `$this->config`. - **the third factory argument**: `Modules::getInstance("Storage", $type, [$config])`. The nesting is the constructor argument list. - **the mask**: Encrypted fields read back as five asterisks; posting that means "unchanged". - **revalidation on save**: An update re-runs `test()` only when `folder_name`, `folder_path`, `base_path` or `remote_directory` changed. #### Upload Verification The engine does not trust a clean return from `upload()`. ```php $module = Backup::buildStorage($storageId); // row -> decrypt -> getInstance // Heartbeat: a slow upload otherwise exceeds the queue's stale window and gets reaped. $module->setProgressCallback(function ($bytes = 0, $total = 0) use ($queueId, $backupId, $row): void { if ($queueId > 0) CronJobQueue::touch($queueId); $tot = (int) ($total > 0 ? $total : ($row['file_size'] ?? 0)); $pct = $tot > 0 ? (int) min(100, round(((int) $bytes) * 100 / $tot)) : 0; Backup::updateProgress($backupId, 'upload', $pct, (int) $bytes, $tot); }); $module->upload($finalArchive, $finalName); // Then: prove the bytes landed. A clean return is not proof; an interrupted transfer can // leave a correctly named but truncated file, and that is discovered only when someone // needs the backup. null means "could not answer" and is recorded as unverified, not failed. $remoteSize = $module->remoteSize($finalName); if ($remoteSize !== null && $remoteSize !== $localSize) throw new Exception("Upload verification failed: the destination holds {$remoteSize} bytes."); ``` ### Example A plain module, then the two sides that read it back. ```php 'text', 'name' => 'bucket', 'width' => 8, 'label' => $this->lang['field-bucket'] ?? 'Bucket', 'required' => true], ['type' => 'text', 'name' => 'region', 'width' => 4, 'label' => $this->lang['field-region'] ?? 'Region', 'value' => 'eu-central'], ['type' => 'password', 'name' => 'api_key', 'width' => 12, 'label' => $this->lang['field-api-key'] ?? 'API Key', 'required' => true, 'encrypted' => true], // Naming a directory field one of the path-like names makes an edit to it // re-run test() on save. Anything else is saved without revalidating. ['type' => 'text', 'name' => 'remote_directory', 'width' => 12, 'label' => $this->lang['field-remote-directory'] ?? 'Remote directory', 'value' => self::DEFAULT_FOLDER_PATH, 'placeholder' => self::DEFAULT_FOLDER_PATH], ]; } public function test(): void { $this->ensureDirectory(''); // Reachability is not writability. Write a probe, read its size back, remove it. $name = '.wisecp-storage-test-' . bin2hex(random_bytes(4)); $probe = 'wisecp'; $tmp = tempnam(sys_get_temp_dir(), 'wcp-acme-'); file_put_contents($tmp, $probe); try { $this->upload($tmp, $name); $size = $this->remoteSize($name); $this->delete($name); // null means the provider could not answer, which proves nothing either way. if ($size !== null && $size !== strlen($probe)) throw new StorageException(sprintf( $this->lang['error-upload-incomplete'] ?? 'Upload incomplete: the server stored %s of %s bytes', $size, strlen($probe) )); } finally { @unlink($tmp); } } public function upload(string $localPath, string $remoteName): void { if (!is_file($localPath)) throw new StorageException("Local file not found: {$localPath}"); $handle = fopen($localPath, 'rb'); if (!$handle) throw new StorageException("Local file not readable: {$localPath}"); $ch = curl_init($this->endpoint($remoteName)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_UPLOAD => true, CURLOPT_INFILE => $handle, CURLOPT_INFILESIZE => filesize($localPath), CURLOPT_HTTPHEADER => $this->headers(), ]); // Without this the watchdog sees no activity and can reap a slow upload as stale. $this->attachProgressCallback($ch); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); fclose($handle); if ($err !== '') throw new StorageConnectionException($err); if ($code < 200 || $code > 299) throw new StorageException("Upload failed (HTTP {$code}): " . (string) $body); } public function download(string $remoteName, string $localPath): void { $out = fopen($localPath, 'wb'); if (!$out) throw new StorageException("Cannot write to {$localPath}"); try { $this->downloadStream($remoteName, $out); } finally { fclose($out); } } public function delete(string $remoteName): void { $this->request('DELETE', $remoteName); } public function list(string $prefix = ''): array { $data = Utility::jdecode($this->request('GET', $prefix), true); $out = []; foreach (($data['objects'] ?? []) as $item) $out[] = [ 'name' => (string) ($item['key'] ?? ''), 'size' => (int) ($item['bytes'] ?? 0), 'mtime' => isset($item['modified']) ? (int) strtotime((string) $item['modified']) : null, ]; return $out; } // Cheaper than the inherited version, which reads a whole directory listing. public function remoteSize(string $remoteName): ?int { try { $head = Utility::jdecode($this->request('HEAD', $remoteName), true); } catch (\Throwable $e) { return null; } return isset($head['bytes']) ? (int) $head['bytes'] : null; } public function ensureDirectory(string $path): void { $this->request('MKCOL', $path); } private function endpoint(string $name): string { $base = trim((string) ($this->config['remote_directory'] ?? self::DEFAULT_FOLDER_PATH), '/'); return 'https://' . rawurlencode((string) ($this->config['region'] ?? '')) . '.acmevault.example/' . rawurlencode((string) ($this->config['bucket'] ?? '')) . '/' . trim($base . '/' . $name, '/'); } private function headers(): array { // The key arrives already decrypted: decoding happened when the row was read. return ['X-Api-Key: ' . (string) ($this->config['api_key'] ?? '')]; } private function request(string $method, string $name): string { $ch = curl_init($this->endpoint($name)); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $this->headers(), CURLOPT_TIMEOUT => 30, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); if ($err !== '') throw new StorageConnectionException($err); if ($code < 200 || $code > 299) throw new StorageException("{$method} failed (HTTP {$code})"); return (string) $body; } } ``` ```php [ 'name' => 'AcmeVault', 'version' => '1.0', 'description' => 'Object storage over HTTPS', 'icon' => 'bi bi-hdd-rack', ], // Merged UNDER the destination row, so a row value always wins. Credentials never // appear here: they belong to the destination, not to the module. 'defaults' => [ 'region' => 'eu-central', 'remote_directory' => '', ], ]; ``` ```php // 1. The destinations screen builds the provider list and your form from the module itself. $module = Modules::getInstance("Storage", $name, [[]]); // empty config: metadata only $entry = [ 'key' => $name, 'name' => $module->lang['name'] ?? $name, 'description' => $module->lang['description'] ?? ($module->meta['description'] ?? ''), 'oauth' => is_subclass_of("WISECP\\Modules\\Storage\\{$name}", "CloudStorageModule"), 'fields' => $module->configuration(), ]; // 2. Saving encrypts exactly the fields encryptedFields() named, then stores the JSON. WDB::insert("backup_storage", [ 'name' => $displayName, 'type' => $name, 'config' => Backup::encodeStorageConfig($name, $config), 'status' => 'enabled', ]); // 3. Running a backup goes the other way: read the row, decrypt, instantiate, transfer. $module = Backup::buildStorage($storageId); $module->upload($finalArchive, $finalName); ``` ### Pitfalls > **A secret missing from encryptedFields() is readable** > > The `encrypted` flag on a form field only styles the input; encryption is driven by the static list. The failure is silent: the value sits readable in the database. > **Never report zero when you cannot measure** > > `remoteSize()` separates "could not answer" (null) from "the file is empty" (zero). Zero for an unanswerable check discards a good archive. > **A silent upload is reaped as stale** > > Uploads run inside a queued job with a stale window that a large transfer can outlast. Without the progress callback the job is killed mid-flight. > **The factory nests the config one level deeper** > > The third argument of `Modules::getInstance("Storage", $type, [$config])` is the constructor's argument list; an unwrapped config spreads across parameters. > **Revoke on disconnect** > > Deleting a cloud destination calls `revokeTokens()` first, best effort: the row is removed either way. Without it a removed destination leaves a live refresh token. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Writing a Social Login Provider](https://dev.wisecp.com/en/writing-a-social-login-provider) - [Security Practices](https://dev.wisecp.com/en/security-practices) ## Writing an Import Module https://dev.wisecp.com/en/writing-an-import-module Move another billing platform into this one, in resumable chunks, by writing a class the migration wizard drives step by step. ### Overview An Imports module is a one-way migration: it reads someone else's database and writes this installation's tables. Three modules ship: WHMCS, Blesta and WISECP. There is no base class and no interface. The wizard instantiates your class by name, assigns four properties, then calls methods it probes for. The chunk loop matters most. A migration cannot finish inside one request, so the wizard calls one data type at a time and your module returns a row count and a done flag. The id map is persisted between calls. ### Prerequisites - Read access to the source database: host, database, user, password. - The source's encryption key, if it stores secrets encrypted. - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers): an import writes through them, not with raw inserts. - A restore point; the wizard offers to queue one. ### Structure ```bash coremio/modules/Imports/AcmeBill/ ├── AcmeBill.php class AcmeBill in namespace WISECP\Modules\Imports ├── hooks.php optional; only if imported rows need runtime help afterwards ├── lang/ │ ├── en.php name + area-* (the platform card) + one -desc per data type │ └── tr.php └── pages/ └── index.php the connection form, rendered inside the wizard's platform card ``` - **AdminTools::import()**: The operation every step posts to. Resolves your class, assigns properties, calls the step method. - **Modules::Load()**: Discovery: the platform name, or `All` for the platform list. - **Modules::getPage()**: Puts `pages/index.php` in your platform card, called from `area()`. - **Config::setd()**: Where the id map is persisted, keyed by platform name plus a connection token. - **coremio/modules/Imports**: Your folder goes here. ### Walkthrough #### 1. Scaffold the Platform 1. Create `coremio/modules/Imports/AcmeBill/AcmeBill.php` in the `WISECP\Modules\Imports` namespace. 2. Declare the four properties the wizard assigns. 3. Write `lang/en.php` and `lang/tr.php`; the `area-*` keys build the platform card. 4. Add one `-desc` key per data type. #### 2. Connect and Declare Types 1. Write `pages/index.php`, inputs named `AcmeBill[db_host]`. The wizard hands the group to you as one array. 2. Implement `connect()`: validate, connect, build a token from the details. That token is half the map's storage key. 3. Filter each credential by kind; passwords use the pass-through filter. 4. Implement `data_types()`: a name, a description and a `required` list per entry. #### 3. Write the Chunk Loop 1. Implement `pull_data()`: once per type, it only counts rows. 2. Implement `transfer_data()`: load the map, dispatch per type, save the map either way, return the row count and a done flag. 3. In a type method, select rows above the highest mapped id, limited to the chunk size, and record every new id. 4. Compute `done` by asking the source what is left after the last mapped id. #### 4. Awkward Parts 1. Import prerequisites at the top of the type method: a type can be selected alone. 2. Respect the mode: clean carries source ids and refuses a non-empty table; enrich adds alongside. 3. Implement `disconnect()`, called after every step. 4. For anything that cannot work yet, add a `hooks.php`; Blesta rewrites unreadable passwords on first login. ### Reference #### What the Wizard Calls No interface exists. These names are probed before each call. ```php // ── assigned onto the instance by the wizard, declare all four ────────── public array $lang; // your lang file, already resolved to the admin's language public string $area_link; // the wizard's own address, for links inside your page public string $name; // your module name, e.g. 'AcmeBill' public object $controller; // the admin controller running the request // ── read by the transfer step when the property exists ────────────────── public array $selected_data_types = []; // everything selected in this run public string $import_type = 'enrich'; // 'enrich' | 'clean' public int $records_per_request = 10; // chunk size chosen by the operator // ── methods, each guarded by method_exists() before it is called ──────── public function area(): void; // renders your connection form public function connect($info = []): void; // $info = the posted credential group, untyped in all three shipped modules public function disconnect(): void; // called at the end of every step // ['type' => ['name' => string, 'description' => string, 'required' => string[]]] public function data_types(): array; // ['status' => 'successful', 'count' => int, 'type' => string] public function pull_data(string $type = ''): array; // ['processed' => int, 'done' => bool] - one chunk, called repeatedly public function transfer_data(string $type): array; ``` #### Step Machine Every action posts to one operation with a `step` field. | step | What the operation does | What it calls on your module | | --- | --- | --- | | `validation` | Stores the backup preference, filters the types. | `connect()`, `data_types()` | | `pull_data` | Asks for a row count. | `connect()`, `pull_data($type)` | | `select` | Validates prerequisites, remembers mode and selection. | `connect()` only | | `backup_status` | Restore point, **before** the module loads. | nothing | | `transfer` | Runs a gate hook, transfers one chunk. | `connect()`, `transfer_data($type)` | - **connect() runs on every step**: Except `backup_status`. Make it cheap: once per chunk. - **the credentials come from the session**: Posted once, kept encrypted in the session, replayed into `connect()`. - **filter:module.import_data_types**: Runs over your type list at validation. Return the filtered list; an addon can add or hide a type. - **gate:module.import_run**: Runs before each chunk with platform, type and mode. Returning a non-empty string stops the run. - **action:import.completed**: Fires after every chunk with your result array; the return is ignored. #### The Id Map One structure, three jobs. ```php // Shape: ['users' => [sourceId => newId, ...], 'orders' => [...], 'language' => 'english'] // Stored under "import_{$this->name}_{$this->token}", where the token is a hash of the // connection details - so two source databases never share one run's progress. public function initialize_data($predefined_data = []): void { if ($this->data === null) { $data = Config::getd("import_" . $this->name . "_" . $this->token) ?: []; if (!$data || !is_array($data)) $data = []; } else $data = []; $this->data = array_replace_recursive($predefined_data, $data); } public function save_data(int|array $overwrite_data = -1): void { $data = $this->data ?? []; if ($overwrite_data !== -1) $data = $overwrite_data; Config::setd("import_" . $this->name . "_" . $this->token, $data); } // The resume point is simply the highest source id already recorded for that type. public function last_id($array): int { return $array ? max(array_keys($array)) : 0; } ``` - **resume**: The next chunk starts after the highest mapped source id. - **deduplicate**: The same filter stops a second run importing twice, which is why the map can be cleared. - **resolve foreign keys**: A service row needs the new client id for its old one, and only this map has it. - **saved even when a chunk throws**: The dispatcher catches, saves the map and rethrows, so a retry does not duplicate rows. #### Enrich and Clean - **enrich (default)**: Add rows alongside existing ones. New ids are local; the map remembers the pairing. - **clean**: For an empty target: source ids carry across, so invoice numbers stay recognisable. - **the clean-mode guard**: On the first chunk, throw if the target table already holds a row. - **the guard runs only on chunk one**: Clean mode **and** an empty map. From chunk two on, the target rows are your own. ### Example The skeleton plus one data type, then the wizard side. ```php name, Filter::route($_GET["page"] ?? "index"), [ 'module' => $this, ]); } public function connect($info = []): void { $host = Filter::html_clear($info["db_host"] ?? '') ?: 'localhost'; $user = Filter::html_clear($info["db_username"] ?? ''); $name = Filter::route($info["db_name"] ?? ''); // Pass-through on purpose: every other filter strips the characters that make // a password strong, and the connection then fails for a reason nobody can see. $pass = Filter::password($info["db_password"] ?? ''); if (Validation::isEmpty($user)) throw new Exception(Language::gc("admin/tools/error6")); if (Validation::isEmpty($pass)) throw new Exception(Language::gc("admin/tools/error7")); if (Validation::isEmpty($name)) throw new Exception(Language::gc("admin/tools/error8")); try { $this->db = new Database("mysql", "pdo", $host, 3306, $user, $pass, null, $name, "utf8mb4", "utf8mb4_unicode_ci"); } catch (Exception $e) { throw new Exception(Language::gc("admin/tools/error9", ['{message}' => $e->getMessage()])); } // Half of the progress key: a different source database gets a different run. $this->token = md5($host . "+++" . $user . "+++" . $pass . "+++" . $name); } public function disconnect(): void { $this->db->disconnect(); } public function data_types(): array { return [ 'users' => [ 'name' => Language::gc("admin/tools/import-result-users"), 'description' => $this->lang["users-desc"] ?? '', 'required' => [], ], 'invoices' => [ 'name' => Language::gc("admin/tools/import-result-invoices"), 'description' => $this->lang["invoices-desc"] ?? '', // Invoices carry a client id, and only the users map can translate it. // A type listed here that is not selected too is refused before transfer. 'required' => ['users'], ], ]; } public function pull_data(string $type = ''): array { $table = $type === 'users' ? 'clients' : 'invoices'; $count = $this->db->select("COUNT(id) as total")->from($table)->build(); return [ 'status' => "successful", 'count' => $count ? (int) $this->db->getObject()->total : 0, 'type' => $type, ]; } public function transfer_data(string $type): array { $this->initialize_data(); try { $result = match ($type) { 'users' => $this->users(), 'invoices' => $this->invoices(), default => [], }; } catch (Exception $e) { // Persist before rethrowing: rows already written keep their mapping, // so the retry continues instead of importing them a second time. $this->save_data(); throw $e; } $this->save_data(); return $result; } public function users(): array { $processed = 0; $last_id = $this->last_id($this->data["users"] ?? []); $rows = $this->db->select()->from("clients"); $rows->where("id", ">", $last_id); $rows->order_by("id ASC"); $rows->limit($this->records_per_request); $rows = $rows->build() ? $rows->fetch_assoc() : []; foreach ($rows as $row) { $userId = (int) User::create([ 'type' => "member", 'status' => ($row["status"] ?? '') === 'Active' ? "active" : "passive", 'name' => Filter::html_clear($row["firstname"] ?? ''), 'surname' => Filter::html_clear($row["lastname"] ?? ''), 'full_name' => Filter::html_clear(trim(($row["firstname"] ?? '') . ' ' . ($row["lastname"] ?? ''))), 'email' => $row["email"] ?? '', 'creation_time' => $row["created_at"] ?? null, ]); if (!$userId) continue; // The map entry is the resume point, the duplicate guard and the key the // invoice type will use to find this client again. Write it immediately. $this->data["users"][(int) $row["id"]] = $userId; $processed++; } // Ask the source what is left rather than comparing counts: a row deleted at the // source mid-run would otherwise keep the loop going forever. $last_id = $this->last_id($this->data["users"] ?? []); $done = !$this->db->select("id")->from("clients")->where("id", ">", $last_id)->limit(1)->build(); return ['processed' => $processed, 'done' => $done]; } public function invoices(): array { $processed = 0; $last_id = $this->last_id($this->data["invoices"] ?? []); // Clean mode carries source ids across, so it needs an empty table. The check runs // only on the first chunk: from the second on, the rows found are the ones we wrote. if ($this->import_type === "clean" && $last_id === 0) { if (WDB::select("id")->from("invoices")->limit(1)->build()) throw new Exception(Language::gc("admin/tools/import-type-clean-error")); } $rows = $this->db->select()->from("invoices"); $rows->where("id", ">", $last_id); $rows->order_by("id ASC"); $rows->limit($this->records_per_request); $rows = $rows->build() ? $rows->fetch_assoc() : []; foreach ($rows as $row) { // Foreign keys are resolved through the map, never through the source id. $ownerId = (int) ($this->data["users"][(int) ($row["client_id"] ?? 0)] ?? 0); if (!$ownerId) continue; // owner not in this run; the prerequisite check prevents it $data = [ 'owner_id' => $ownerId, 'total' => (float) ($row["total"] ?? 0), 'status' => ($row["status"] ?? '') === 'Paid' ? "paid" : "unpaid", 'cdate' => $row["created_at"] ?? null, ]; // Only clean mode preserves the source numbering. if ($this->import_type === "clean") $data["id"] = (int) $row["id"]; WDB::insert("invoices", $data); $this->data["invoices"][(int) $row["id"]] = (int) WDB::lastID(); $processed++; } $last_id = $this->last_id($this->data["invoices"] ?? []); $done = !$this->db->select("id")->from("invoices")->where("id", ">", $last_id)->limit(1)->build(); return ['processed' => $processed, 'done' => $done]; } public function initialize_data($predefined_data = []): void { $data = $this->data === null ? (Config::getd("import_" . $this->name . "_" . $this->token) ?: []) : []; if (!is_array($data)) $data = []; $this->data = array_replace_recursive($predefined_data, $data); } public function save_data(int|array $overwrite_data = -1): void { $data = $this->data ?? []; if ($overwrite_data !== -1) $data = $overwrite_data; Config::setd("import_" . $this->name . "_" . $this->token, $data); } public function last_id($array): int { return $array ? max(array_keys($array)) : 0; } } ``` ```html
    ``` ```php // operations/AdminTools.php - how your instance is built and driven, reduced. $load = Modules::Load("Imports", $platform); if (!$load) throw new Exception("The platform is not supported. #1"); $instanceKey = "\\WISECP\\Modules\\Imports\\" . $platform; if (!class_exists($instanceKey)) throw new Exception("The platform is not supported. #2"); $instance = new $instanceKey(); $instance->lang = $load["lang"] ?? []; $instance->area_link = LinkGenerator::admin("tools-1", ["imports"]); $instance->name = $platform; $instance->controller = $this; if (method_exists($instance, "connect")) $instance->connect($platform_info); // Prerequisites are enforced at the select step, before a single row moves. foreach ($data_Types as $selected_type) foreach (($data_types[$selected_type]["required"] ?? []) as $needed) if (!in_array($needed, $data_Types, true)) throw new Exception(Language::gc("admin/tools/import-missing-prerequisite", [ '{type}' => $data_types[$selected_type]["name"] ?? $selected_type, '{needed}' => $data_types[$needed]["name"] ?? $needed, ])); // The transfer step, once per chunk. if (property_exists($instance, "selected_data_types")) $instance->selected_data_types = $data_types_s; if (property_exists($instance, "import_type")) $instance->import_type = $import_type; if (property_exists($instance, "records_per_request")) $instance->records_per_request = $records_per_request; $result = $instance->transfer_data($type); // ['processed' => int, 'done' => bool] if (method_exists($instance, "disconnect")) $instance->disconnect(); ``` ### Pitfalls > **A stale id map makes a rerun import nothing** > > The chunk query skips everything already mapped, so a second run finds no rows and looks like it worked. Clear the map for "start over", never for "resume". > **Declare every prerequisite** > > A type that resolves a foreign key through another type's map must name it in `required`. Leave it out and the map is empty at lookup time: rows land with no owner and nothing throws. > **Filter each credential by its kind** > > One text filter over the whole group mangles the password and the encryption key, and the failure looks like wrong credentials. Use pass-through for secrets. > **Do not compute done by counting** > > Comparing processed rows against the starting total breaks when the source changes mid-migration. Ask what remains past the last mapped id. > **Write through the helpers** > > Clients, orders, services and invoices have side effects raw inserts skip. Import the global classes explicitly: inside the module namespace an unqualified helper name does not resolve. ### Related Articles - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Querying with WDB](https://dev.wisecp.com/en/querying-with-wdb) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Operations](https://dev.wisecp.com/en/operations) # Theme Development / Theme Basics ## The Theme Engine https://dev.wisecp.com/en/the-theme-engine A website theme owns the whole public surface: the markup, styling and wording of every page a visitor sees. ### Overview The admin panel and the website are built by different systems. The panel is fixed: plain PHP templates. The website is themed: a directory of views the platform hands its data to. A theme is not a skin: no markup underneath to fall back on. No cart view means no cart, so every theme implements the same list of surfaces. ### Structure #### Template Engines Three engines are supported; a theme commits to one in its manifest. The same data reaches the view either way. - **smarty**: Tag syntax with its own filters and functions, views ending in `.tpl`. - **twig**: The other tag engine, views ending in `.twig`. - **php**: Plain `.php` templates, no tag layer. Full language access, full responsibility for escaping. #### What a Theme Holds A theme directory sits under `templates/website`. The directory is the theme; the manifest is the first file read. ```bash templates/website/{Theme}/ ├── theme.php # the manifest: engine, meta, status, settings schema. Written by the author ├── config.php # the saved setting VALUES. Written at runtime, never by hand ├── hooks.php # the theme's listeners: variables, output filters, routing additions ├── cover.png # the catalogue thumbnail meta['image'] points at ├── layouts/ # page shells a view extends: default, auth, checkout, invoice ├── partials/ # the pieces a layout assembles: header, footer, topbar, drawer, popup ├── components/ # markup two or more views share: plan grid, payment methods, panels ├── views/ # the surfaces, grouped by area: account/ auth/ checkout/ content/ page/ products/ ├── tables/ # column presets for the client area list tables ├── assets/ # css, js, images, favicon the theme ships ├── locale/ # the theme's own wording, per language and per view scope └── content/ # operator edits of that wording, written from the panel ``` - **theme.php**: The only required file: engine, catalogue entry, settings schema. Without it the directory never appears in the panel. - **config.php**: The saved values. The platform writes it; a fresh theme ships without one. - **layouts/**: Page shells: public, checkout, invoice, plus an optional sign-in shell. A view extends one and fills its blocks. - **partials/**: Pieces a layout assembles rather than a view: header, footer, topbar, drawer, popup. - **components/**: Markup two or more views share: plan grid, payment methods, dashboard panel. - **views/**: The surfaces, grouped by area. A controller asks for `account/dashboard`; the engine adds directory and extension. - **tables/**: Column presets for the client area lists. A missing preset is not an error: raw columns are shown. - **assets/**: Everything the browser fetches: `css/`, `js/`, `images/`, favicon, libraries. One function addresses them. - **locale/ and content/**: `locale/` holds the author's defaults, per language and view scope; `content/` holds the operator's edits. - **hooks.php**: Optional, included once before the first page. Where a theme reaches the platform without editing a controller. #### How Data Arrives Controllers do the work and hand the result to the view as named variables. A theme asks through a hook rather than querying the database itself. Most variables belong to one surface, catalogued in [Template Variables](https://dev.wisecp.com/en/template-variables). A smaller set is injected on **every** themed view. - **$setting**: Array. Manifest settings keys merged with saved values. A multilang field is already the active language's string. A colour field appears twice: `brand` as hex, `brand_rgb` as `"0, 149, 149"`. - **$ui_lang · $ui_dir**: Strings. Active language key (`en`) and `ltr`/`rtl`. They belong on the `` element; writing either by hand breaks the right to left packs. - **$badress · $sadress · $tadress**: Strings with a trailing slash: installation root, shared `resources/`, this theme's directory. Use `$tadress` only outside `assets/`. - **$template_dir**: String. The theme's **filesystem** path, not a URL. Printing it into markup leaks a server path. - **$cookie_domain**: String, empty unless the installation shares cookies across subdomains. The theme's cookies must carry this scope. - **$demo_mode**: Boolean, true only on a demonstration installation. `$company_name` and `$current_year` are on every client page. Check anything else with `|default:` before printing. ### Reference #### The Manifest: theme.php A plain array, no class. Five top-level keys; only `engine` changes how the theme is built. Every key is in [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy). - **engine**: `'smarty'`, `'twig'` or `'php'`. The only place the engine is declared; it decides the view extension. - **status**: `'ready'` or `'development'`. A development theme can be previewed but not activated. Omitting the key means ready. - **update-url**: Where the version check posts. Empty means never checked. - **meta**: Catalogue entry: `name`, `version`, `author`, `website`, `image`, `description`. Read from here, never from saved values. - **meta['disabled_routes']**: Route keys the theme does not serve. Those addresses answer 404 and leave the sitemap. - **settings**: The settings **schema**, below. The admin form is generated from it; values land in `config.php`. #### The Settings Schema `settings` holds two maps: `groups` is `key => ['label', 'icon']`, `fields` is `key => descriptor`: - **type**: `switch` · `checkbox` · `color` · `number` · `select` · `textarea`. Anything else appears as a text field. - **group**: Key of the group the field belongs to. An undeclared group still appears, ungrouped. - **label · desc · placeholder**: Not literal text: **keys into the theme's own locale file**. An unresolved key prints as itself. - **default**: Value used until the operator saves one, so the views must be able to print it. - **options**: Select only, as `value => label key`. Its presence makes the field a dropdown. - **depends**: Map of `other field => required value`. The row collapses until every condition matches. - **multilang · rows**: Text and textarea only. `multilang` gives one tab per active language and stores a `lang => value` map. `rows` sizes a textarea, default 4. #### The Theme Object ```php public static function active(): self; // the installation's theme, falling back to Basic public static function installed(): array; public static function manifest(string $themeName): array; public function getName(): string; public function engine(): string; public function exists(): bool; public function dir(): string; // filesystem path, trailing separator public function assetUrl(string $path = ''): string; // address of a file under assets/ public function viewExists(string $view): bool; // 'account/dashboard', no extension public function render(string $view, array $data = []): string; public function lang(string $key, array $vars = []): string; public function settingsSchema(): array; // theme.php → settings public function savedConfig(): array; // config.php → the saved values public function setting(string $key): mixed; // saved value, else the schema default public function allSettings(): array; // what the views receive as $setting public function boot(): void; // includes hooks.php, once, before the first render ``` ### Example The two halves of a setting: the schema declares it, a view reads it back under `$setting`. ```php return [ 'engine' => 'smarty', 'meta' => [ 'name' => 'Acme', 'version' => '1.0.0', 'author' => 'Acme Ltd', 'image' => 'cover.png', ], 'settings' => [ 'groups' => [ 'topbar' => ['label' => 'grp_topbar', 'icon' => 'bi-megaphone'], ], 'fields' => [ 'topbar_enabled' => [ 'type' => 'switch', 'group' => 'topbar', 'label' => 'set_topbar_enabled', // a key in locale/{lang}.php 'default' => false, ], 'topbar_text' => [ 'type' => 'textarea', 'group' => 'topbar', 'label' => 'set_topbar_text', 'default' => '', 'rows' => 3, 'multilang' => true, // The row stays collapsed until the switch above is on. 'depends' => ['topbar_enabled' => true], ], ], ], ]; ``` ```smarty {if $setting.topbar_enabled}
    {$setting.topbar_text nofilter}
    {/if} ``` Data a theme needs on every page goes through its own hooks file. The listener gets the template path and the data, and it **must return the data**. ```php // ONE handler per theme: only the last registration's return value survives. Hook::add("filter:template.variables", 1, function ($template, $data) { // Runs on every website page, so anything with a query goes through the cache. $data["footer_groups"] = Cache::remember('website', 'acme_footer_' . Language::selected(), 3600, fn (): array => Products::groups()); return $data; // returning nothing drops every key the platform collected }); // Markup, not data: the layout's hook points take a string and print it as-is. Hook::add("ui:client.head.css", 1, fn () => ''); ``` ### Pitfalls > **A fix belongs in every theme, not only in yours** > > Themes are siblings, not forks: a defect in one is almost always in the others. Apply the correction across the set. > **Form protection is the theme's job to wire, not to invent** > > The protections exist in the platform, but a form only gets them if the theme includes them. > **A template is not a place to keep a secret** > > Both tag engines compile views to plain PHP on disk, literals and conditions included. Keep values and decisions in PHP. > **A theme registers one variables listener, not several** > > Only the last registration's return value survives, so a second one silently discards the first one's data. ### Related Articles - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Your First Theme](https://dev.wisecp.com/en/your-first-theme) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms) - [Multi-Theme Parity](https://dev.wisecp.com/en/multi-theme-parity) ## Theme Anatomy https://dev.wisecp.com/en/theme-anatomy Every key a theme can declare in `theme.php`, and which directory holds what. ### Overview A theme is a directory and a manifest: a plain PHP array returned from `theme.php`. `theme.php` is the **schema**, `config.php` the **values**. An upgrade replaces the first and never touches the second. ### Structure #### The Directories Counts are from the shipped `WStyle` theme. | Directory | Holds | Required | | --- | --- | --- | | `theme.php` | The manifest array | Yes | | `layouts/` | 4 shells: default, auth, checkout, invoice | `default.*` only | | `views/` | 63 surfaces in 6 areas, plus 6 loose files | `home.*` only | | `partials/` | 16 pieces the layouts assemble | No | | `components/` | 33 shared blocks, plus `home/` with 10 sections | No | | `tables/` | 7 column presets, always `.php` | No; raw columns without it | | `assets/` | `css/`, `js/`, `images/`, favicon, libraries | No | | `locale/` | Author defaults, per language and scope | No | | `content/` | Operator edits, written from the panel | No. Created on first save | | `hooks.php` | The theme's listeners, included at boot | No | | `config.php` | Saved setting values | No. Created on first save | `tables/` holds plain PHP even in a Smarty or Twig theme: a preset is not a view. The list component includes it with a `$table` variable in scope. The file name is the preset the controller asked for. #### Inside views/ Views are grouped by area. A controller asks for a path without prefix or extension; the engine adds both. ```bash views/ ├── account/ 25 the client area: dashboard, services, invoices, tickets, settings ├── auth/ 6 sign in, sign up, forgotten password, reset, activate, accept invite ├── checkout/ 14 configure, cart, checkout, pay, order complete ├── content/ 14 knowledge base, news, contact, legal ├── products/ 3 catalog surfaces ├── page/ 1 file based pages: views/page/{slug} answers /{slug} ├── home.tpl the one view apply_theme insists on ├── 404.tpl theme's own not-found surface ├── maintenance.tpl rendered while the site is closed └── domain.tpl the public domain search render("account/dashboard") -> views/account/dashboard.tpl (smarty) -> views/account/dashboard.twig (twig) -> views/account/dashboard.php (php) ``` ### Reference #### Top Level Keys | Key | Type | What it decides | | --- | --- | --- | | `engine` | string | `smarty`, `twig` or `php`. Sets the view extension and the sandbox; missing means `php` | | `meta` | array | The catalogue entry plus the behaviour flags core reads | | `status` | string | `ready` or `development`. Previewable but not activatable. Missing means ready | | `update-url` | string | Where the version check posts. Empty, or no `meta.version`, means never checked | | `settings` | array | The schema: `groups` and `fields`. The admin form is generated from it | #### The meta Block Read by the theme list, the detail panel and the update check. - **name**: Display name; the locale file wins. - **version**: Installed version, sent as `installed-version`. Missing disables the check. - **description**: One sentence for the catalogue card; the locale file wins. - **image**: Card thumbnail, resolved **relative to the theme directory**, not `assets/`. - **author, provider**: Two names for one field; `provider` wins. - **website, providerUrl**: Author's address. Here `website` wins over `providerUrl`. - **commercial, premium, price, period**: Either flag makes the card paid; `price` and `period` label it. - **official, license, support, updates, features**: Detail panel rows; `license` defaults to `Open Source`. - **docsUrl, supportUrl, demoUrl, purchaseUrl, learnMoreUrl**: Detail panel buttons, printed only when set. #### Behaviour Flags in meta Core reads these to decide how it behaves. - **signup_minimal**: Boolean. Set it when the register view asks for the core fields only; requirement settings for the omitted fields are then not enforced. - **dashboard_due_soon_alert**: Boolean. Off, the dashboard reminder strip carries the overdue invoice only. On, also the next due invoice. - **disabled_routes**: Route **keys** the theme does not serve: those addresses answer 404 and leave the sitemap. #### settings.groups Group key to a two key descriptor. Groups only sort the admin form. ```php $settings = [ 'groups' => [ // group key label = a key in the theme's locale file, NOT literal text 'appearance' => ['label' => 'grp_appearance', 'icon' => 'bi-palette'], 'checkout' => ['label' => 'grp_checkout', 'icon' => 'bi-cart3'], ], ]; ``` #### settings.fields Setting key to a descriptor: the POST name, the config key and the name views read. - **type**: One of the six below; anything else is a text field saving a string. - **group**: Key of the group. An undeclared group still appears, ungrouped. - **label, desc, placeholder**: Keys into the theme's own locale file, with an English fallback. - **default**: Used until the operator saves one, and when a submission is invalid. - **options**: Select only, as `value => label key`. Also the allowlist: unknown values fall back to the default. - **depends**: `other field => required value`. The row stays collapsed until every condition matches. A hidden field is still saved. - **multilang**: Text and textarea only. One tab per active language, saved as `lang => value`. - **rows**: Textarea height, default 4. - **html, allowed_tags**: Text and textarea only. Without `html => true` every tag is stripped on save; with it the value is sanitized against `allowed_tags`. - **from_logo**: Color only, an integer index. Groups the field into the brand colour pair and adds the "pick from logo" action. #### Field Types and What They Save | type | Control | Saved value | | --- | --- | --- | | `switch`, `checkbox` | Checkbox, description as label | Real boolean `true` / `false` | | `color` | Swatch plus a typable hex box | `'#rrggbb'`. 3-8 hex digits, else the default | | `number` | Number input | Integer, cast | | `select` | Dropdown built from `options` | The chosen key, or the default | | `textarea` | Textarea, or language tabs with `multilang` | String, or a `lang => string` map | | anything else | Text input, or language tabs with `multilang` | String, or a `lang => string` map | #### Reading the Manifest ```php // The whole manifest of ANY theme, by folder name. Cached per name, empty array when absent. public static function manifest(string $themeName): array; // The running theme. Falls back to Basic when the configured folder is gone. public static function active(): self; public function meta(): array; // manifest['meta'] public function engine(): string; // lowercased, 'php' when unset public function settings(): array; // manifest['settings'] public function settingsSchema(): array; // the SAME array as settings() public function exists(): bool; // manifest is non-empty AND the directory is there // Values. public function savedConfig(): array; // config.php, cached per instance public function setting(string $key): mixed; // saved value, else the field's default, else null public function allSettings(): array; // every schema field, merged, as views receive it public static function is_shipped(?string $name = null): bool; // one of Basic, WStyle, WCOM public static function engineLabel(string $engine): string; // 'smarty' -> 'Smarty', for the panel ``` `allSettings()` is what views get as `$setting`: a multilang field as the active language's string, a colour field twice (`primary_color` plus `primary_color_rgb`). ### Example #### One Field, Declared A promotional strip: a switch that gates a rich text field. ```php return [ 'meta' => [ 'name' => 'Acme', 'version' => '1.0.0', 'author' => 'Acme Ltd', 'website' => 'https://acme.example', 'image' => 'cover.png', // beside theme.php, NOT under assets/ 'description' => '', // locale/en.php wins, so it is left empty here // Behaviour flags: read by core, not by the catalogue. 'signup_minimal' => false, 'dashboard_due_soon_alert' => false, 'disabled_routes' => ['references', 'references_detail'], ], 'update-url' => '', // empty: never checked for updates 'engine' => 'smarty', 'status' => 'ready', 'settings' => [ 'groups' => [ 'topbar' => ['label' => 'grp_topbar', 'icon' => 'bi-megaphone'], ], 'fields' => [ 'topbar_enabled' => [ 'type' => 'switch', 'group' => 'topbar', 'label' => 'set_topbar_enabled', // a key in locale/{lang}.php 'desc' => 'set_topbar_enabled_desc', 'default' => false, ], 'topbar_text' => [ 'type' => 'textarea', 'group' => 'topbar', 'label' => 'set_topbar_text', 'default' => '', 'rows' => 3, 'multilang' => true, // one tab per active language 'html' => true, // otherwise every tag is stripped on save 'depends' => ['topbar_enabled' => true], ], ], ], ]; ``` ```php return [ // The catalogue card reads these two from here, not from meta. 'name' => 'Acme', 'description' => 'A compact storefront theme with a promotional strip.', 'grp_topbar' => 'Promotional Strip', 'set_topbar_enabled' => 'Show the strip', 'set_topbar_enabled_desc' => 'Prints a single line above the header on every public page.', 'set_topbar_text' => 'Strip content', ]; ``` #### The Same Field, Read Back The panel writes the values; the theme reads them. ```php return [ 'topbar_enabled' => true, 'topbar_text' => [ 'en' => 'Launch week 20% off every plan.', 'tr' => 'Lansman haftası tüm planlarda %20 indirim.', ], ]; ``` ```smarty {* $setting.topbar_text is already the active language's string, not the map. *} {if $setting.topbar_enabled}
    {$setting.topbar_text nofilter}
    {/if} ``` ```php // setting() returns the RAW stored value: a multilang field is still the lang => value map here. $strip = Theme::active()->setting('topbar_text'); $lang = Language::selected() ?: 'en'; $text = is_array($strip) ? ($strip[$lang] ?? $strip['en'] ?? '') : (string) $strip; // allSettings() is the resolved form, which is why the templates never do the above. $resolved = Theme::active()->allSettings(); $text = (string) ($resolved['topbar_text'] ?? ''); ``` ### Pitfalls > **config.php is not yours to write** > > Generated on save and left alone by an upgrade. Hand edits survive only until the operator presses save. > **A value the schema does not declare never reaches a view** > > Merged settings are built from the schema fields, not the saved file, so a stray key never reaches the templates. > **Labels are locale keys** > > A settings screen showing `set_topbar_enabled` means the key is missing from the locale file. > **settings() and settingsSchema() are the same array** > > Both return the manifest's settings block. The processed form is allSettings(). > **meta.image is relative to the theme, not to assets/** > > The shipped value is a bare `cover.png` next to the manifest. A leading slash produces a broken card. ### Related Articles - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) - [Your First Theme](https://dev.wisecp.com/en/your-first-theme) - [Theme Settings](https://dev.wisecp.com/en/theme-settings) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) ## Your First Theme https://dev.wisecp.com/en/your-first-theme Four files, one directory and one button: a theme the installation will actually serve. ### Overview A theme is not registered anywhere. Create a directory under `templates/website`, put a manifest in it, and the panel finds it. Two gates decide whether the operator can switch to it: the manifest, and a default layout plus a home view. ### Prerequisites - Write access to `templates/website` and `temp`, where compiled templates land. - An installation you may switch themes on: activation replaces its public site. - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) read once, for the manifest-versus-values split. - `developer` turned on in `coremio/configuration/debug.php`, or a template error comes back as an empty page. ### Structure #### What the Gates Actually Require The theme screen gates nothing: one card per directory, manifest or not. Everything is decided on Activate, and the first failing check is the message. | Requirement | Checked when | If it is not met | | --- | --- | --- | | `theme.php` exists | Activating, and by the card | The card appears with no engine and an Unknown author; activation refuses it | | `status` is not `'development'` | Activating, **first** | Refused with the development message; preview still works | | `layouts/default.*` | Activating, after the status check | Refused with the incomplete package message | | `views/home.*` | Activating, after the status check | Same: the two files are checked as a pair | | `locale/{lang}.php` | Never | Optional; without it labels print as raw keys | The `*` is the extension your `engine` chose. The examples below use Smarty. ### Step by Step #### 1. Create the Directory 1. Create `templates/website/Acme/`. The folder name is the theme's identity. 2. Create `layouts/`, `views/`, `locale/` and `assets/css/` in it. Reload the theme screen. The card shows an empty folder: placeholder cover, folder name, Unknown author. #### 2. Write the Manifest 1. Create `templates/website/Acme/theme.php` returning the array below. 2. Set `engine` deliberately: it decides every view's extension. Leaving it out means plain PHP. 3. Leave `update-url` empty while you build: the theme is then never checked for updates. Reload the screen. The card now carries the name, version, author and engine. Activate is refused: `status` is `'development'`, checked first. #### 3. Add the Page Shell 1. Create `layouts/default.tpl`: the document, and the blocks a view fills. 2. Declare five blocks: `title`, `head`, `content`, `scripts`, `body_end`. The shipped themes use these names. 3. Use `{asset}` for every file and `{lang}` for every string. Half the gate is satisfied; activation stays refused until the second file. #### 4. Add the Home View 1. Create `views/home.tpl`, extending the layout and filling `content`. 2. Print one real value: `{$company_name}` is on every client page. Both halves of the integrity gate are in place; only `status` is left. #### 5. Add the Theme's Own Wording 1. Create `locale/en.php`: a flat map of key to text. 2. Put `name` and `description` in it; the card reads these before the manifest. 3. Add a key for every string the home view prints, referenced with `{lang key='...'}`. The card now shows your name and description. #### 6. Activate It 1. Change `status` to `'ready'` in `theme.php`. While it says `'development'` the button is refused. 2. Press Activate on the Acme card in `{admin}/settings/theme?group=theme`. 3. Open the site root in another tab. The home page is your markup, stored as a single key in `coremio/configuration/theme.php`. ### Reference #### What a View Can Call Both tag engines run in a sandbox with an empty class allowlist. Nine functions are the entire bridge: Smarty named, Twig positional. | Function | Smarty | Twig | Returns | | --- | --- | --- | --- | | `link` | `{link route='x' p1='a' p2='b'}` `{link page='pages/1'}` | `link('x', null, 'a', 'b')` | A client URL for a route key, or a stored page's target | | `lang` | `{lang key='k' foo='bar'}` | `lang('k')` | The theme's translation of `k`. Extra Smarty parameters fill `{foo}`; **Twig takes the key** | | `asset` | `{asset path='css/x.css'}` | `asset('css/x.css')` | URL under the theme's `assets/`, versioned for css and js | | `config` | `{config key='favicon'}` | `config('favicon')` | One value from the **theme** configuration. A key with a slash returns an empty string | | `money` | `{money amount=$v currency=$c}` | `money(v, c)` | The amount with its symbol; currency defaults to the visitor's | | `hook` | `{hook name='ui:client.head.css'}` | `hook('ui:client.head.css')` | Every listener's string return | | `captcha` | `{captcha area='a' tray='t' class='' force=false}` | `captcha('a', 't', '', false)` | The active provider's widget, or empty when captcha is off there | | `csrf` | `{csrf form='key'}` | `csrf('key')` | The hidden token input for that form key | | `content` | `{content var=$page.content}` | `content(page.content)` | Operator authored HTML, printed raw. Template syntax inside it is compiled | The Smarty sandbox also permits twenty plain PHP functions. Twig has no PHP access at all: twelve tags and seventeen filters. Anything else raises a sandbox error. ```php // Smarty: the only PHP functions a view may call count sizeof nl2br number_format htmlspecialchars strip_tags strlen mb_strlen substr mb_substr str_contains str_starts_with str_ends_with ucfirst date time implode explode in_array is_array // Twig: the only tags if for set block with apply autoescape verbatim extends include use embed ``` Escaping is on by default: Smarty unless you append `nofilter`, Twig unless you pipe through `raw`. ### Example The complete theme, four files. ```php return [ 'meta' => [ 'name' => 'Acme', 'version' => '1.0.0', 'author' => 'Acme Ltd', 'website' => 'https://acme.example', 'image' => 'cover.png', // beside this file, not under assets/ ], 'update-url' => '', // empty while you build: no version check at all 'engine' => 'smarty', // decides the extension of EVERY view 'status' => 'development', // preview only; set to 'ready' when it can be activated 'settings' => [ 'groups' => [], 'fields' => [], ], ]; ``` ```smarty {* $ui_lang and $ui_dir arrive on every render; writing them by hand breaks RTL packs. *} {block name=title}{$page_title|default:$company_name}{/block} {* Page specific CSS goes here. Page specific SCRIPTS do not: see below. *} {block name=head}{/block} {hook name='ui:client.head.css'} {hook name='ui:client.body.begin'}
    {block name=content}{/block}
    © {$current_year} {$company_name}
    {* Core scripts first, then the page's own: defer executes in document order. *} {block name=scripts}{/block} {* Modals live here, outside
    , so a fixed overlay is not trapped in its stacking context. *} {block name=body_end}{/block} {hook name='ui:client.body.end'} ``` ```smarty {extends file='layouts/default.tpl'} {block name=title}{lang key='home_title'} | {$company_name}{/block} {block name=head} {/block} {block name=content}

    {lang key='home_headline' brand=$company_name}

    {lang key='home_lead'}

    {lang key='home_cta'}
    {/block} ``` ```php return [ // Read by the theme card in the panel, ahead of meta.name / meta.description. 'name' => 'Acme', 'description' => 'A minimal starter theme.', // The home view's strings. {brand} is filled by the tag: {lang key='home_headline' brand=$company_name} 'home_title' => 'Home', 'home_headline' => 'Everything {brand} runs, in one place.', 'home_lead' => 'Hosting, domains and licences from a single account.', 'home_cta' => 'Sign in', ]; ``` Set `status` to `'ready'` when Activate should work. Until then the theme is previewable but not activatable. ### Pitfalls > **A missing view is a blank page, not an error** > > The platform catches everything a template engine throws and returns an empty string. A typo or an unclosed block produces an empty page. Turn `developer` on to see it. > **Layouts and partials are referenced from the theme root** > > `{extends file='layouts/default.tpl'}`, not a path relative to the view: the template directory is the theme directory. > **The view has nine functions and no classes** > > Every platform class is unreachable from a view. A theme that needs the whole language declares `engine => 'php'`. > **Do not build the second surface by copying the first** > > Markup that appears twice belongs in `components/`, shell pieces in `partials/`. > **A view never queries anything** > > Controllers prepare the data as named variables. When a surface needs more, add a listener in the theme's hooks file. ### Related Articles - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Theme Assets](https://dev.wisecp.com/en/theme-assets) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) ## Theme Assets https://dev.wisecp.com/en/theme-assets Where a theme's stylesheets, scripts, images and fonts live, and why only two of the four get a version query. ### Overview Everything the browser fetches from a theme sits under its `assets/` directory, addressed by one function. Nothing is registered, bundled or compiled: the file is where you put it. Stylesheets and scripts come back with a cache busting query built from the file's modification time. Fonts and images come back clean, deliberately. ### Structure #### Inside assets/ The layout is a convention: the function takes any path under `assets/`. Counts are from the shipped `WStyle` theme. ```bash assets/ ├── css/ 50 stylesheets: default.css, theme.css, then one per surface │ └── libs/ 5 third party bundles: bootstrap-icons, fontawesome, fonts, prism, wcp-table ├── js/ 55 scripts: default.js, money.js, then one per surface │ └── libs/ 6 third party bundles: tom-select, intl-tel-input, jspdf, ... ├── images/ 27 entries, grouped: hero/, logo/, banks/, avatars/, addons/ ├── videos/ anything heavier than an image ├── favicon.svg addressed like any other asset └── component-showcase.html a live catalogue of the theme's own primitives ``` `default.css` and `default.js` load on every page, so every visitor pays for them. A stylesheet named after a surface is linked from that view and by nothing else. `component-showcase.html` is the theme's own component catalogue. Open it in a browser before writing new markup. ### Step by Step #### 1. Put the File in Place 1. Drop the file under `assets/`, in `css/`, `js/` or `images/`. 2. Name a surface file after its surface: `css/balance.css`, `js/balance.js`. 3. Third party bundles go unmodified in `css/libs/` or `js/libs/`, so upgrading one is a directory swap. Nothing watches the directory: the file is reachable but unreferenced. #### 2. Link It from a View 1. Site wide files belong in the layout's head, once. 2. A surface's own stylesheet goes in that view's `{block name=head}`. 3. A surface's own script goes in `{block name=scripts}`, never in `head`. Core scripts print before that block, so a head script runs too early and fails silently. 4. Write the path relative to `assets/`. The function adds the rest. Reload and read the markup. A link ending in `?v=1753974812` is your file, found on disk. A link with no query is the diagnostic below. #### 3. Reference It from PHP 1. In `hooks.php`, or any other PHP, call the same function on the active theme. 2. Inject markup through a layout hook point instead of editing the layout. An optional stylesheet ships without a second head. The injected tag now appears wherever the hook point fires, with the same version query. #### 4. Add a Font 1. Put the `woff2` files and their `@font-face` stylesheet together under `css/libs/fonts/`. 2. Inside that stylesheet, reference the font files **relatively**. Browsers resolve `url()` against the stylesheet, not the page. 3. Link the stylesheet with the normal tag, and preload the font file the first paint needs. 4. If the font already ships with a hashed query in its stylesheet, repeat that exact query on the preload. The network panel shows one request per font file. Two requests mean the preload and the stylesheet disagree. ### Reference #### The Addressing Function ```php public function assetUrl(string $path = ''): string; // $path relative to the theme's assets/ directory. A leading slash is trimmed, // so 'css/default.css' and '/css/default.css' are the same request. // An empty string returns the assets directory itself. // // returns {APP_URI}/templates/website/{Theme}/assets/{$path} // plus ?v={mtime} when the extension is css or js AND the file exists on disk. ``` | Argument | Returned URL | Version query | | --- | --- | --- | | `'css/default.css'` | `.../assets/css/default.css?v=1753974812` | Yes, the file's modification time | | `'js/home.js'` | `.../assets/js/home.js?v=1753902114` | Yes | | `'images/hero/banner.webp'` | `.../assets/images/hero/banner.webp` | No, deliberately | | `'css/libs/fonts/x.woff2'` | `.../assets/css/libs/fonts/x.woff2` | No, deliberately | | `'css/typo.css'` (no such file) | `.../assets/css/typo.css` | None. The URL is still returned, and 404s | | `''` | `.../assets/` | None | A link with no version query says the file is not on disk. The function stats it only to read a modification time. #### Calling It from a View ```html ``` - **{asset path='...'}**: Smarty. One named parameter, and only one. Mistype it and the tag falls back to an empty path: the link points at the bare assets directory instead of failing. - **asset('...')**: Twig. Positional, and present in the sandbox's function allowlist. Same resolution, same string. - **Theme::active()->assetUrl()**: PHP. What both tags call underneath. Use it from `hooks.php` and from any markup built outside a template. - **$tadress**: Every page also receives the theme directory's URL as a plain variable. It is the theme root, not `assets/`, and carries no version query. ### Example #### Site Wide Assets, Once, in the Layout The head of a shipped layout, cut to the asset lines. Order matters: the icon font is preloaded before the stylesheet that declares it. ```html {if $is_client_area}{/if} ``` #### Surface Assets, in the View That Needs Them A real view's two asset blocks. Stylesheets go in `head`, scripts in `scripts`. The library the page script needs is loaded in the same block, above it. ```smarty {extends file='layouts/default.tpl'} {block name=head} {/block} {block name=scripts} {* The library first, then the page script that uses it: same block, document order. *} {/block} {block name=content} {* ... *} {/block} ``` ```php // A hook point takes a string and prints it as is, so build the tag here and // leave the layout alone. Same function, same version query. Hook::add("ui:client.head.css", 1, function (): string { $href = Theme::active()->assetUrl('css/extra.css'); return ''; }); ``` ### Pitfalls > **No version query means the file is not there** > > A URL is versioned only when the function finds the file on disk. A link that comes back as `css/balance.css` with nothing after it is a wrong path, and it is about to 404. > **Fonts and images are unversioned on purpose. Do not "fix" it** > > A font is requested twice: once by your preload tag, once by the `url()` inside the font stylesheet. The function never touches that second one. Version the preload and the two URLs stop matching. The preload is wasted, and a face declared `font-display: optional` misses first paint and keeps the fallback metrics. > **A preload's query must match the stylesheet's query character for character** > > Bundles like the icon font ship their own hashed query inside `src:`. Repeat it exactly on the preload, or the browser downloads the font twice. Read the hash from the bundle's own stylesheet, not from another theme. > **A page script in the head is a script that runs too early** > > Deferred scripts execute in document order. A page script moved into `{block name=head}` runs before the theme's core script. It cannot see the global object, and fails without an error. One exception: a library the core script itself consumes loads in the head, above it. > **A url() inside CSS is resolved against the CSS file** > > The addressing function is for markup. A background image, font file or SVG mask referenced from inside a stylesheet resolves against that stylesheet, never through the function. Keep those references relative and keep the files beside the stylesheet that names them. ### Related Articles - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Your First Theme](https://dev.wisecp.com/en/your-first-theme) - [Theme Performance and Caching](https://dev.wisecp.com/en/theme-performance-and-caching) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) # Theme Development / Theme Surfaces ## Page Surfaces https://dev.wisecp.com/en/page-surfaces Every public and client page resolves to one view file inside your theme. This is the complete map from address to file. ### Overview A controller never names a template file. It names a *view*, a slash-separated path with no extension. The engine turns that into a file inside your theme: `render("account/services")` becomes `views/account/services.tpl` for a Smarty theme and `views/account/services.twig` for a Twig one. The theme decides the extension through its manifest; the controller never learns which engine is running. That single rule is what makes a theme portable. Ship the 69 view files a full theme needs and every surface answers. Ship fewer and the controller answers 404 for the ones you left out, because almost every website controller checks the view first. ### Structure #### The Views Tree Views are grouped by area, never dumped flat into one folder. The counts below are the shipped themes measured file by file. **Basic** and **WStyle** carry 69 views each; **WCOM** carries 110, because it adds a large set of file-based marketing pages. | Folder | Basic / WStyle | What lives there | | --- | --- | --- | | `views/` (root) | 6 | Standalone surfaces with no natural family: home, domain search, the SMS landing page, licence verification, 404, maintenance | | `views/products/` | 3 | Catalog: category page, software detail page, software store | | `views/checkout/` | 14 | Configure, cart, checkout, payment and their shared fragments | | `views/account/` | 25 | The signed-in client area, from the dashboard to sub accounts | | `views/auth/` | 6 | Login, register, password reset, activation, invitation | | `views/content/` | 14 | Editorial surfaces: blog, news, references, knowledge base, CMS pages, contact | | `views/page/` | 1 | File-based pages: a view here publishes a URL with no controller and no route | #### Layouts, Partials and Components Views hold page bodies only. The shell around them lives in three sibling folders, referenced from the theme root, never relative to the view. - **layouts/**: Full documents a view extends. WStyle ships four: `default` (public and client shell), `auth` (split stage and form), `checkout` (focused funnel shell), `invoice` (print-friendly). - **partials/**: Chrome the layout includes, in three sets. Public: `header`, `topbar`, `drawer`, `footer`, `announcements`, `cookie-notice`. Client area: `client-topbar`, `client-sidebar`, `client-footer`. Funnel: `checkout-header`, `checkout-footer`. - **components/**: Reusable blocks a view includes with arguments: plan grids, dashboard panels, payment method lists, configure sub cards. - **tables/**: Plain PHP table presets for the client lists: services, domains, invoices, tickets. The list engine reads them, not a template. ### Reference #### The Resolution API Three methods decide whether a surface exists and how it is produced. All three are on the theme instance returned by `Theme::active()`. ```php public static function active(): self; // True when views/. is a real file. $view is slash separated, no extension. // '' and any path containing '..' return false before the disk is touched. public function viewExists(string $view): bool; // Render one view to a string, independent of request context (cron, mail, PDF). public function render(string $view, array $data = []): string; // Read one theme setting: config.php value, or the schema default from theme.php. public function setting(string $key): mixed; // The manifest's meta block, including the theme flags the core reads. public function meta(): array; // Absolute URL under the theme's assets/ folder. Backs the {asset} template function. public function assetUrl(string $path = ''): string; ``` - **Theme::viewExists()**: The extension comes from the manifest engine: `twig` gives `.twig`, `php` gives `.php`, anything else gives `.tpl`. Website controllers alone call it 41 times, and every one of those guards a page. - **Theme::render()**: The context free entry point. Use it from cron, mail and PDF code; `View::chose("website")` silently skips the theme when `CRON` or `ADMINISTRATOR` is defined. - **View::render()**: The in-request path used by every controller. It adds the address variables, runs `filter:template.variables`, loads the view's content scope and finally filters the finished markup. - **TemplateEngine::render_file()**: The engine dispatcher underneath both. It builds a fresh Smarty or Twig instance per call and prefixes the view with `views/`. #### Root Views | View | Produced by | Address | | --- | --- | --- | | `home` | `controllers/website/index.php` | route `home`, the site root | | `domain` | `controllers/website/domain.php` | route `domain`, `/domain` | | `sms-introduction` | `controllers/website/sms.php` | route `international-sms`, `/international-sms` | | `license-verification` | `controllers/website/license.php` | route `license`, `/license-verify` | | `404` | `Controllers::page_404` | no route: any unmatched address, and every failed view guard | | `maintenance` | `controllers/system/maintenance.php` | no route: every address while maintenance mode is on | #### Catalog Views | View | Produced by | Address | | --- | --- | --- | | `products/category` | `controllers/website/products.php` | routes `products` and `products-2`, `/{category}` and `/{kind}/{category}` | | `products/software` | `controllers/website/softwares.php` | routes `softwares` and `softwares_cat`, `/softwares` and `/softwares/{category}` | | `products/detail` | `controllers/website/product-detail.php` | route `software_detail`, `/software/{slug}` | #### Checkout Views Four of the fourteen are fragments. They carry no layout, and another template pulls them in or an AJAX response returns them. | View | Produced by | Address | | --- | --- | --- | | `checkout/configure` | `controllers/website/configure.php` | routes `configure`, `configure-p`, `/configure/{type}/{id}` | | `checkout/configure-addon` | `configure.php`, `page_addon()` | the addon branch of the same configure address | | `checkout/configure-domain` | `configure.php`, `page_edit_item()` | route `configure-edit`, `/configure/edit/{key}` | | `checkout/cart` | `controllers/website/cart.php` | routes `cart` and `basket`, `/cart` | | `checkout/checkout` | `controllers/website/checkout.php` | route `checkout`, `/checkout` | | `checkout/order-complete` | `checkout.php`, `page_complete()` | route `order-complete`, `/checkout/complete/{id}` | | `checkout/invoice-complete` | `invoices.php`, `page_complete()` | route `invoice-complete`, `/invoices/complete/{id}` | | `checkout/pay` | `controllers/website/pay.php` | route `pay`, `/pay/{id}` | | `checkout/pay-result` | `controllers/website/payment.php` | routes `pay-successful` and `pay-failed` | | `checkout/pay-choices` | operations `ClientCheckout`, `ClientInvoicePay`, `BalanceOps` | fragment: AJAX response, three callers share it | | `checkout/section-account` | `checkout/checkout.tpl` | fragment: included by the checkout body | | `checkout/section-billing` | `checkout/checkout.tpl` | fragment: included by the checkout body | | `checkout/section-payment` | `checkout/checkout.tpl` | fragment: included by the checkout body | | `checkout/section-rail-items` | `checkout/checkout.tpl` | fragment: included by the order summary rail | #### Client Area Views | View | Produced by | Address | | --- | --- | --- | | `account/dashboard-hero` | `controllers/website/dashboard.php` | route `my-account`, `/dashboard`, hero layout variant | | `account/dashboard-standard` | `dashboard.php` | same address, standard layout variant | | `account/settings` | `controllers/website/account.php` | route `info`, `/account/info` | | `account/services` | `services.php`, `page_list()` | route `services`, `/services` | | `account/service-detail` | `services.php`, `page_detail()` | route `service-detail`, `/services/detail/{id}` | | `account/service-transfer-approve` | `services.php`, `page_transfer_approve()` | route `service-transfer-approve`, token in the path | | `account/domains` | `domains.php`, `page_list()` | route `domains`, `/domains` | | `account/domain-detail` | `domains.php`, `page_detail()` | route `domain-detail`, `/domain-detail/{id}` | | `account/invoices` | `invoices.php`, `page_list()` | route `invoices`, `/invoices` | | `account/invoice-detail` | `invoices.php`, `page_detail()` | route `invoice-detail`, `/invoices/detail/{id}` | | `account/subscriptions` | `invoices.php`, `page_subscriptions()` | route `invoice-subscriptions`, `/invoices/subscriptions` | | `account/bulk-pay` | `invoices.php`, `page_bulk_pay()` | route `bulk-pay`, `/invoices/bulk-pay` | | `account/balance` | `controllers/website/balance.php` | route `balance`, `/balance` | | `account/tickets` | `tickets.php`, `page_list()` | route `tickets`, `/tickets` | | `account/ticket-detail` | `tickets.php`, `page_detail()` and `page_guest_view()` | routes `ticket-detail` and `ticket-guest` | | `account/ticket-create` | `tickets.php`, `page_create()` | route `ticket-create`, `/tickets/create` | | `account/ticket-msg` | `tickets.php` | fragment: one reply, returned to the AJAX poller | | `account/sms` | `sms.php`, `page_panel()` | route `sms`, `/sms` | | `account/sub-accounts` | `controllers/website/sub-accounts.php` | route `sub-accounts`, `/sub-accounts` | | `account/api-credentials` | `controllers/website/api.php` | route `api`, `/api-credentials` | | `account/affiliate` | `controllers/website/affiliate.php` | route `affiliate`, `/affiliate` | | `account/reseller-program` | `reseller.php`, `page_program()` | route `reseller`, guest and non reseller view | | `account/reseller` | `reseller.php`, `page_dashboard()` | route `reseller`, the reseller's own panel | | `account/license-transfer-verify` | `verify-license-transfer.php` | route `verify-license-transfer`, token in the path | | `account/access-denied` | `Controllers`, the permission guard | no route: any client page a sub account may not open | #### Auth Views All six go through one private helper on the sign controller. That is why they share a guard, a canonical link and a page title convention. | View | Produced by | Address | | --- | --- | --- | | `auth/login` | `sign.php`, `page_in()` | route `sign-in`, `/login` | | `auth/register` | `sign.php`, `page_up()` | route `sign-up`, `/register` | | `auth/forget-password` | `sign.php`, `page_forget()` | route `sign-forget`, `/forget-password` | | `auth/reset-password` | `sign.php`, `page_reset()` | route `sign-reset`, `/reset-password` | | `auth/activate` | `sign.php`, `page_activate()` | route `account-activation`, `/account-activation` | | `auth/accept-invite` | `sign.php`, `page_invite()` | route `accept-invite`, `/invitation/{token}` | #### Content Views | View | Produced by | Address | | --- | --- | --- | | `content/blog` | `controllers/website/articles.php` | route `articles`, `/articles` | | `content/blog-detail` | `page-detail.php` | route `articles_detail`, a bare slug | | `content/blog-comment-list` | operation `ClientBlogComments` | fragment: the comment list, reloaded over AJAX | | `content/news` | `controllers/website/news.php` | route `news`, `/news` | | `content/news-detail` | `page-detail.php` | route `news_detail`, a bare slug | | `content/references` | `controllers/website/references.php` | route `references`, `/references` | | `content/references-detail` | `page-detail.php` | route `references_detail`, a bare slug | | `content/page-detail` | `page-detail.php` | routes `normal_detail` and `contract_detail`, every CMS page and contract | | `content/knowledgebase` | `knowledgebase.php` | route `kbase`, `/knowledgebase` | | `content/knowledgebase-category` | `knowledgebase.php` | route `kbase_category`, `/knowledgebase/category/{slug}` | | `content/knowledgebase-article` | `knowledgebase.php` and `knowledgebase_detail.php` | route `kbase_detail`, `/knowledgebase/article/{slug}` | | `content/contact` | `controllers/website/contact.php` | route `contact`, `/contact` | | `content/newsletter-unsubscribe` | `controllers/website/newsletter.php` | no route key: the first URL segment resolves to the controller | | `content/addon` | `controllers/website/addon.php` | no route key: `/addon/{ModuleName}`, an addon module's own page | #### File-Based Pages A view under `views/page/` publishes an address on its own. No controller, no route entry, no core edit. A routing filter runs last, after every registered route has failed to match, and hands the request to a generic controller when the file is there. - **views/page/about.tpl**: Published at `/about`. The slug is restricted to `a-z 0-9 / _ -` and any `..` is rejected, checked twice: once in the routing filter, once in the controller. - **filter:routing.match**: The router's last fallback. It never shadows a registered route, so adding a file-based page can never break an existing address. - **locale scope**: The `page/` prefix does not reach the locale: `views/page/about.tpl` reads `locale/{lang}/about.php`, because the scope mirrors the public URL, not the folder. #### What Happens When a View Is Missing Three different things, depending on who asked. Knowing which one you are looking at saves an hour of hunting for a template error that was never raised. | Caller | Behaviour | What you see | | --- | --- | --- | | A controller that guards with `viewExists()` | Returns the 404 page instead | HTTP 404 with your theme's `404` view, no error at all | | A view with no guard | The engine throws, the dispatcher catches it and returns an empty string | A blank page. A warning goes to the log, and in development the message appears inside a preformatted block | | An optional feature view | The feature turns itself off | No error, a different code path runs (see the PDF pitfall below) | ### Example One surface end to end: the controller line that names the view, and the view file that answers it. The two halves are shown together because the view path is the only contract between them. ```php // controllers/website/services.php, page_list() // Guard first: a theme that ships no services list answers 404 rather than a blank page. if (!Theme::active()->viewExists("account/services")) return $this->page_404("website"); $this->addData("page_title", Language::gc("website/services/meta/title")); $this->addData("canonical_link", LinkGenerator::client("services")); $this->addData("meta_robots", "noindex, nofollow"); // Turns on the client shell (sidebar, subnav, notification bell) and marks the active tab. $this->addData("show_client_subnav", true); $this->addData("subnav_active", "services"); // Logo, company name, contract links, currency list, the client chrome data. $this->set_predefined_data("client"); // No extension, no engine: "account/services" is resolved against the ACTIVE theme. return $this->view->chose("website")->render("account/services", $this->data, true); ``` ```smarty {extends file='layouts/default.tpl'} {* Stylesheets and libraries the core JS itself consumes go in head. *} {block name=head} {/block} {* Page scripts go in scripts, AFTER the core bundle, or window.WStyle is not ready yet. *} {block name=scripts} {/block} {block name=content}
    {csrf form='services'}
    {/block} ``` ### Pitfalls > **A missing view is a blank page, not an exception** > > The engine dispatcher catches every throwable and returns an empty string in production. If a surface comes out as nothing at all, look for a view file before you look at your data. In development the caught message appears on the page instead, which is the fastest way to confirm it. > **Background output must not go through the request path** > > `View::chose("website")` only resolves the theme when neither `CRON` nor `ADMINISTRATOR` is defined. From a scheduled task it silently falls back to plain PHP templates. It then fails to find your template file and returns an empty string. Use `Theme::active()->render()` for cron, mail attachments and PDF generation. > **The dashboard is one view or two, and the theme decides** > > The controller first asks for `account/dashboard`. If that file exists the theme has one dashboard and the layout setting is ignored entirely. Only when it is absent does the controller fall back to `account/dashboard-hero` or `account/dashboard-standard`, picked by the theme setting. WCOM ships the single view, Basic and WStyle ship the pair. > **An optional view can switch a whole engine on** > > Invoice PDF generation picks headless Chrome only when a Chrome binary resolves *and* the theme ships `account/invoice-pdf`. None of the three bundled themes ships it, so the built in generator is what actually runs today. Adding that one file changes the output engine for every invoice. > **The maintenance view is a whole document, not a page body** > > It is the one view that does not extend a layout. The site is closed, so no navigation, cart or account chrome may link back into it. It opens with its own doctype, its own head and its own first-paint guard. A theme that ships no maintenance view falls back to the legacy system template, which is not skinned. ### Related Articles - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Catalog and Product Pages](https://dev.wisecp.com/en/catalog-and-product-pages) - [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout) - [The Client Area](https://dev.wisecp.com/en/the-client-area) - [Login and Registration](https://dev.wisecp.com/en/login-and-registration) - [System Pages](https://dev.wisecp.com/en/system-pages) ## Catalog and Product Pages https://dev.wisecp.com/en/catalog-and-product-pages Three views carry the whole public catalog. The hard part is printing a price the JavaScript will not immediately change. ### Overview The catalog is the only part of a theme where the same number is produced twice: the server prints it for the first paint, the browser again on every billing toggle. One cent of disagreement and the page flickers. Amounts arrive converted and promotion resolved, so no pricing logic belongs in a view. ### Structure Three views, one price mechanism. The category page carries two arrangements and picks between them at runtime. Until a category has priced products, every branch below is skipped. | View | Answers | Main data | | --- | --- | --- | | `views/products/category` | Every category address, hub and leaf | `$mode`, `$tabs` or `$cards` plus `$plans`, `$billing_cycles`, `$price_mode` | | `views/products/software` | The store root and each store category | `$products`, `$filters`, `$page_size`, `$total_count` | | `views/products/detail` | One software product's own page | `$product`, `$prices`, `$gallery`, `$included`, `$versions`, `$faq` | - **components/plan-grid.tpl**: Vertical cards. Expects `$plans` and reads `$billing_cycles` from the parent, so pass both. - **components/plan-rows.tpl**: Horizontal rows. Expects `specs` and `chips` on each plan instead of `features`. - **Products::catalog_plans()**: Builds both shapes; the third argument picks one. ### Step by Step #### Handle Both Category Modes Branch on `$mode` first thing inside the content block. 1. **tabs**: every child is a leaf, so the page shows sub family tabs and a grid per pane. Each `$tabs` entry carries its own title, meta, hero background and plans. 2. **drilldown**: the category has sub categories *and* its own plans — `$cards` and `$plans`. 3. Print the billing toggle only when there is something to toggle: `{if $billing_cycles|@count > 1}`. #### Print the Plan, Not the Price Logic 1. Give the card the class `plan-card`, or `plan-row` in the horizontal layout. 2. Add the `data-*` attributes from the contract below. 3. Use `$plan.in_stock` to swap the call to action for a sold out state; do not hide the card. 4. Use `$plan.link` as it comes. #### Print the Price Twice, Identically The server prints so the page is never blank, the script overwrites on load, and both must produce the same string. 1. Print the server value into `{$plan.price_now}`. 2. Print the per month suffix beside it, hidden on a period total: `{if $plan.is_period}d-none{/if}`. 3. Leave the derived figures empty — they are the script's. 4. Put the page context on the wrapper. #### Respect the Category's Layout Choice A category can ask for horizontal rows instead of the grid; the choice arrives as `$layout`. 1. Branch once, at the include: rows or grid, never a half converted card. 2. A grid plan has `features`; a rows plan has `specs` and `chips` and no `features`. 3. Feature text with no `value|label` pairs turns every line into a chip and leaves the specification columns empty. That is data, not a bug. ### Reference #### The Shape of One Plan ```php public static function catalog_plans(int $categoryId, string $type, string $layout = 'grid'): array; // $categoryId the category whose products are wanted // $type the product kind, the same segment the configure address uses // $layout 'grid' or 'rows'; it changes the SHAPE of every returned plan ``` ```php $plan = [ 'id' => 18, // product id 'title' => 'Starter', 'tagline' => 'For a first site', 'popular' => true, // from the product's own options 'in_stock' => true, // '' stock means unlimited, 0 means sold out 'prices' => [ // ONLY the cycles that actually carry a price 'monthly' => 4.90, 'annual' => 49.00, ], 'currency' => 'USD', // display code, for the data-currency attribute 'currency_id' => 2, // display currency id, used by the formatter 'link' => '/configure/hosting/18', 'features' => ['10 GB disk', '1 domain'], ]; // With $layout = 'rows' the last key is replaced by two: // 'specs' => [['value' => '10 GB', 'label' => 'Disk'], ...] the comparable columns // 'chips' => ['Free migration', ...] the plain lines ``` #### How the First-Paint Price Is Produced ```php public static function seed_plan_prices(array &$plans, string $cycle, bool $periodMode): void; // $cycle the default billing cycle for this page // $periodMode true prints the full period total, false prints the monthly equivalent // // Adds two keys to every plan: // price_now the formatted string the template prints // is_period whether that string is a period total (drives the "/mo" suffix) // // A plan with no price for $cycle gets price_now = '' rather than a zero. // A plan with no monthly price is forced into period mode even when $periodMode is false. public static function plan_cycles(array $tabs): array; // [['key' => 'monthly', 'label' => 'Monthly'], ['key' => 'annual', 'label' => 'Annually']] // Only cycles that at least one plan on the page actually prices. Fixed display order. public static function price_monthly_equivalent(): bool; // The GLOBAL setting behind $periodMode. It lives in the installation's theme // configuration, NOT in your theme's own settings schema, so a theme cannot force it. ``` #### The data-* Contract Rename one and the price silently stops updating. The script takes each `data-billing` element as a group, then each `.plan-card` and `.plan-row` inside it as a card. - **data-billing**: On the wrapper: the opening cycle from `$default_cycle`, and the group selector; the toggle rewrites it. - **.plan-card and .plan-row**: The card selector: grid uses the first, rows the second. Add classes, do not replace these. - **data-price-mode**: On the wrapper: `monthly` or `period` from `$price_mode`. A page value, not a theme setting. - **data-currency**: On the wrapper and each card; the card wins, so mixed currencies format correctly. - **data-monthly, data-annual, and so on**: On the card: raw decimals, one per cycle, with `|default:''` so an unpriced cycle stays empty rather than zero. - **data-role**: The nodes the script writes into: `price-now`, `price-suffix`, `price-was`, `savings`, `cycle-total`, `cycle-mo`, `currency-label`. - **data-save-text**: On the wrapper: the savings badge label, from the locale because the script has no translator. #### The Billing Toggle Contract The button carries its cycle in `data-cycle`, never `data-billing`: the handler walks up to the nearest `data-billing` ancestor, so a button carrying it becomes its own group. - **data-billing-toggle**: On the button group. Moves the active class between its buttons, so several toggles can coexist. - **data-action="set-billing"**: On each button. The theme's delegated click handler dispatches on it; no listener of your own. - **data-cycle**: On each button: `monthly`, `quarterly`, `semiannually`, `annual`, `biennial` or `triennial`. Print it from `$c.key`, the label from `$c.label`. ### Example ```php // controllers/website/products.php, the drilldown branch $plans = Products::catalog_plans($categoryId, $kind, $layout); // Global setting, inverted: monthly-equivalent ON means period mode OFF. $period_mode = !Theme::price_monthly_equivalent(); // Writes price_now + is_period onto every plan, in the page's default cycle. Products::seed_plan_prices($plans, $default_cycle, $period_mode); $this->addData("mode", "drilldown"); $this->addData("plans", $plans); $this->addData("cards", $cards); $this->addData("layout", $layout); $this->addData("billing_cycles", Products::plan_cycles([['plans' => $plans]])); $this->addData("default_cycle", $default_cycle); $this->addData("price_mode", $period_mode ? "period" : "monthly"); ``` ```smarty {* The section wrapper carries the page-level context the price script reads. *}
    {if $layout == 'rows'} {include file='components/plan-rows.tpl' plans=$plans billing_cycles=$billing_cycles} {else} {include file='components/plan-grid.tpl' plans=$plans billing_cycles=$billing_cycles} {/if}
    {* components/plan-grid.tpl, the price band of one card *}
    {* Filled by the script. Empty on the server on purpose. *} {* Printed by the server so the first paint is never blank, then rewritten with the SAME string by the script on load. *} {$plan.price_now|default:''} {lang key='website/products/category-per-month'} {* One row per cycle; the totals are the script's job. *} {foreach $billing_cycles as $c} {$c.label} {/foreach}
    ``` ### Pitfalls > **One cent of disagreement is a visible flicker** > > `toFixed(2)` rounds the stored double uncorrected, the server formatter rounds half to even, and PHP's round corrects floating point first. All three disagree on values ending in five: 237.905 becomes two strings and the page jumps. The seeding helper pre rounds with `sprintf('%.2f')`; do the same. > **The horizontal layout has two keys behind it** > > The panel writes the choice into `list_template` (2 means flat); the page reads `layout` and expects `rows`. Nothing maps between them, so a flat category still comes out as a grid; the homepage rack reads both. > **Monthly equivalent is not your theme's setting** > > An installation wide option, deliberately kept out of the theme settings schema. Read it through `$price_mode` and add no lookalike switch of your own. > **Pasted embeds can delay every derived figure** > > A category's rich text body goes out raw, so a synchronous third party tag in it blocks parsing. The main price survives; the derived figures wait. > **Stock is live, not cached with the plan** > > Plan lists are cached; the in stock flag is refreshed after. Treat `$plan.in_stock` as current and the rest as up to an hour old, and never cache a fragment mixing the two. ### Related Articles - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) - [Cart and Checkout](https://dev.wisecp.com/en/cart-and-checkout) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Theme Performance and Caching](https://dev.wisecp.com/en/theme-performance-and-caching) - [Theme Settings](https://dev.wisecp.com/en/theme-settings) ## Cart and Checkout https://dev.wisecp.com/en/cart-and-checkout Fourteen views make up the purchase funnel and share one focused shell. The order summary is portalled out of the document at wide sizes. ### Overview Configure, cart and checkout drop the public header and footer for a stripped shell with a step indicator. A visitor mid purchase should not be offered the way out. Two things behave unlike anything else in a theme. The order summary is moved out of its container at desktop widths. The sidebar arrangement is a theme setting, not per page. ### Structure Four addresses, numbered by `$checkout_step`, plus what follows an order. | Step | View | What the visitor does | | --- | --- | --- | | 1 | `checkout/configure` | Cycle, domain, requirements, add-ons | | 1 | `checkout/configure-addon` | An add-on for an owned service | | 1 | `checkout/configure-domain` | Edits a domain line in the cart | | 2 | `checkout/cart` | Reviews lines, applies a coupon, removes items | | 3 | `checkout/checkout` | Account, billing and payment in one page | | 3 | `checkout/pay` | An invoice, outside the cart | | 4 | `checkout/order-complete` | Order confirmation | | 4 | `checkout/invoice-complete` | Directly paid invoice confirmation | | 4 | `checkout/pay-result` | Return from a redirect gateway | The remaining five carry no layout. - **section-account, section-billing, section-payment**: Included by `checkout/checkout.tpl`, one per card. - **section-rail-items**: The summary's line items, separate because the summary appears twice and must match. - **checkout/pay-choices**: Not included, returned as AJAX HTML by three operations. ### Step by Step #### Wire the Funnel Shell 1. Extend `layouts/checkout.tpl`: step indicator, thin footer, shell script, no nav. 2. Do not compute the step in the view; the controller sets it. 3. Put page scripts in `{block name=scripts}`: the core bundle loads before it, the shell after. 4. Put modals in `{block name=body_end}`: inside `
    ` a stacking context breaks a fixed overlay. #### Build the Order Summary Correctly Count the nesting here; indentation in a design file misleads. 1. The aside must be a **direct child** of the split container and a **sibling** of the main column. 2. Give the form an `id` and attach outside submit buttons with the `form` attribute. The stack action bar and the portalled aside both land outside. 3. Build the summary from the shape the cart operations return; two shapes, two totals. #### Offer the Sidebar Variants 1. Declare `checkout_sidebar` as a select: rail, card, stack. 2. Stamp the choice on the root element from the layout. Rail is the default, with no class. 3. The shell script reads that class and skips the portal in card and stack mode. #### Host the Payment Pane 1. Give the payment section an empty pane the operation fills with returned HTML. 2. A gateway may return nothing embeddable: the response reports a fallback and your footer button takes the visitor to the pay page. 3. Never build your own gateway markup — the shared partial turns a redirect into one button. ### Reference #### Funnel Variables Only two names are shared by the funnel. `$cart_items` in the checkout body prints and reports nothing. - **$checkout_step**: On all four: 1 configure, 2 cart, 3 checkout or pay, 4 complete. Read by the header partial. - **$checkout_legal**: On configure, cart and checkout: purchase time contracts, flagged per page. Not the footer links. | Cart page | Checkout page | Holds | | --- | --- | --- | | `$cart_items` | `$checkout_items` | The lines, priced and formatted | | `$cart_summary` | `$checkout_summary` | Discount groups, stacked taxes and savings | | `$cart_subtotal`, `$cart_total` | read from the summary | Figures printed outside the summary | | `$cart_count` | `$cart_count` | Recomputed from the lines; overrides the badge | | `$coupon_enabled`, `$cart_has_unconfigured` | not set | Cart only: coupons on, and unconfigured lines | | not set | `$payment_methods`, `$payment_default`, `$payment_locked` | Checkout only: gateways, preselection, no-choice flag | | not set | `$is_member`, `$billing_profiles`, `$countries` | Checkout only: account and billing cards | - **$domain_section**: Configure only. Always carries `visible` and `mode`. Branch on `visible` first: a suppressed card arrives as `["visible" => false, "mode" => ""]`, not absent. `license` mode adds `need_domain`, `need_ip`, `can_change`; `chooser` adds `tabs`, `tab_count`, `default_tab`, `subdomains`, `nameservers`, `check_url`, `free_json`. #### Functions the Funnel Needs ```php // {csrf form=''} prints a hidden token input, scoped by key. // $input = false returns the bare token instead of an input element. public static function get_csrf_token($form_index = '', $input = true); // {money amount=$x currency=$cid} formats one amount. // $currency defaults to the visitor's selected currency when omitted. public static function formatter_symbol($amount = 0, $currency = 0, $exchange = false, $info = false): string; // {link route='cart'} and {link route='configure' p1=$type p2=$id} // p1..p5 are COLLECTED in numeric order into one list, not indexed into it: // skipping p1 does not leave a hole, it moves p2 into the first slot, so a link // built with p2 alone silently addresses the wrong segment. public static function client($route = '', $params = [], $lang = ''); // {captcha area='' tray='' class='mb-3' force=true} renders the active // provider, or '' when the operator has not enabled captcha for that form area. // $opts accepts exactly three keys, and the template function passes only these: // tray id of a collapse wrapper; the slot is rendered closed inside it // class wrapper classes; defaults to 'mt-2' with a tray and 'mb-3' without // force render unconditionally and visibly, ignoring the per-area toggle public static function widget(string $area = '', array $opts = []): string; ``` #### Gateway Pane Modes | Mode | What the gateway returned | What reaches your pane | | --- | --- | --- | | `html` | Its own markup, a card form or hosted fields | That markup, embedded as is | | `choices` | Options, say instalments or bank accounts | The shared pay-choices partial | | `redirect` | A single destination address | The same partial with one choice: one button | | `none` | Nothing embeddable, a legacy full page form | Nothing; a fallback is reported, the footer button takes over | Your script sees two of these. The first three arrive as `mode: "html"` with an `html` string, the fourth as `mode: "fallback"` with no markup. #### What pay-choices Receives Built from an explicit two key payload: layout, cart and account variables are out of scope. ```php $this->view->chose("website")->render("checkout/pay-choices", [ // One entry per button. A redirect gateway arrives as a list of exactly one. 'pay_choices' => [ [ 'url' => 'https://gateway.example.com/session/abc', 'label' => 'Pay now', // the module's own button label 'image' => '', // when set, print the image INSTEAD of the label ], ], // Optional heading above the buttons. Print nothing when it is empty. 'pay_choices_note' => '', ], true); // The pay page (views/checkout/pay) sets the same two names itself, plus // pay_mode, pay_html, pay_total_fmt, pay_stored_cards, pay_can_store, // pay_can_autopay, pay_has_installments, pay_capture_url and pay_error. ``` ### Example ```smarty {extends file='layouts/checkout.tpl'} {block name=scripts} {/block} {block name=content}
    {* The form WRAPS the split, aside included. Give it an id even so: the action bar below sits outside it and needs the explicit association. *}
    {* Depth matters: the aside is a CHILD of checkout-split and a SIBLING of checkout-split-main. One level deeper it collapses under the content in card and stack mode, where the script does not portal it away. *}
    {csrf form='checkout'} {include file='views/checkout/section-account.tpl'} {include file='views/checkout/section-billing.tpl'} {include file='views/checkout/section-payment.tpl'}
    {* On >=lg the shell script MOVES this node to . Anything inside it that must submit needs form="checkout-form" from that moment on. *}
    {* Stack-mode action bar: OUTSIDE the form on purpose, so form="" is the only thing that makes it submit. Same rule the portalled aside falls under. *}
    {$checkout_summary.total_fmt}
    {/block} {* Modals live OUTSIDE main: the content block is wrapped in a stacking context that breaks a fixed-position backdrop. *} {block name=body_end} {/block} ``` ```php // templates/website/{Theme}/theme.php returns the whole manifest. return [ 'meta' => ['name' => 'Acme', 'version' => '1.0.0', 'author' => 'Acme'], 'engine' => 'smarty', 'status' => 'ready', 'settings' => [ 'groups' => [ 'checkout' => ['label' => 'grp_checkout', 'icon' => 'bi-cart3'], ], 'fields' => [ // Read by layouts/checkout.tpl, which stamps a class on the root element. // It is a THEME setting, not a per-page one: configure, cart and checkout // all change together, which is what keeps the funnel visually coherent. 'checkout_sidebar' => [ 'type' => 'select', 'group' => 'checkout', 'label' => 'set_checkout_sidebar', 'desc' => 'set_checkout_sidebar_desc', 'options' => [ 'rail' => 'opt_checkout_rail', // portalled full-height rail (default) 'card' => 'opt_checkout_card', // plain card beside the content 'stack' => 'opt_checkout_stack', // stacked under the content ], 'default' => 'rail', ], ], ], ]; ``` ### Pitfalls > **A misplaced aside is invisible in rail mode** > > The script moves the summary to the body, so its position never shows. In card or stack the same markup drops it under the content. > **A delegated handler will not see a modal button** > > Modals sit outside the page root your script scopes to, so a handler checking that container silently misses them. Allow the closest modal too. > **Use the theme's collapse, not the framework's** > > The framework collapse snaps in this shell, so the shipped themes use their own everywhere, configure's domain and nameserver panels included. > **Collecting a field is not persisting it** > > Configure validates field groups the collect chain reads, not the template. An input with a plausible name arrives nowhere; follow each new field to its operation. > **The domain card reuses the public search endpoint** > > Configure has no availability endpoint: it posts to the domain controller's check operation with the public search's token form key. ### Related Articles - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) - [Catalog and Product Pages](https://dev.wisecp.com/en/catalog-and-product-pages) - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms) - [Theme Settings](https://dev.wisecp.com/en/theme-settings) - [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway) ## The Client Area https://dev.wisecp.com/en/the-client-area Twenty five views share one shell that a single controller flag turns on. Every one can appear for an account other than the person logged in. ### Overview This is the only family where the same page answers two questions: what is on it, and whose it is. A member can act on another account, so a list may belong to someone else. A tab may be missing because a permission was withheld, not because the data is empty. The views extend the public layout, and a flag turns it into the client shell. ### Structure The twenty five views group into six jobs. | Group | Views | Notes | | --- | --- | --- | | Dashboard | `dashboard-hero`, `dashboard-standard` | Two variants of one address, or a single `dashboard` view | | Assets | `services`, `service-detail`, `domains`, `domain-detail`, `service-transfer-approve`, `license-transfer-verify` | Lists use a preset; detail pages are multi tab and deep linkable | | Money | `invoices`, `invoice-detail`, `subscriptions`, `bulk-pay`, `balance` | Two extend the invoice layout | | Support | `tickets`, `ticket-detail`, `ticket-create`, `ticket-msg` | `ticket-msg` is a fragment: one reply for the poller | | Account | `settings`, `sub-accounts`, `api-credentials`, `sms` | Avatar menu pages; they mark no tab | | Programs | `affiliate`, `reseller`, `reseller-program`, `access-denied` | One reseller address, two views | ### Step by Step #### Turn On the Client Shell 1. Compute the flag once at the top of the layout: signed in *and* sub navigation requested. 2. Use it to choose the chrome: client topbar, sidebar and footer, no marketing bands. 3. Mark the active tab from `$subnav_active`. Controllers fix its values: `dashboard`, `services`, `domains`, `billing`, `support`, `sms`. Avatar menu pages carry an empty string. 4. Do not invent tab names: an unrecognised value shows no active tab, silently. #### Build for the Right Account 1. Never print the login identity as the owner: one is who signed in, the other is whose data is on screen. 2. Expect a panel to be absent rather than empty; a withheld permission means the query never ran. 3. Keep the account switcher visible; a member acting for another needs a way back. #### Build a List Surface The four big lists print rows twice: on the server for the first page, through the table engine after. 1. Put the row markup in the theme's table preset, under `tables/`. 2. Have the view's loop print the same markup, so first paint and an AJAX page are byte identical. 3. Read page values from the table options, not globals: the preset sees no controller data. #### Make Detail Tabs Linkable 1. Give each tab button a stable key attribute, separate from the pane id, so the page does not jump. 2. On load, open the tab named in the query string; it must override the remembered tab. 3. When a tab is shown, write its key back and drop parameters from the tab you left. 4. For a lazily loaded tab, check on load and again on the next tick: the remembered tab is restored at two moments. ### Reference #### Shell Variables - **$show_client_subnav**: Turns the default layout into the client shell. Set true **before** the predefined client data. - **$subnav_active**: The current sub navigation tab; empty string is deliberate. - **$notification_count**: The bell badge, always set even at zero, with a preformatted `$notification_count_text`. Core chrome. - **$account_info**: The identity block, always the **signed-in member**. Keys: `name`, `surname`, `full_name`, `email`, `avatar`, `initials`, `balance` (formatted), `support_pin`, `two_factor`, `is_reseller`, `dealership`, `last_login`, `last_login_date`, `last_login_ip`, `last_login_country`, `last_login_city`. Fall back avatar → initials → icon. - **$account_info.last_login_date**: Empty when the account has never signed in. Formatting an absent date stamps the current time and presents now as the last sign in — print a dash. - **$currency_formats_json and siblings**: Sample strings, currency ids and rates, so the money script matches the server. #### The Account Context API ```php // Null when there is no member session. Never mutates the login identity. public static function activeAccount(): ?array; // Own account is always allowed; a switched account only when the permission was granted. public static function accountCan(string $permission): bool; // Only a still-valid membership (or self) is accepted; a client-supplied id is never trusted. public static function switchAccount(int $ownerId): bool; // The bell count for the signed-in member. 0 means "no member" as well as "nothing unread". public static function notification_count(int $user_id = 0): int; ``` ```php $ctx = [ 'login_id' => 41, // who authenticated; NEVER changes while switched 'owner_id' => 88, // whose data this page shows 'is_self' => false, // true when the two ids are the same 'permissions' => ['view_services', 'view_invoices'], // re-read every request ]; // The switched account is re-validated against the membership table on EVERY request, // so a revoked membership loses access immediately rather than at the next login. ``` #### Dashboard Data | Variable | Holds | Behaviour when unpermitted | | --- | --- | --- | | `$dash_services`, `$dash_domains`, `$dash_invoices`, `$dash_tickets` | The panel lists | Empty; the query never runs | | `$dash_stats` | The figure strip | Built from permitted reads | | `$dash_alerts` | Attention items, through a filter hook modules extend | Fewer entries, never missing | | `$dash_health`, `$dash_feed`, `$dash_balance` | Health line, feed, balance | Present and empty — "not yours to see", not zero | | `$dash_greeting`, `$dash_first_name`, `$dash_today` | The welcome line; names the signed-in member | Always present | | `$dash_l10n` | Strings the dashboard scripts label with | Always present | | `$show_activity` | Whether the activity feed may appear | False when switched: activity is personal | #### Client Area Theme Flags - **dashboard_layout**: A settings field, `hero` or `standard`, selecting `views/account/dashboard-{value}`. Ignored when a single `account/dashboard` ships. - **meta.dashboard_due_soon_alert**: A manifest flag, not a setting. False alerts only on overdue invoices; true also on the next. - **meta.disabled_routes**: Route keys your theme does not serve, answered with 404 rather than a half finished inherited view. Templates see the list too. ### Example ```smarty {* Computed ONCE, at the very top, before the doctype. Both halves are required: a signed-in visitor on a marketing page must still get the public chrome. *} {$is_client_area = $is_logged_in && !empty($show_client_subnav)} {* Shell-only stylesheet: a public page never pays for it. *} {if $is_client_area}{/if} {block name=head}{/block} {hook name='ui:client.head.css'} {hook name='ui:client.body.begin'} {if $is_client_area} {include file='partials/client-topbar.tpl'} {include file='partials/client-sidebar.tpl'} {else} {include file='partials/header.tpl'} {/if}
    {block name=content}{/block}
    {* Marketing bands belong to the public site only. *} {if !$is_client_area}{block name=bands}{/block}{/if} {if $is_client_area} {include file='partials/client-footer.tpl'} {else} {include file='partials/footer.tpl'} {/if} {block name=body_end}{/block} {hook name='ui:client.body.end'} ``` ```php // The identity is never the owner. Resolve both, then ask about permissions. $ctx = UserManager::activeAccount(); $uid = (int) $ctx["owner_id"]; $self = (bool) $ctx["is_self"]; // Own account bypasses the gate entirely; a switched account is checked per area. $can = fn (string $p): bool => $self || in_array($p, $ctx["permissions"], true); if (!Theme::active()->viewExists("account/services")) return $this->page_404("website"); // MUST come before set_predefined_data("client"): the notification count is read // behind this flag, and a controller that sets it afterwards gets a zero badge. $this->addData("show_client_subnav", true); $this->addData("subnav_active", "services"); // An unpermitted area contributes nothing rather than an empty-looking panel. $this->addData("service_list", $can("view_services") ? $this->model->services($uid) : []); $this->set_predefined_data("client"); return $this->view->chose("website")->render("account/services", $this->data, true); ``` ### Pitfalls > **Set the shell flag before the client data, not after** > > The notification count is computed behind `show_client_subnav`. Set the flag afterwards and the badge is always zero. > **Hiding a link is not access control** > > Omitting a tab for an unpermitted area is presentation. The boundary is the guard behind the access denied view, which runs regardless. > **A table preset may be included twice** > > Declare its helpers as variables holding closures: a named function fatals on the second include, which an AJAX page request returning a summary produces. > **Set globals unconditionally, hide them conditionally** > > A counter the shell reads is always assigned, even at zero. A variable set inside an `if` is undefined wherever the branch is skipped. > **Localise the cancel button too** > > The confirmation dialog defaults its cancel label to English, so passing only the action label leaves a stray English word in other languages. ### Related Articles - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) - [Login and Registration](https://dev.wisecp.com/en/login-and-registration) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) - [Interface Components](https://dev.wisecp.com/en/interface-components) ## Login and Registration https://dev.wisecp.com/en/login-and-registration Six views, one split layout, one step machine. The auth surface is built once, then rearranged in the browser. ### Overview The login page is one document holding seven states. Nothing reloads, so every string, field and consent must be present in the markup the server produced. Registration is the opposite problem. Almost every field is optional, so the form is a set of conditionals. ### Structure All six go through one private helper on the sign controller. They share a guard, a canonical link and a title convention. | View | Guest only | Notes | | --- | --- | --- | | `auth/login` | Yes | Seven states, one document | | `auth/register` | Yes, unless a verification is pending | Countries, custom fields, contracts, gating | | `auth/forget-password` | Yes | Always reports success, existing address or not | | `auth/reset-password` | Yes | The token resolves first: the form, or an expired notice | | `auth/activate` | Yes | The email confirmation landing page | | `auth/accept-invite` | No | Guests and members; the state says which of four cases | - **stage_title, stage_text, stage_foot**: The left column. The foot holds the cross link, wrapped in the matching switch. - **split_class, inner_class**: Shape modifiers for a wider form; register uses both. - **layouts/auth.tpl**: Carries head, scripts, body_class, content, body_end, plus the five above. ### Step by Step #### Build the Login State Machine 1. Print every state in one document, one visible and the rest hidden. Each carries a step attribute: `login`, `password`, `totp`, `sms`, `email`, `code-login`, `restricted`. 2. Gate the machine behind the login switch, with a notice in the other branch. 3. Put the passwordless call to action before the social buttons, each behind its own switch and divider. 4. Use one shared one time code component for all four code states. It binds to the input class, not the state, so a second one gets no paste or auto verify. #### Gate the Registration Fields 1. Read visibility from the gating map, never from a configuration key. 2. Mark required fields with a data attribute, not the native one alone, so the validator skips hidden ones. 3. Print custom fields and contracts from the arrays given; neither has a fixed set. 4. The server is the authority — every browser check is repeated on submit. #### Decide Whether Your Form Is Minimal 1. Set the manifest flag true **only** when your register view prints none of phone, landline and national id. 2. Leave it false when you print them behind the visibility switches, as two of three shipped themes do. 3. It is read from the manifest, not the request, so a submission cannot claim it. #### Wrap Every Auth Link in Its Switch 1. Wrap a sign up link in the registration switch, a sign in link in the login switch. The account menu and cart use the combined one. 2. Do not rely on hiding — the server refuses a closed registration or login itself. ### Reference #### Auth Core Signatures ```php // The single entry point for creating a member. See the input keys below. public static function register(array $input): array; // Password login. $type is 'member' or 'admin'; the member master switch is checked here. public static function attemptLogin(string $type, string $email, string $password, bool $remember = false): array; // The ONE place a session is established, password or not. Every passwordless flow // (code login, social, post-registration, email verification) ends here. public static function completeLogin(string $type, int $userId, object $user, bool $remember = false, array $sso = []): string; // Passwordless: issue a one-time code, then exchange it for a session. // issueLoginCode reports success whether or not the address exists. public static function jetpassEnabled(): bool; public static function issueLoginCode(string $email): array; public static function jetpassLogin(string $email, string $code, bool $remember = false): array; // Password reset. issueReset is also enumeration-safe. public static function issueReset(string $type, string $email): array; public static function verifyResetToken(string $rawKey, string $type): object|false; public static function resetPassword(string $type, int $userId, string $password): void; // Providers to offer, for a given mode and audience. public static function activeProviders(string $mode, string $context): array; ``` #### What register() Accepts ```php $input = [ // Identity 'first_name' => 'Ada', 'last_name' => 'Lovelace', 'email' => 'ada@example.com', 'password' => 'read with the pass-through filter, never a text filter', // Account type and the corporate fields it unlocks 'account_type' => 'individual', // or 'corporate' 'company_name' => '', 'tax_number' => '', 'tax_office' => '', // Optional contact fields, each behind its own visibility switch 'phone' => '', 'landline_phone' => '', 'national_id' => '', // Billing address 'address1' => '', 'state' => '', // id when picked from the list, free text otherwise 'city' => '', // same rule as state 'postal_code' => '', 'country' => 0, // resolved to an id before it gets here // Consents 'marketing_email' => 0, 'marketing_sms' => 0, 'contract' => 1, // the terms checkbox 'contracts_required' => true, // whether any contract is actually configured // Operator-defined extra fields, collected by the operation 'custom_fields' => [], // Declared by the THEME MANIFEST, never by the request. True skips the required // checks for phone, landline and national id, and nothing else. 'minimal_fields' => false, ]; ``` #### The Field Gating Map | Template variable | Default | Controls | | --- | --- | --- | | `$registration.account_type_visible` | on | The individual/corporate chooser | | `$registration.company_name_required` | on | Company name, corporate branch | | `$registration.tax_number_required`, `tax_office_required` | off | The corporate tax fields | | `$registration.phone_visible`, `phone_required` | on, on | Mobile number | | `$registration.landline_visible`, `landline_required` | off, off | Landline number | | `$registration.national_id_visible`, `national_id_required` | off, off | National identity, individual branch | | `$registration.password_min_length` | 12 | Enforced on both sides | #### What Each View Receives | View | Variable | Holds | | --- | --- | --- | | `auth/register` | `$countries` | `id`, `a2_iso`, `name`. States and cities cascade over AJAX | | `auth/register` | `$custom_fields` | Operator-defined extra fields, localised. No fixed set | | `auth/register` | `$contracts` | Contract pages for sign up; each carries a `link` | | `auth/register` | `$contract_names` | Those titles joined with commas, for a naming label | | `auth/register` | `$registration` | The gating map above | | `auth/register` | `$verify_email`, `$verify_name` | Filled when `$verify_pending` is true, so the step names the member | | `auth/reset-password` | `$reset_valid` | Whether the token resolved; false means the expired notice | | `auth/reset-password` | `$verify_key` | The raw token, posted back with the new password | | `auth/login` | `$social_providers`, `$jetpass_enabled` | Set by the login page; register sets its own | #### Page and Feature Switches - **$registration_enabled**: Whether to show a standalone sign up link: the master switch minus purchase first mode. It can be off while the cart still registers. - **$login_enabled**: The member login master switch; administrator login is separate. - **$account_actions_enabled**: Whether an account is possible at all: the registration *master*, not the link switch. - **$jetpass_enabled**: The passwordless code flow, off by default. Gate its call to action. - **$social_providers**: The providers for this page; empty means no divider and no buttons. - **$verify_pending**: Set when a signed-in member has an unverified address; the register view shows the verification step. ### Example ```smarty {extends file='layouts/auth.tpl'} {block name=stage_title}{lang key='auth_stage_login_title'}{/block} {block name=stage_text}{lang key='auth_stage_login_text'}{/block} {* The cross link is gated: offering sign-up on a closed installation is a dead end. *} {block name=stage_foot} {if $registration_enabled} {lang key='auth_stage_login_foot'} {lang key='auth_stage_login_foot_link'} {/if} {/block} {block name=content} {if $login_enabled} {* State 1: email. Visible; every other state ships hidden in the SAME document. *}
    {csrf form='sign-in'} {captcha area='sign-in' tray='login-captcha'}
    {* Passwordless BEFORE social, each behind its own switch and divider. *} {if $jetpass_enabled}
    {lang key='website/sign/or'}
    {/if} {if $social_providers}
    {lang key='website/sign/or'}
    {foreach $social_providers as $p} {$p.label} {/foreach} {/if}
    {* ... *}
    {* All four code states reuse ONE component: the script binds to .otp-input, not to the state, so a second implementation gets none of its behaviour. *}
    {* ... *}
    {else}

    {lang key='website/sign/login-disabled'}

    {/if} {/block} ``` ```php // templates/website/{Theme}/theme.php return [ 'meta' => [ 'name' => 'Acme', 'version' => '1.0.0', 'author' => 'Acme', // TRUE only when the register view renders NONE of phone, landline and // national id. Auth::register then skips the operator's required checks // for exactly those three fields; every other validation still runs. // // FALSE when the view renders them behind $registration.*_visible, which // is what two of the three bundled themes do. // // Setting it true while still printing the fields is the one wrong answer: // the requirement is skipped even though the visitor saw an empty input. 'signup_minimal' => false, ], 'engine' => 'smarty', 'status' => 'ready', ]; ``` ### Pitfalls > **Never reveal whether an account exists** > > Password reset and the passwordless code report success for an unknown address on purpose. Your script must advance in every case. > **A password is read with the pass-through filter** > > Every text filter strips the characters that make a password strong. The stored value then differs from what was typed, with no error. > **The minimal flag is a claim about your markup** > > It is read from your manifest and trusted, so it must describe what your form prints. Declare it while printing the optional fields and an empty submission passes unreported. > **Every auth form needs its token and captcha area** > > One action name is the token form key, the captcha area and the rate limit bucket at once. A new name opts out of all three. The passwordless request and its verification share the login action. > **A closed login still lets a new registration in** > > Post registration sign in calls the session establishing method directly, not the password path where the switch is checked. ### Related Articles - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) - [The Client Area](https://dev.wisecp.com/en/the-client-area) - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms) - [Writing a Social Login Provider](https://dev.wisecp.com/en/writing-a-social-login-provider) - [Writing a Captcha Module](https://dev.wisecp.com/en/writing-a-captcha-module) ## System Pages https://dev.wisecp.com/en/system-pages Some pages have to appear while the application is broken, blocked or switched off. Exactly two of them can still come from your theme. ### Overview A missing page and a closed site are ordinary, and your design belongs on screen. A fatal error or a blocked address is not: the page builder may be what failed, so those carry their own shell. Your theme owns `404` and `maintenance`. The second is unlike every other view you write: a complete document, not a page body. ### Structure | Surface | Comes from | When it appears | | --- | --- | --- | | Not found | Your theme, `views/404` | Any unmatched address, and every failed view guard | | Maintenance | Your theme, `views/maintenance` | Every address in maintenance mode, unless an administrator is recognised | | Maintenance fallback | Core, `templates/system/maintenance.php` | Only when the active theme ships no maintenance view | | Application error | Core, `templates/system/application-error.php` | A fatal error on a non AJAX request, answered with HTTP 500 | | Blocked address | Core, `templates/system/blocked-ip.php` | The address blocker rejected the request before routing | The three core pages share one shell, so a style fix lands in all three. Each uses its helpers and nothing else. - **templates/system/inc/shell.php**: Required by each page; returns the helper map below. - **lang, dir**: For the root element. Never hardcode a language code. - **e, l**: The escaper and the translator, which falls back to the string you pass. - **head, mark, brand**: The head links, an inline icon by shape, and the product signature. - **mask**: Replaces the administrator directory. Every printed string passes through it. ### Step by Step #### Build the Not Found Page 1. Extend the default layout: this page comes through the normal request path. 2. Offer the two escape routes behind their switches: knowledge base when enabled, support or contact form by ticket system. 3. Never print the address that was not found. It is attacker supplied text. 4. Every failed view guard lands here too — this is what a half installed theme shows. #### Build the Maintenance Document The one view that does not extend a layout. Turn the maintenance switch on, or you cannot see it. 1. Open with your own doctype, root element and head, taking language and direction from the template variables. 2. Copy your first paint guard in verbatim, final fallback value included. 3. Print your skin tokens from the theme settings. 4. Include only the logo, the message and the switcher — no navigation, cart or account menu. 5. Keep the four injection points, so a module can still reach a closed site. #### Touch the Core Pages Correctly 1. Require the shared shell and use its helpers; never copy its style block. 2. Take text from the locale files: the English strings in the PHP are only the translator's fallback. ### Reference #### The Shell Helpers ```php $sys = require __DIR__ . DIRECTORY_SEPARATOR . 'inc' . DIRECTORY_SEPARATOR . 'shell.php'; // Values $sys['lang']; // 'en', 'tr', ... from the language package $sys['dir']; // 'ltr' or 'rtl' $sys['app']; // the installation's base address // Callables $sys['e']($text); // htmlspecialchars, quotes included, UTF-8 $sys['l']($file, $key, $fallback); // system/{file}/{key}, or $fallback when unavailable $sys['head'](); // local font link + the shared stylesheet $sys['mark']($shape); // inline icon: 'alert', 'shield' or 'tools' $sys['brand'](); // product signature + the version file $sys['mask']($text); // administrator directory replaced by a placeholder // Typical use, with the per-page locale file bound once: $e = $sys['e']; $L = static fn (string $k, string $fallback): string => $sys['l']('error', $k, $fallback); ``` #### What the Error Page May Show | Situation | Message | Technical block | | --- | --- | --- | | An engine error: a type or parse failure | Generic: the original text was written for a developer | Development only | | An exception raised on purpose | Shown: the operator needs to read it | Development only | | A fatal caught at shutdown, so no class is known | Generic, as an engine error | Development only | | A source excerpt around the failing line | Not applicable | Development only; readable, unreadable or encoded, and too large handled | | The request data | Not applicable | Development only, passwords and tokens redacted first | #### Theme Side Contract ```php // True when the manifest loaded AND the theme directory is really there. public function exists(): bool; // True when views/. is a real file. The extension follows the manifest engine. public function viewExists(string $view): bool; // Rendering a system surface outside a request (a CLI health check, a probe): // chose("website") would skip the theme, so call the theme directly. public function render(string $view, array $data = []): string; ``` - **the maintenance gate**: The controller asks both, then falls back to the core template. Skip the second and the call returns an empty string: a blank page. - **ui:client.maintenance.body**: Injected before the closing body tag once the page is built, so it reaches theme view and core fallback alike. Return the HTML to add; strings are joined. - **views/404**: Its own four: `$page_title`, `$meta_robots`, and the switches `$support_enabled` and `$kbase_enabled` that pick the escape routes. The full client data package *is* prepared here. #### What the Maintenance View Receives The whole list. The client data package is not prepared: menus, cart, announcements and account variables are absent; reading one shows nothing and logs a warning. - **$ui_lang, $ui_dir**: The language code and `ltr` or `rtl` for your root element. Added by the template engine, so they survive a missing data package. - **$light_logo_link, $dark_logo_link, $company_name**: The two logo variants and the operator's name. Print both and let your stylesheet pick: there is no server side detection. - **$lang_list, $lang_count, $selected_lang_key**: Each entry carries `key`, `name`, `link`, `selected` and `flag-img`. Print the control only when the count is above one. - **$current_year, $setting**: The year for a copyright line, and your theme's settings. `$setting` reaches every themed view, so a closed site keeps operator colours. ### Example ```smarty {* NOT layouts/default.tpl: the site is closed, so no nav, cart or account chrome may link back into it. The language switcher is the only control, and it re-renders THIS page in the chosen language. *} {lang key='system/maintenance/meta'} {* Copy the guard VERBATIM from the main layout. Its final fallback value must match the theme script's, or the page paints in one mode and flips to the other one frame later. The bug only shows on a visitor with no stored value, which is why a developer's own browser never reproduces it. *} {* Operator colours still apply on a closed site. *} {hook name='ui:client.head.css'} {hook name='ui:client.head.js'} {hook name='ui:client.body.begin'}
    {$company_name} {* The ONLY control on the page. *} {if $lang_count > 1} {foreach $lang_list as $l} {$l.name} {/foreach} {/if}

    {lang key='system/maintenance/text'}

    {hook name='ui:client.body.end'} ``` ```php public function main(): void { // Minimal on purpose: a closed page needs no menus, cart or announcements. $this->takeDatas(["language", "website_logos", "company_name", "lang_list"]); $this->addData("current_year", date("Y")); // BOTH checks are required. Without viewExists a theme that ships no // maintenance view falls through to the plain-PHP path, finds no file // and returns an empty string: a blank page with no error anywhere. $theme = \Theme::active(); if ($theme->exists() && $theme->viewExists("maintenance")) $html = $this->view->chose("website")->render("maintenance", $this->data, true); else $html = $this->view->chose("system")->render("maintenance", $this->data, true); // Injected after rendering, so it reaches the theme view and the fallback alike. $injection = implode('', \Hook::run('ui:client.maintenance.body', $this->data)); if ($injection) $html = preg_replace('/<\/body>/i', $injection . '', $html, 1); echo $html; } ``` ### Pitfalls > **The first paint guard must match the theme script** > > Both read the same stored value and need the same final fallback. A mismatch paints one mode and flips a frame later, seen only by a visitor with no stored preference. > **The administrator directory must not reach the markup** > > It reaches the request address, the dumped request data and a retry button's target, so a screenshot leaks it. Mask everything; the retry link stays empty. > **No external request on a core system page** > > They appear when the server is unwell, so a content network buys nothing. Icons are inline, the typeface is local with a system fallback, and there is no framework. > **Hiding the message and hiding the detail are different** > > An engine failure carries developer text, so a generic sentence replaces it. An exception raised on purpose is shown: the operator has to read it. > **Nothing on a system page folds away** > > Every section arrives open: the reader solves the problem here. Centre with automatic margins; centring alignment crops the top edge once content overflows. ### Related Articles - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) - [Theme Assets](https://dev.wisecp.com/en/theme-assets) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) - [Debugging and Logs](https://dev.wisecp.com/en/debugging-and-logs) # Theme Development / Theme Data ## Template Variables https://dev.wisecp.com/en/template-variables Everything a view can print arrives as a named variable. Each is written by one of three layers: the engine, the client page pack, or the theme's hooks file. ### Overview A theme does not query. The platform prepares the data and the view prints what it was given. Knowing which layer wrote a variable tells you where to look when it is missing, and whether a theme may change it. The layers always run in the same order. The engine writes the display environment on every page. Then `set_predefined_data("client")` writes what every client page needs, and the theme's `hooks.php` adds the rest, the only layer the theme owns. ### Structure #### The Three Layers | Layer | Written by | What belongs there | | --- | --- | --- | | Display environment | `View::render()`, on every page including admin | Language, writing direction, asset addresses, theme settings | | Client content | `set_predefined_data("client")`, from every client controller | Branding, menus, currency, cart, session state, legal links | | Theme data | `hooks.php`, through `filter:template.variables` | Anything only this theme needs, derived from core values | Only the theme layer answers with a value, and that value replaces the whole set instead of merging into it. A listener returns the array it was handed, with its own keys added; a return that is not an array leaves the set untouched. Page data sits on top of all three. The controller adds it with `addData()`, and only the page that asked for it gets it. ### Reference #### What the Engine Writes on Every Page Assigned in the themed branch of the view layer, after the hook has run. A listener cannot remove them. The list is narrower on the plain PHP engine. There, `$cookie_domain`, `$demo_mode` and `$demo_themes` are written only on the tag-engine path. `$template_dir` resolves to a path built from the theme's name. A theme on that engine asks the theme object for its directory. - **$template_dir**: Filesystem path of the active theme directory, with a trailing separator. Not an address: use it only to read files. - **$tadress**: Address of the theme directory. Prefer `{asset path='...'}`, which points inside `assets/` and adds a cache-busting stamp to CSS and JS. - **$badress**: Base address of the installation, with a trailing slash. On a bound subdomain this is that host, not the main site. - **$sadress**: Address of the shared resources directory (uploads, flags, plugin assets), owned by no theme. - **$ui_lang**: Active language code, resolved when the page is built, so a late language switch is reflected. - **$ui_dir**: `ltr` or `rtl`, from the language pack. Print it on the html element; do not hardcode a direction. - **$cookie_domain**: The host scope this installation's cookies use, empty on a single-host install. Theme JavaScript writing a cookie uses the same scope. - **$demo_mode · $demo_themes**: Demo chrome only. The theme list is computed only while demo mode is on. - **$setting**: Every field of this theme's settings schema merged with its saved value, from `Theme::allSettings()`. Colour fields also expose a `_rgb` twin. #### What Every Client Page Adds - **$company_name**: Trading name, from the language pack's constants, falling back to the company information block. - **$light_logo_link · $dark_logo_link**: Site logo per colour mode. The client panel and the invoice document carry their own pair (`$client_logo_light_link`, `$invoice_logo_light_link` and their dark twins). Each falls back to the site logo. - **$favicon_link**: Square brand mark, for places a wordmark does not fit (a collapsed rail, an app icon). - **$current_year**: Current year as a string, for the footer copyright line. Do not compute a date in a view. - **$lang_list · $lang_count · $selected_lang_key**: Active languages with a ready link each, their number, and the selected code in upper case. The layout also prints them as alternate language links. - **$currencies · $currencies_count · $selected_currency · $selected_currency_code**: Currencies offered to the visitor (hidden ones filtered out) and the active one. Switching is a server round trip: link to the same page with a `currency` parameter. - **$currency_formats_json · $currency_ids_json · $currency_rates_json · $currency_default**: Ready JSON for the theme's money script. Each format is a sample string the script parses to learn that currency's separators. - **$header_menu · $footer_menu · $mobile_menu**: The three operator-managed menu trees. The mobile tree falls back to the header tree when it is empty. - **$social_links · $contact_email · $contact_phone**: Social profiles as a list, plus the first configured address and number as plain strings, for the header and footer strips. - **$visibility_cart · $cart_count**: Whether the shop is on at all, and the live item count. The count is always set, so a badge can be revealed without a reload. - **$login_enabled · $registration_enabled · $account_actions_enabled**: The operator's account switches. The third is false when neither signing in nor signing up is possible. That hides the account menu and the cart. - **$affiliate_enabled · $reseller_enabled · $only_client_panel**: Program switches for the account menu, and portal-only mode, in which a theme drops its links back to the public website. - **$is_logged_in · $client_id**: The canonical session flag and the signed-in identity. Every "signed in or guest" branch tests this flag. - **$privacy_contract_link · $terms_contract_link · $cookie_contract_link**: Legal pages the operator mapped, empty when unmapped. Hide the link when it is empty instead of printing a dead one. - **$cookie_notice**: The consent prompt as a finished payload, or an empty array when consent is off. Decided server side: the cookies are not readable from the browser. - **$notification_count · $notification_count_text**: Bell counter and its pill text (capped beyond two digits). Always set, but only queried for a page that asked for the client shell. - **$password_min_length · $password_special_chars · $default_country**: The operator's password rules for the suggestion button, and the site's default country as a last-resort fallback for phone and address fields. - **$links · $meta · $breadcrumb · $local_l · $show_powered_by**: Page frame: the controller's link map, page metadata, the trail, the installation's default language, and whether the footer prints the platform credit. #### What a Signed-In Page Adds These exist only while a member is signed in. A public page must not read them without guarding on `$is_logged_in`. - **$account_info · $user_info**: Display identity for the chrome (name, avatar, initials, formatted balance, support PIN, previous sign-in) and the raw account row behind it. - **$announcements**: Operator notices for the account shell, already filtered by language and by the active account's country. - **$show_services · $show_domains · $show_invoices · $show_support · $show_sms**: Section visibility: the granted sub-user permission AND the operator's feature switch. A section the visitor cannot open is never linked. - **$is_self_account · $switch_accounts**: Whether the active account is the member's own, and the accounts they may switch to. - **$client_badges · $client_badges_text**: Attention counters per section as raw numbers, plus their pill text. The raw numbers stay for accessible names, which say the real figure. - **$order_service_link**: Where "buy a service" goes, empty when the sub-user may not order. A theme that disables the catalogue overrides this in its hooks file. - **$dashboard_modal · $twofa_required · $password_required**: The dashboard gate chain. Only one modal opens per load, and the chosen one is named here rather than decided in the view. - **$client_addon_links**: Client-area pages contributed by enabled addon modules, each with a name, an address and an icon. - **$verification_required · $verify_link**: Set while the member owes identity verification. The banner belongs to the layout; the redirect for gated pages already happened before the view ran. #### The Shape of the Composite Values ```php // $lang_list : one row per ACTIVE language, ranked, the installation default first. $lang_list = [ ['rank' => 1, 'local' => 1, 'selected' => true, 'key' => 'en', 'name' => 'English', 'global-name' => 'English', 'link' => 'https://example.com/home', 'cc' => 'gb', 'cname' => 'United Kingdom', 'pc' => '44', 'flag-img' => 'https://example.com/resources/assets/images/flags/gb.svg'], ]; // $currencies : active, non-hidden currency rows. $currencies = [ ['id' => 1, 'code' => 'USD', 'name' => 'US Dollar', 'prefix' => '$', 'suffix' => '', 'rate' => '1.00000000', 'local' => 1, 'hidden' => 0], ]; // $social_links : one entry per configured profile. $social_links = [ ['name' => 'X', 'url' => 'https://x.com/example', 'icon' => 'bi bi-twitter-x'], ]; // $account_info : display identity, already formatted. `balance` is a STRING with its symbol. $account_info = [ 'name' => 'Ada', 'surname' => 'Lovelace', 'full_name' => 'Ada Lovelace', 'email' => 'ada@example.com', 'avatar' => '', 'initials' => 'AL', 'balance' => '$120.00', 'support_pin' => '481625', 'two_factor' => true, 'is_reseller' => false, 'last_login' => [], 'last_login_date' => '02/08/2026 - 11:40', 'last_login_ip' => '203.0.113.9', 'last_login_country' => 'GB', 'last_login_city' => 'London', 'dealership' => [], ]; // $client_badges : raw counts; $client_badges_text holds the same keys as pill strings. $client_badges = ['invoices' => 2, 'support' => 1, 'domains' => 0, 'services' => 0]; // $cookie_notice : empty array when consent is off. $cookie_notice = [ 'needs_prompt' => true, 'text' => 'We use cookies…', 'policy_link' => 'https://example.com/cookie-policy', 'policy_label' => 'Cookie policy', 'accept' => 'Accept', 'reject' => 'Reject', 'prefs' => 'Preferences', 'save' => 'Save', 'prefs_title' => 'Cookie preferences', 'categories' => [ ['key' => 'necessary', 'label' => 'Necessary', 'desc' => '…', 'locked' => true, 'granted' => true], ], 'url' => 'https://example.com/cookie-consent', ]; ``` #### Signatures ```php // Controllers : the page and the pack. public function set_predefined_data(string $type = 'client', array $meta = [], array $breadcrumbs = [], array $links = []): void; public function addData($k = '', $v = ''): void; public function getData($key); // View : the render itself. $return_output = true returns the HTML instead of printing it. public function chose($dir, $noTemplate = false): self; public function render($_name = null, $data = [], $return_output = false, $source = false): mixed; // Theme : the settings map behind $setting, and the context-free render used off-request. public function allSettings(): array; public function render(string $view, array $data = []): string; ``` ### Example A client page and the view that reads it back. The controller adds only what belongs to this page. ```php public function page_overview(&$links, &$meta, &$breadcrumbs): string { // Read INSIDE set_predefined_data, so it has to be set before the call. $this->addData("show_client_subnav", true); $this->set_predefined_data("client", $meta, $breadcrumbs, $links); // Page data on top of the pack. Set unconditionally: a key the view reads has to // exist on every render, empty or not, or the view falls into an undefined key. $this->addData("recent_orders", $this->model->recent_orders((int) $this->getData("client_id"))); echo $this->view->chose("website")->render("account/overview", $this->data, true); return ''; } ``` ```smarty {* Environment layer: direction and language come from the engine, never hardcoded. *}
    {* Client layer: guard on the session flag before touching a signed-in-only value. *} {if $is_logged_in}

    {$account_info.full_name} · {$account_info.balance}

    {if $client_badges.invoices > 0} {$client_badges_text.invoices} {/if} {/if} {* Theme layer: a setting this theme declared, read straight off $setting. *} {if $setting.topbar_enabled}
    {$setting.topbar_text nofilter}
    {/if} {* Page layer: default:[] so an empty page never costs a warning per row. *} {foreach $recent_orders|default:[] as $order} {$order.number} {/foreach}
    ``` ### Pitfalls > **A variable set inside a condition is missing on the pages that skipped it** > > Set every global unconditionally and decide visibility in the view. Absence is not "false": the view falls into an undefined key, and every one costs a log write. Inside a loop that is a measurable slowdown. > **The variables hook replaces the array, it does not merge into it** > > A listener that returns something other than the array it was handed drops every key the platform collected. The layout then breaks on the first missing address. Start from the incoming array, add to it, return all of it. > **A background page gets the environment, not the client pack** > > A view built from a scheduled task or a document generator goes through the theme object directly. No controller ran to fill the pack. Only the environment layer and the theme settings are there; anything else is passed as page data. > **Addresses are built in the view, not passed as variables** > > The controller does not hand out ready links for pages the theme can address itself. Build them with the link function and a route key. A theme that renames a page then needs no controller change. ### Related Articles - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) - [Theme Settings](https://dev.wisecp.com/en/theme-settings) - [Menus and Navigation](https://dev.wisecp.com/en/menus-and-navigation) - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) ## Theme Hooks and Output Filters https://dev.wisecp.com/en/theme-hooks-and-output-filters A theme changes the data it receives and the markup it emits from one file of its own. No controller or core template is edited. ### Overview Every theme may ship a `hooks.php`, included once before its first page is built. It travels and is deleted with the theme. **Data** listeners change what reaches the view: a variable this theme needs on every page, a menu tree, the finished HTML. **Markup** points work the other way: the theme opens them, a module injects into them. ### Structure #### Where It Lives - **templates/website/{Theme}/hooks.php**: Optional. Plain PHP, no class, no return value. Registers listeners and the theme's helper functions. - **Theme::boot()**: Included once per request, before the first page is built. Called by the view layer and the context-free off-request path. - **Scope**: Website requests only. The admin panel and scheduled tasks never resolve a theme. - **Cost**: Every listener runs on every page view. #### The Two Families | Family | The theme is | Mechanism | Return | | --- | --- | --- | --- | | Data (filter) | the listener | `Hook::add()` in the hooks file | Replaced by the return, or changed by reference | | Markup (injection) | the publisher | `{hook name='...'}` in the layout and partials | The listeners' strings are concatenated, printed raw | ### Reference #### The Hook API ```php // $priority: lower runs first; a clash is resolved by bumping, never by dropping a listener. // $properties: a closure, or ['class' => 'X', 'method' => 'y'], or ['class' => 'X', 'method::static' => 'y']. public static function add($name, $priority, $properties = []): void; // By value. Returns one entry per listener; a markup point concatenates them. public static function run($name, ...$args): array; // By reference. EVERY argument is passed by reference, so every one must be a plain variable. public static function runRefs($name, &...$args): array; ``` #### The Variables Filter The single point through which a theme adds a variable to every page. It runs for every template, so a listener wanting only some tests the path. - **filter:template.variables**: Fires in the view layer once the data set is complete, before the template is included. - **($template, $data)**: The full path of the template processed, and the variable array. Neither is by reference. - **Return**: An array REPLACES the data set entirely. Anything else is ignored and the set is left as it was. - **One listener per theme**: Only the LAST returned array survives; everything a theme adds belongs in one body. #### The Output Filter The finished HTML, after the engine built it and before it reaches the browser. A rewrite that would otherwise touch every payload happens here. - **filter:client.page.output**: Fires in the themed branch of the view layer, on both the printed and returned paths. - **(&$output, $view, $engine)**: The whole HTML by reference, the view name that produced it, and the active engine. Changed in place; the return is unused. - **Scope**: Tag engines only. A theme declaring the plain PHP engine prints through include and is never captured. - **Fragments too**: Section fragments answered over AJAX come through the same branch; tell them apart by `$view`. #### Markup Points Written as `{hook name='ui:client.head.css'}` in the layout. The shipped themes open more than a hundred and fifty; those every theme should carry are below. | Point | Where the layout puts it | What belongs there | Return | | --- | --- | --- | --- | | `ui:client.head.css` | Head, end | Stylesheets, style blocks | Return `` or ` ``` ### Pitfalls > **Renaming a key orphans what was already saved** > > The key is the form name, the stored key and the name the view reads. A value under the old name is never read again and never cleaned up. Every installation that had configured the theme falls back to the default. > **The saved file is generated, not source** > > It holds values and nothing else. Name and version stay in the manifest, where the theme listing and the upgrade check read them. Shipping one in a theme package hands your test configuration to everyone who installs it. > **A multilingual value is a map in the file and a string in the view** > > PHP asking for the raw setting gets the whole map. The view gets the active language resolved. Code confusing the two works in exactly one place. > **A collapsed dependent field is still saved and still read** > > The dependency hides the row; it does not disable the value. A view reading a dependent setting must check the field it depends on too. Otherwise it shows something the operator believes is off. ### Related Articles - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) - [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder) # Theme Development / Theme Quality ## Securing Theme Forms https://dev.wisecp.com/en/securing-theme-forms A public form is not protected by the framework. The template prints the guards and the handler enforces them. ### Overview No middleware shields a theme form. You write the pair: the view prints a token and a challenge box, the operation checks them. The form key joins the halves. Five layers stack on one request. Four judge *who submits*, the fifth judges *what was submitted*. ### Prerequisites - A Smarty or Twig theme; `{csrf}` and `{captcha}` exist in both. - An operation to receive the submit ([Operations](https://dev.wisecp.com/en/operations)). - Thresholds are the operator's, in `coremio/configuration/options.php`. Never hard-code one. ### Structure #### The Five Layers | Layer | What it stops | What leaving it out opens | | --- | --- | --- | | **CSRF** | Submits that did not come from your own form. | Any page can post to your endpoint with a signed-in visitor's session. | | **Process restriction** | Repetition from one address, with a timed hard block. | One address can hold the endpoint open indefinitely. | | **Bot shield** | Automation, by demanding a challenge past a threshold. | An automated client never meets a challenge; the form becomes an oracle. | | **Captcha (static)** | Every submit on an area the operator locked. | The closed area stays open, and the panel shows the setting as on. | | **Spam guard** | Banned words, disposable mailboxes, reputation lists. | No content rule runs, so the blocked list stays empty and looks healthy. | Each layer is one call, and that call is the proof. - **Validation::verify_csrf_token()**: The token layer. A false result must end the request. - **ProcessRestriction::blocked()**: The hard block layer. Asks only; `hit()` counts the request at the end. - **BotShield::triggered()**: The adaptive layer. True means this address must answer a challenge. - **Captcha::enabled()**: The static layer. Reads the per-area switch; combined with the previous by OR. - **Validation::spam_guard()**: The content layer. Returns the blocking reason and records the attempt. > **The order is part of the protection** > > Token, hard block, challenge, content, then the work. Reading input earlier lets an attacker reach your parser. #### Which Half Owns Which Piece | Piece | Theme side | Server side | | --- | --- | --- | | Token | `{csrf form='contact-form'}` prints a hidden `token` input. | Verified with the same key. | | Challenge | `{captcha area='contact-form'}` prints the provider's box, or nothing. | Read by the captcha helper; the theme never names it. | | Request header | The fetch call sends `X-Requested-With`. | Required unless the third argument says otherwise. | | Thresholds | Nothing; the theme reads no limit. | Read from the operator's configuration. | ### Walkthrough #### 1. Mark Up the Form 1. Pick one key string and use it everywhere: token key, captcha area, throttle action. 2. Print the token inside the form element. 3. Print the captcha next to the button; it shows nothing when that area is off. ```smarty
    {* Same string as the handler's verify key. Emits . *} {csrf form='contact-form'}
    {* Renders '' when the operator has captcha off for this area, so the row still lays out. *} {captcha area='contact-form'}
    ``` #### 2. Guard the Handler 1. Verify the token before you touch the request. 2. Ask whether this address is hard blocked, and stop if it is. 3. Decide whether a challenge is required: the area is locked *or* the shield tripped. 4. Read and validate the inputs, then run the spam guard on them. 5. Do the work, then close the window: advance the limiter, update the shield counter. ```php // 1. Token. Before anything is read from the request. if (!\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "contact-form")) throw new \Exception(Language::g("needs/csrf-failed")); // 2. Hard block. This address already crossed the limit and is serving a timeout. if (\ProcessRestriction::blocked("contact-form")) throw new \Exception(Language::gc("website/contact/rate-limited")); // 3. Challenge. Operator-locked area OR this address tripped the shield. $needCaptcha = \Captcha::enabled("contact-form") || \BotShield::triggered("contact-form"); if ($needCaptcha && !(new \Captcha())->check()) { \BotShield::record("contact-form"); return $operation->output([ "status" => "captcha_required", "message" => Language::gc("website/contact/captcha-required"), ]); } // 4. Content and sender, after validation, before the write. if (\Validation::spam_guard($full_name, $message, $email, $phone, $ip) !== '') throw new \Exception(Language::g("needs/spam-blocked")); // 5. ... the actual work ... // 6. Close the window. \ProcessRestriction::hit("contact-form"); if ($needCaptcha) \BotShield::clear("contact-form"); else \BotShield::record("contact-form"); ``` #### 3. Send the Request 1. Build the body from the form element; `FormData` carries the token and the answer. 2. Send the AJAX header, or the token check refuses. 3. Handle the third outcome: `captcha_required` is a question, not a refusal. 4. Refresh the challenge in the finally branch. The answer is single use. ```javascript var fd = new FormData(form); // token + captcha answer are named inputs inside the form fetch(endpoint, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'XMLHttpRequest' } }) .then(function (r) { return r.json(); }) .then(function (res) { if (res.status === 'captcha_required') { captchaRequired = true; // remember it; the next empty submit is stopped locally captchaMsg = res.message || captchaMsg; revealCaptcha(); setAlert(captchaMsg, 'warning'); return; } if (res.status !== 'successful') { setAlert(res.message, 'danger'); return; } captchaRequired = false; showDoneStep(res); }) .finally(function () { if (typeof window.wcpCaptchaRefresh === 'function') window.wcpCaptchaRefresh(); }); ``` ### Reference #### Template Functions | Call | What it emits | When it emits nothing | | --- | --- | --- | | `{csrf form=''}` | A hidden `token` input: an HMAC of the key against a per-session secret. | Never. An empty key is still a key, shared by every form using it. | | `{captcha area='' tray=''}` | The provider's box in the theme slot; a tray when `tray` is given. | When captcha is off *and* the shield is not armed. Design the row without it. | In Twig the options have an order: `captcha(area, tray, class, force)` and `csrf(form)`. Skipping one means naming it. #### Helper Signatures Read the argument order carefully: the token functions take the key *second*. ```php // coremio/classes/Validation.php // $input = true returns the ready ; false returns the bare token. public static function get_csrf_token($form_index = '', $input = true); // The KEY IS THE SECOND ARGUMENT. $nonAjax = true drops the X-Requested-With requirement. public static function verify_csrf_token($incoming_data = '', $form_index = '', $nonAjax = false); // '' means clean. A non-empty string is the operator-facing reason, already written to the blocked list. public static function spam_guard(string $subject = '', string $message = '', string $email = '', string $phone = '', string $ip = '', string $domain = ''): string; ``` ```php // coremio/helpers/processrestriction.php // $ip = null resolves the caller's address itself, proxy and CDN aware. Pass one only when // you are judging an address other than the current visitor's. public static function blocked(string $action, ?string $ip = null): bool; // serving a timeout right now public static function hit(string $action, ?string $ip = null): bool; // count one; true = now blocked public static function clear(string $action, ?string $ip = null): void; // reset counter and block ``` ```php // coremio/helpers/botshield.php public static function active(string $action): bool; // armed for this action at all public static function triggered(string $action, ?string $ip = null): bool; // this address needs a challenge public static function record(string $action, ?string $ip = null): void; // count one uncontested attempt public static function clear(string $action, ?string $ip = null): void; // a challenge was solved ``` ```php // coremio/helpers/captcha.php public static function enabled(string $area = ''): bool; // operator switched this area on public static function widget(string $area = '', array $opts = []): string; // what {captcha} calls public function check(): bool; // instance method: (new Captcha())->check() ``` #### Widget Options - **tray**: The id of a collapse tray to hold the box. Sanitised to letters, digits, underscore, hyphen. - **class**: Utility classes for the slot wrapper. - **force**: Always visible, ignoring the per-area switch and the shield. A decision, not a default. #### The Three Response Shapes - **successful**: The work happened; swap the form for a confirmation step. - **captcha_required**: The server is asking, not refusing. Reveal the box and keep the typed values. - **error**: A thrown exception as JSON. Already translated; the spam reason is not in it. ### Example The contact form, both halves. The key `contact-form` repeats in view and handler, so one grep proves the pair. ```php public function submit(Operation $operation): bool { $operation->demo(); if (!\Validation::verify_csrf_token((string) Filter::init("POST/token", "hclear"), "contact-form")) throw new \Exception(Language::g("needs/csrf-failed")); if (\ProcessRestriction::blocked("contact-form")) throw new \Exception(Language::gc("website/contact/rate-limited")); $needCaptcha = \Captcha::enabled("contact-form") || \BotShield::triggered("contact-form"); if ($needCaptcha && !(new \Captcha())->check()) { \BotShield::record("contact-form"); return $operation->output([ "status" => "captcha_required", "message" => Language::gc("website/contact/captcha-required"), ]); } $full_name = trim((string) Filter::init("POST/name", "hclear")); $email = trim((string) Filter::init("POST/email", "email")); $phone = trim((string) Filter::init("POST/phone", "numbers")); $message = trim((string) Filter::init("POST/message", "hclear")); $ip = \UserManager::GetIP(); if (\Validation::isEmpty($full_name)) throw new \Exception(Language::gc("website/contact/error-name")); if (\Validation::isEmpty($email) || !\Validation::isEmail($email)) throw new \Exception(Language::gc("website/contact/error-email")); if (\Validation::isEmpty($message) || mb_strlen($message) < 5) throw new \Exception(Language::gc("website/contact/error-message")); // Content rules run on the values that are about to be stored, never on the raw request. if (\Validation::spam_guard($full_name, $message, $email, $phone, $ip) !== '') throw new \Exception(Language::g("needs/spam-blocked")); $message_id = $this->model->add([ 'full_name' => $full_name, 'email' => $email, 'phone' => $phone, 'message' => $message, 'ip' => $ip, 'cdate' => \DateManager::Now(), ]); \ProcessRestriction::hit("contact-form"); if ($needCaptcha) \BotShield::clear("contact-form"); else \BotShield::record("contact-form"); return $operation->output([ "status" => "successful", "message" => Language::gc("website/contact/success"), "email" => $email, ]); } ``` Two extension points: a veto hook before the write, an event hook after. ```php // Any listener returning a non-empty string refuses the submission with that message. foreach (\Hook::run('gate:client.contact_submit', $full_name, $email, $phone, $message, $ip) as $veto) if (is_string($veto) && $veto !== '') throw new \Exception($veto); // After the write. Return values are ignored; this is an announcement, not a decision. \Hook::run('action:client.contact_submitted', $message_id, $full_name, $email, $phone, $message, $ip); ``` - **gate:client.contact_submit**: Runs after validation, before the write. A non-empty string is a refusal. - **action:client.contact_submitted**: Runs after the row exists, with its id first. The return is ignored. ### Pitfalls > **A mistyped key rejects every submit, forever** > > The token is an HMAC of the key. Print one key, verify another, and the visitor sees an expired session. No warning, no log entry. > **Without the AJAX header the token check refuses by design** > > Verification demands `X-Requested-With` unless the third argument disables it. A missing header looks like a wrong key. > **Gate the client on the server's answer, not on whether the box is visible** > > A tray can open because the visitor started typing. Keep a flag that only `captcha_required` sets. > **Do not reveal the results panel in the finally branch** > > Opening the results container after every request leaks the placeholder. Open it on success only. > **Four bot layers do not imply the fifth** > > The throttling layers judge an address, not content. A perfect token still stores banned words until the spam guard runs. ### Related Articles - [Login and Registration](https://dev.wisecp.com/en/login-and-registration) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) - [Operations](https://dev.wisecp.com/en/operations) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Writing a Captcha Module](https://dev.wisecp.com/en/writing-a-captcha-module) ## Theme Performance and Caching https://dev.wisecp.com/en/theme-performance-and-caching A theme runs on every page view, so anything it computes twice is paid twice. The cache helper stops that; the key makes it correct. ### Overview Themes are cheap by construction: the template prints values, the controller produces them. The one place a theme can slow an installation down is `hooks.php`. The remedy is one call: a bucket, a key, a lifetime and a producer. Two things decide whether it is correct: the key, and what you leave outside it. ### Prerequisites - A working theme. New to `hooks.php`? Read [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) first. - One handler per theme for `filter:template.variables`. Only the last returned value survives. - Nothing to switch on; the cache setting is read inside the helper. ### Structure #### Where Work Belongs | Need | Where it belongs | How often it runs | | --- | --- | --- | | Data for one page (this service's invoices) | The controller, with the page data | Once, on that page only. | | Data every page needs (footer categories, a currency strip) | The theme's `hooks.php`, through the variables filter | Every website request. The only per-request cost a theme creates. | | Presentation (loops, conditions, formatting) | The template | Every page, and it must stay free. A template that queries has no cache at all. | #### What Belongs in the Key The key is the whole contract. Anything the value depends on has to appear in it. | Fragment | Read from | Leaving it out produces | | --- | --- | --- | | Currency | `Money::getUCID()` | Prices formatted for whoever warmed the cache, with their symbol. | | Language | `Language::selected()` | Titles and generated links in one language everywhere. Links are built through the selected language. | | Scope of the query | The category, product or menu id you passed in | One scope's rows served for every scope. The fragment forgotten when a parameter is added later. | | Visitor identity | Nothing. It never belongs in a shared cache. | A key per visitor is a leak, not a cache. Per-visitor values are not cached at all. | ### Walkthrough #### 1. Find the Repeated Work 1. Look at what your handler calls, not what it returns. A plain-looking getter can issue one query per node. 2. Ask how the value changes. Catalogue data and menus tolerate an hour; stock and carts do not. 3. Ask who the value belongs to. If the answer names a person, no key makes it cacheable. #### 2. Wrap the Producer 1. Put the expensive work in a closure. Computing first and caching afterwards runs it every time. 2. Build the key from the fragments above, in a fixed order, with a prefix unique to your theme. 3. Choose a lifetime: an hour for operator-edited data, a day for reference data. Zero never expires. 4. Add a static guard when one request asks for the same value several times. ```php $ucid = (int) Money::getUCID(); $rows = Cache::remember('website', 'acme_footer_' . $ucid . '_' . Language::selected(), 3600, fn (): array => Products::group_cards($ucid)); ``` #### 3. Keep Hooks Outside the Cached Region 1. Cache the data, then filter the result. Inside the producer, a module's listener freezes into the cached copy. 2. Lift the context into local variables first: the by-reference runner turns a literal into a fatal error. 3. Follow the shipped precedent: the software store list caches its rows, then filters them. ```php $out = Cache::remember('website', 'software_store_' . $categoryId . '_' . $ucid . '_' . Language::selected(), 3600, function () use ($ucid, $categoryId): array { // one query, many rows, shaped for the template return $this->build($categoryId, $ucid); }); // OUTSIDE the producer: this must run on every request, cached or not. $ctx = ['currency' => $ucid, 'category' => $categoryId]; Hook::runRefs('filter:product.software_list', $out, $ctx); return $out; ``` ### Reference #### The Canonical Call ```php // coremio/classes/Cache.php // Returns whatever the producer returns: array, string, int, object. public static function remember(string $name, string $key, int $ttl, callable $producer); ``` - **$name**: The bucket. One file per bucket, loaded and cleared together. Website data uses `website`; menus use `menus`. - **$key**: The entry, and the whole correctness contract. Prefix it with your theme, then append every fragment. - **$ttl**: Lifetime in seconds. 3600 for operator-edited data, 86400 for reference data. Zero disables expiry. - **$producer**: Any callable. Runs on a miss, and on every call when caching is switched off. Never add your own check. #### The Instance API Rarely needed. Reach for these to inspect or remove a single entry. ```php // coremio/classes/Cache.php public static function getInstance(): self; public function store($key, $data, $expiration = 86400): bool; // $expiration = 0 never expires public function retrieve($key, $timestamp = false); // null when missing or unreadable public function isCached($key): bool; // also drops the entry if expired public function erase($key): self; // drops one entry public function eraseAll(): self; // empties the bucket this instance points at public function clear($keys = []): void; // named buckets, or no argument = every bucket ``` There is no `get()` and no `set()`. A missing or corrupt entry reads back as null, treated as a miss. #### Invalidation | Trigger | Call | What it removes | | --- | --- | --- | | An operator saved something in the panel | Already done by the operation that saved it | Everything. Most save operations clear the whole store. | | You cached something the panel does not know about | `Cache::getInstance()->clear(['bucket'])` | The named buckets only. Use this when your theme owns both the write and the read. | | Time passing | Nothing | The entry, on the first read after its lifetime. Expiry is checked on read. | > **Repeats inside one request are already handled** > > The bucket file is read once per request, so three keys cost one file read. A static variable also skips the key building. ### Example Product group cards in a footer. The producer is cached, the filter runs outside it, the template only loops. ```php /* * ONE handler per theme: the view layer keeps only the last returned value, so a second * registration would drop every key set here. Add keys, do not add a second Hook::add. */ Hook::add("filter:template.variables", 1, function ($template, $data) { $data["footer_groups"] = acme_footer_groups(); return $data; }); if (!function_exists('acme_footer_groups')) { /** * Product group cards for the footer strip. Runs on every website page, so the query * behind it is cached; the value is shared by every visitor, which is exactly why the * key has to carry the two things that make it visitor-specific. */ function acme_footer_groups(): array { // Same request, several partials: skip even the key building. static $rows = null; if ($rows !== null) return $rows; // Currency comes from the visitor, links come from the selected language. // Both go in the key, or the first visitor's version is served to everyone. $ucid = (int) Money::getUCID(); $rows = Cache::remember('website', 'acme_footer_groups_' . $ucid . '_' . Language::selected(), 3600, fn (): array => Products::group_cards($ucid)); return $rows; } } ``` ```smarty {if $footer_groups} {/if} ``` The template does no lookup and no formatting: the price arrived formatted. - **Cache::remember()**: The only cache call a theme needs. Falls through to the producer when caching is off. - **Money::getUCID()**: The visitor's currency id. Belongs in the key of anything that produces a formatted amount. - **Language::selected()**: The selected language code. Belongs in the key of anything that produces text or a generated link. - **Products::group_cards()**: The shipped producer used above; it caches internally with the same two fragments. ### Pitfalls > **A key without the currency is wrong on a machine you never test on** > > The cache is warmed by whoever loaded the page first; locally that is always you. The same applies to the language. > **A hook inside the producer runs once an hour instead of once a request** > > A filter inside the closure is applied only when the entry is rebuilt. The module works right after a clear and stops a minute later. > **The by-reference hook runner takes every argument by reference** > > That includes the context after the filtered value: a literal or a cast is a fatal error. > **Never cache what belongs to one visitor** > > Stock levels, cart contents, anything behind a login: none of it goes through a shared store. Wrong for the second reader means data exposure. > **Register the variables filter once per theme** > > Only the last returned value survives, so a second registration drops every key the first one set. The symptom is template variables going empty at once. ### Related Articles - [Caching](https://dev.wisecp.com/en/caching) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) - [Template Variables](https://dev.wisecp.com/en/template-variables) - [Menus and Navigation](https://dev.wisecp.com/en/menus-and-navigation) - [Multi-Theme Parity](https://dev.wisecp.com/en/multi-theme-parity) ## Responsive and Accessible Markup https://dev.wisecp.com/en/responsive-and-accessible-markup The measurable contract a theme's markup must satisfy, and how each rule is proven. ### Overview Responsiveness and accessibility are not a review pass at the end. They are a few shell declarations plus rules you can measure on a live page. Two obligations belong to the theme alone: the viewport declaration in every layout, and the root language and direction attributes. ### Prerequisites - A theme with at least one layout: any template that opens its own document head. - Bootstrap 5 semantics. A version 4 class name is not an error: it is silently unstyled. - A browser you can measure in. ### Structure #### Layout Declarations Three declarations per layout; the engine adds none. 1. The viewport meta, exactly `width=device-width, initial-scale=1`. Without it a phone lays out at desktop width. 2. The root language and writing direction, from the engine's variables. 3. The direction-aware stylesheet choice before first paint, inside the head guard. Audit, per theme: `grep -L 'name="viewport"' templates/website//layouts/*.tpl` must print nothing. #### Engine Variables | Variable | Shape | Resolved from | | --- | --- | --- | | `$ui_lang` | A language code, such as `en` or `tr`. | The selected language, resolved again on every request. | | `$ui_dir` | Exactly `ltr` or `rtl`, never anything else. | The language pack's own direction flag. | | `$setting` | Every manifest field, merged with saved values. | The settings schema; operator-owned layout switches only. | ### Reference #### Theme API ```php // coremio/classes/Theme.php // Behind {asset path='...'}. Appends ?v= with the file's modification time for css and js ONLY. // Fonts and images stay query-less on purpose: a versioned font preload would never match the // query-less url() inside the font stylesheet, the preload is wasted and the face misses first paint. public function assetUrl(string $path = ''): string; // Behind {lang key='...' var='...'}. Every named argument except 'key' and 'g' becomes a // {var} replacement in the value, which is how an accessible name carries a real number. // Smarty only: the Twig function takes the key alone and forwards no replacements, so a // Twig theme substitutes outside the call rather than shipping a literal {count} to a reader. public function lang(string $key, array $vars = []): string; // True when views/ exists in this theme, used to gate a shell before rendering into it. public function viewExists(string $view): bool; ``` ```php // coremio/classes/Language.php public static function selected(): string; public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool; ``` ```php // The template only prints the result; it never resolves the direction itself. $ui_lang = Language::selected(); $ui_dir = Language::g("package/rtl") ? 'rtl' : 'ltr'; ``` - **Theme::assetUrl()**: Resolves and versions a path inside the theme's assets. A hand-written link has no cache buster. - **Theme::lang()**: Every visible string, and every invisible one: aria labels are text too. - **Language::selected()**: The code in the root language attribute. Screen readers read pronunciation from it. #### Breakpoints A fourth query is a decision, not a detail. | Query | Tier | What belongs here | | --- | --- | --- | | `max-width: 575.98px` | Below the small tier | Phone compaction: hiding a label, collapsing a toolbar. | | `max-width: 767.98px` | Below the medium tier | Layout changes that outlive the phone. | | `min-width: 992px` | Large and up | Desktop-only chrome. Written as a minimum, so the small screen is the default. | | `print` | Print | Chrome that has no meaning on paper. One block. | #### Type Scale A new size is never invented next to an existing one. | Class | Value | At a 16px root | Use for | | --- | --- | --- | --- | | `fs-7` | 0.85rem | 13.6px | Secondary text: card bodies, list rows, table cells. | | `fs-8` | 0.8rem | 12.8px | Hints and helper lines under a control. | | `fs-9` | 0.7rem | 11.2px | The floor. Badges and micro labels only. | | Headings | Bootstrap scale | The fifth heading step is 20px | Section titles. A helper line is never larger than its label. | #### Motion and Focus | Preference | What the theme does | How to prove it | | --- | --- | --- | | Reduced motion | A global block clamps every animation to a hundredth of a millisecond and one iteration. | Count elements still at zero opacity; the answer must be zero. | | Keyboard focus | Rings appear for keyboard traversal only. | Tab through: every stop must be visible. Then click: no ring may remain. | #### Off-Screen Text | Need | Correct markup | What the wrong one does | | --- | --- | --- | | A control with no visible text | An aria label from the language file. | Hard-coded text stays English everywhere else. | | Text for screen readers only | `visually-hidden` | `sr-only` is the Bootstrap 4 name; Bootstrap 5 does not define it. A bundled icon library keeps it alive. | | A landmark for keyboard users | A nav element with an aria label, plus the current page marked. | Unnamed navigation landmarks are indistinguishable in a landmark list. | #### Wide Content | Content | Wrapper | Without it | | --- | --- | --- | | A table with more columns than a phone can show | A responsive table wrapper around the table element. | The page itself scrolls sideways, and every surface on it inherits that. | | A long unbroken string (a key, a token, a domain) | Wrapping on the cell, not a width removal on the container. | Widening the container pushes the last columns past the edge. | | A code block or payload | Its own scroll container. | The same page-level sideways scroll, on pages with a long line. | Wide content scrolls inside its own box; the page body never does. ### Example The obligations, in layout-head order. ```html {* Before first paint: resolve the stored direction and write the matching Bootstrap file, so the first painted frame is already correct instead of flipping a frame later. *} ``` The two markup patterns that carry most of the accessibility work. ```smarty {* Bootstrap 5 name. 'sr-only' is not a Bootstrap 5 class; do not rely on it. *} {lang key='website/index/footer-payment-methods'} ``` Those two variables arrive with the client page data, not from the engine. - **$client_badges**: Integers keyed `services`, `domains`, `invoices`, `support`. A key is zero rather than absent when the section is off. - **$client_badges_text**: The same keys, as display strings; past two digits it becomes a plus form. Print this inside the badge, the raw one inside the label. The acceptance list, measured on a live page. - **No sideways page scroll**: At 320 CSS pixels wide, document scroll width must not exceed window inner width. - **Tap targets**: At least 44 CSS pixels on the shorter axis. Measure the box, not the icon. - **Focus visible on keyboard, absent on pointer**: Tab through: every stop is visible. Click: no ring survives the press. - **Reduced motion leaves nothing hidden**: With the preference on, no element stays at zero opacity. - **Contrast**: 4.5 to 1 for body text, 3 to 1 for large text. Measure in light and in dark. - **Right to left**: Flip the root direction attribute and re-walk. Nothing overlaps, nothing is clipped. ### Pitfalls > **A Bootstrap 4 class name is silently unstyled** > > Copied markup keeps working until the missing class was the one doing the work. Find the rule that styles it first. > **One layout without the viewport meta breaks a whole flow** > > The obligation is per layout, not per theme. A checkout shell without it puts the purchase flow at desktop width. > **Widening the container hides more than it shows** > > Removing a cell's width limit pushes the last columns off the edge. Let the value wrap instead. > **Invisible text is still text** > > Aria labels are the easiest strings to hard-code, because nobody sees them in review. > **A fix in one theme is a fix in all of them** > > Everything here is behaviour, not visual identity. A fix in one theme only leaves the same defect elsewhere. ### Related Articles - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Theme Assets](https://dev.wisecp.com/en/theme-assets) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) - [Multi-Theme Parity](https://dev.wisecp.com/en/multi-theme-parity) - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) ## Multi-Theme Parity https://dev.wisecp.com/en/multi-theme-parity A behavioural fix belongs in every theme that carries the same file, and a tool says which themes still lack it. ### Overview An installation ships several themes. They share a file layout and mostly the same markup. A defect found in one theme is rarely a defect of that theme: it belongs to the file, wherever that file was copied. The rule is short. **A fix made in one theme is made in every theme that carries the counterpart of that file.** It works both ways, and memory does not keep it: the second theme lints clean on its own. ### Prerequisites - Two or more themes under `templates/website`. The tool finds them from the manifest in each directory. - A finished change. Run the check after the fix: its input is what you touched. - A place to record deliberate differences. An unexplained missing file is a bug. ### Structure #### Two Triggers, One Tool | Situation | Direction | What you do | | --- | --- | --- | | A bug, a behaviour, a security hole or an accessibility defect. | Outwards, to every other theme. | Apply the same fix to every theme with the counterpart file, then run the single-file check. | | You are building a new theme, or adding a page or a component. | Inwards, from the other themes. | Run the inventory. Port every missing file, or write it down as intentionally absent. | Whether a difference is deliberate depends on the *nature* of the change. | Kind of change | Travels to | Why | | --- | --- | --- | | Behaviour, correctness, security, accessibility, data handling | Every theme | None of this is visual identity. A theme may look different; it may not be less correct. | | Colour, spacing, a structure from that theme's own design source | Only that theme | This is what makes a theme a theme. Copying it outwards erases the other theme's design. | #### Why the Comparison Is Theme Neutral Two copies of the same component are never byte-identical, because each theme owns its own prefixes. So each file is normalised first, and only real content differences survive: 1. Custom property and data attribute prefixes fold together. 2. The JavaScript namespace folds, so a call through one theme's global object equals another's. 3. The bare theme name folds everywhere, class names included. A reported difference is a difference in what the file *does*, not in what it is called. ### Walkthrough #### 1. Run It After Every Fix 1. Fix the file in the theme you were working in. 2. Run the single-file check on the path you touched. 3. If the round touched several files, use the changed-files mode. ```bash php .claude/tools/theme-parity.php views/account/services.tpl # one file php .claude/tools/theme-parity.php --changed # everything this round touched ``` #### 2. Read the Verdict Three outcomes; only one means you are finished. | Output | Meaning | What to do | | --- | --- | --- | | All equivalent | Same content everywhere, once prefixes are folded. | Nothing. The fix travelled. | | Diverging, with theme names and a character delta | The named themes hold a different version. | Deliberate visual identity stays. Anything else has not reached those themes yet. | | Missing, with theme names | The file does not exist in those themes. | Port it, or write down why it is absent. | #### 3. Close the Gap 1. Port the file, or record the substitution. 2. Verify by counting, not by loading. 3. Count the marker it should have produced: a styled class, a preset wrapper, a layout shell. | Missing piece | What the page does | How to catch it | | --- | --- | --- | | A table preset | Loads. The component falls back to raw columns. | Count the preset's classes in the output; zero means it never loaded. | | A stylesheet rule | Loads. The class matches nothing, so the block appears unstyled. | Compare the count of the styled class across themes. | | A block override | Loads, inside the wrong shell. | Check what surrounds the content, not the content. | ### Reference #### Invocation - **No argument**: Inventory. Every theme with its file count, then every file missing from at least one theme. A new theme is finished against this mode. - **A file path**: Single file. Prints who has it, who does not, and whether the copies are equivalent. - **The changed-files switch**: Every theme file changed in the working tree, with a count of how many need attention. #### What Is Compared - **Included**: Templates, PHP, JavaScript and stylesheets under the theme root. A fix inside a stylesheet is the kind that gets left behind. - **Excluded: the saved settings file**: Each installation writes its own operator-chosen values there, so comparing it would be noise. - **Excluded: vendored libraries**: Third-party code is expected to be byte-identical. - **How themes are discovered**: Every directory under the website templates root that holds a theme manifest. #### Exit Codes Usable as a gate, not only as a report. ```bash 0 nothing needs attention: present everywhere and equivalent everywhere 1 at least one file is missing somewhere, or one copy has diverged (in the inventory mode, 1 simply means the tree still has unexplained gaps) # Fewer than two themes is not a failure, it is a no-op: the tool says so and exits 0. php .claude/tools/theme-parity.php views/account/services.tpl; echo "exit: $?" ``` ### Example Real output, one block per verdict. ```bash $ php .claude/tools/theme-parity.php components/news-newsletter.tpl === components/news-newsletter.tpl var : Basic, WCOM, WStyle ✔ içerik: hepsi eşdeğer (tema-nötr karşılaştırma) ``` ```bash $ php .claude/tools/theme-parity.php views/account/services.tpl === views/account/services.tpl var : Basic, WCOM, WStyle ⚠ içerik: Basic ile AYRIŞAN → WCOM WCOM 124 karakter fark ``` All three themes have the file; one holds a version 124 characters apart. A layout decision stays. Your fix has not arrived yet. ```bash $ php .claude/tools/theme-parity.php partials/client-subnav.tpl === partials/client-subnav.tpl var : Basic ❌ YOK : WCOM, WStyle <-- ya taşı ya da tema-özel/ikame olarak belgele ``` This gap is legitimate only because it is written down: the other two themes navigate with a sidebar instead. ```bash $ php .claude/tools/theme-parity.php Tema (3): Basic, WCOM, WStyle Basic 229 dosya WCOM 514 dosya WStyle 249 dosya Tüm temalarda olan: 218 dosya Bazılarında eksik : 321 dosya --- eksik olanlar (dosya → hangi temalarda YOK) --- assets/css/app-detail.css YOK: Basic, WStyle ... assets/css/client-nav.css YOK: Basic, WCOM ... assets/css/marketplace.css YOK: Basic, WStyle ... ``` Read the list for answers, not for a verdict: a large gap count is not alarming on its own. What matters is that every line has an answer. ### Pitfalls > **A page that loads is not evidence** > > All three silent classes answer with a working page, so route tests and a quick look pass. Count the marker the missing piece should have produced. > **Copying the source theme when the design was redrawn** > > First compare the two design sources. Same structure: copy and rename prefixes. Redrawn structure: fidelity to the source is the failure. > **Prefixes copied with the markup break silently** > > Copied markup brings the source theme's prefix along, and the selector then matches nothing. After any port, search the copied files for the source theme's name. > **An unexplained gap becomes permanent** > > The inventory prints missing files, not reasons. A gap nobody wrote down looks like an oversight, and an unread gate is the same as no gate. > **The rule does not depend on how many themes exist** > > It reads the same with two themes as with ten. ### Related Articles - [Theme Anatomy](https://dev.wisecp.com/en/theme-anatomy) - [Your First Theme](https://dev.wisecp.com/en/your-first-theme) - [Responsive and Accessible Markup](https://dev.wisecp.com/en/responsive-and-accessible-markup) - [Theme Assets](https://dev.wisecp.com/en/theme-assets) - [Page Surfaces](https://dev.wisecp.com/en/page-surfaces) ## Tables and Record Lists https://dev.wisecp.com/en/tables-and-record-lists A theme never ships its own grid library: record lists run on the core Table component, and everything else is plain markup. ### Overview Two paths cover every table a theme needs. The question is not how many rows you have, but **what the rows are**. - **Record list**: Services, invoices, domains, tickets, ledger entries, messages, activity. The visitor searches, filters and pages through them, and the set can be empty. Use `Table` with `'renderer' => 'list'`. - **Document table**: Invoice line items, a feature matrix, DNS records, an SMS report. It is read, not navigated, and it belongs to the page. Write plain markup in the template. ### Prerequisites - Your theme is active, so `Theme::active()` resolves to its directory. - You know the component itself; this article only covers the client-side list mode. - A `tables/` directory exists in your theme root. ### Structure Four layers, and each one owns a different file. The core controller already exists for the pages listed above, so a theme usually writes only the second and third. - **Data and paging**: Core controller. Builds the table and hands over the two model closures. - **Row markup**: Your preset, at `templates/website/{Theme}/tables/{name}.php`. - **Container, toolbar, pager**: Your view, at `views/account/{name}.tpl`. - **Interaction**: Your `list.js`: client search, filter, paging, counter text, empty state. ### Step by Step #### Adding a record list 1. Copy the preset for the page from another theme into your own `tables/` directory. The file name matches the table name the controller passes. 2. Rewrite the markup inside `setRowRender()` in your own design language, and keep the data and the order untouched. 3. Mirror that markup in the view's `{foreach}` branch, so the first page and the later pages look the same. 4. Give the container its three text bridges, then load the page and page through it. The counter now reads in your language and the empty state appears when a filter matches nothing. ### Reference The list mode changes three things about the component. - **Table::preset()**: Reads from two different roots. In the panel it is `templates/admin/tables/`; on the site it is `Theme::active()->dir()."tables"`. A preset placed in the admin directory is never found. - **setRowRender(callable)**: Your callback returns the whole row in `$row["html"]`. The column matrix under `$row["data"]` is unused in this mode. - **buildList(string $format = ''): string**: Concatenates the rows and returns them. No columns, no slicing, and `justBody` returns the same thing as the full call. - **Sorting**: There are no column headers to click, so the order comes from the model's allowlist. - **Download**: Never offered here. The privilege check returns false for a customer session. The controller finishes the work the template cannot do, because a theme template may not call the component under the sandbox: - **build('justBody')**: Builds the rows ahead of time and hands them to the view as a ready string. - **getAjax()**: The data address, and it is filled **only** when the account crossed the row threshold. The view writes it on the container as `data-ajax`. - **ajaxControl(['baseLink' => …])**: Answers a data request and returns a non-empty string when it did, so the controller returns that value untouched. > **Small accounts never reach the server** > > Below the threshold every row is already in the first answer, `data-ajax` is absent and searching, filtering and paging all happen in the browser. Above it the same address serves one page at a time. Your markup is identical in both, which is why the row attributes below carry their own values. The container carries the wording the interaction script needs: - **data-noun**: The key inside the script, in English, and it is never shown. - **data-txt-count · data-txt-nores · data-txt-noun**: The translated counter sentence, the no-match sentence and the word for the record. Leave one out and that text falls back to English. ### Example ```php /** @var \WISECP\Components\Table $table */ if (!isset($table)) return; $table->setRowRender(function ($row) { $service = $row["model"]; $name = htmlspecialchars($service["name"]); $status = $service["status"]; $danger = in_array($status, ['suspended', 'expired'], true) ? ' is-danger' : ''; $link = LinkGenerator::client("service-detail", [$service["id"]]); // The whole row, in your own markup. No anywhere. $row["html"] = ''; return $row; }); ``` ```html
    {$rows nofilter}
    ``` ### Pitfalls > **A preset in the wrong directory fails silently** > > The lookup asks whether the file exists and moves on when it does not. Nothing is logged, no error appears, and the list comes back empty. If your rows are missing, check the path before you check your code. > **The first page and the later pages must match** > > The view builds page one, and the preset builds every page after it. When the two drift apart, the rows change shape as soon as the visitor pages forward. > **Two empty states, not one** > > No records at all replaces the toolbar, the list and the pager together, and it may offer a way to create the first record. A filter that matches nothing lives inside the container and offers a way to clear the filter. Writing only one of them leaves the visitor stuck. > **Every theme needs the file** > > Presets live in the theme, so each theme carries its own copy of all seven. A missing one costs that theme an empty list. ### Related Articles - [Interface Components](https://dev.wisecp.com/en/interface-components) - [The Client Area](https://dev.wisecp.com/en/the-client-area) - [Multi-Theme Parity](https://dev.wisecp.com/en/multi-theme-parity) ## Client List Interaction https://dev.wisecp.com/en/client-list-interaction The rows your preset writes and the script that searches, sorts and pages them meet through a fixed set of attributes. ### Overview A record list is served in one of two modes, and the theme markup is the same in both. The mode is decided by the row count, not by you. - **Client mode**: Below the threshold every row arrives in the first answer and the container has no `data-ajax`. Search, filter, sort and paging all run in the browser, against the attributes on each row. - **Server mode**: Above it the container carries `data-ajax` and the script asks for one page at a time. The totals come from the answer instead of being counted locally. > **Attributes are not optional in either mode** > > Client mode reads them to decide what a filter matches. Server mode still reads them for sorting and for the visual meters. A row that omits one is not rejected and no error appears. It stops matching the filter it belongs to. ### Prerequisites - Your preset already returns the row in `$row["html"]`. - Your view has the `.list-rows` container with its three text attributes. - Your theme has a `list.js`; copying another theme's is the usual start. ### Structure Three nodes matter to the script, and it finds them by class or role, never by position. - **.list-rows**: The container. Carries the data address in server mode and the wording in both. - **.list-item**: One row, written by your preset, carrying the attributes below. - **[data-role="empty"]**: The no-match block. It stays in the container and the script shows it. The zero-records block is a different thing, and it replaces the whole list. ### Reference #### Row attributes Your preset writes these on the row element. Each one has a single reader, so leaving one out disables exactly that feature. | Attribute | Value | What stops working without it | | --- | --- | --- | | `data-status` | status key | Status filter and the default sort order | | `data-group` | type key | Type filter; the value has to match the option the toolbar offers | | `data-flag` | segment key | The tile row above the list | | `data-name` | visible name | Search, which reads this first | | `data-search` | extra words | Search on anything not in the name, such as a domain or a number | | `data-price` | raw amount | Sorting by price | | `data-due` | timestamp | Sorting by due date and the remaining-term meter | | `data-start` | timestamp | Sorting by newest and the term meter, which needs both ends | Timestamps are seconds, not formatted dates: the script compares them as numbers and a formatted value reads as zero. #### Container attributes - **data-ajax**: Written by the view from the table's own address, and present only in server mode. Its absence is the signal for client mode, so never write a placeholder value. - **data-noun**: The key inside the script, in English, never shown to anyone. - **data-txt-count · data-txt-nores · data-txt-noun**: The counter sentence, the no-match sentence and the word for the record. The first two hold the placeholders below. The sentences are filled in, not concatenated, so the wording stays in the translator's hands: - **_START_ · _END_ · _TOTAL_ · _NOUN_**: First row on the page, last row on the page, the filtered total, and the word from `data-txt-noun`. Every one of them may appear more than once. #### The server answer In server mode the script asks the table's own address and expects the shape the panel uses: ```json { "type": "partial", "total": 240, "total_filter": 18, "body": "
    ...
    ..." } ``` The request carries `page`, `perPage`, `search`, `filter[type]`, `filter[status]`, `order` and `direction`. The component builds all of them. A filter you add to the toolbar reaches the query only when the controller declared it. ### Example ```php $row["html"] = '
    ' . $body . '
    '; ``` ### Pitfalls > **A filter value has two ends** > > The option in the toolbar and the attribute on the row have to hold the same value. When they drift apart the filter selects nothing. An empty result is a legitimate answer, so nobody is told that a mistake happened. > **The two empty blocks are different** > > The no-match block lives inside the container and the script shows it. The zero-records block replaces the toolbar, the list and the pager together, and the view decides that before the script ever starts. Writing only one leaves the visitor with an empty page and no way out. > **Typing cancels the previous request** > > In server mode each keystroke supersedes the one before it, and the older request is dropped on purpose. Do not add your own timer on top: two of them make the list flicker between answers. > **Formatted values break sorting** > > Dates go in as seconds and amounts as raw numbers. A value with a currency symbol or a thousands separator reads as zero and sinks to the bottom of every sort. ### Related Articles - [Tables and Record Lists](https://dev.wisecp.com/en/tables-and-record-lists) - [The Client Area](https://dev.wisecp.com/en/the-client-area) - [Theme Translations](https://dev.wisecp.com/en/translating-a-theme) # Theme Development / Notification Templates ## How Notification Templates Work https://dev.wisecp.com/en/how-notification-templates-work Every mail and text message the platform sends is built from three files on disk, one set per event and language. A template author owns those files the same way a theme author owns a view. ### Overview A notification is not written in code. Code decides that something happened and hands over the facts; the wording, the markup and the subject line live under `templates/notifications`. An installation can change what a customer reads without a developer, and a module can ship its own wording. The unit is the **event**, written as `group/name`. Each event owns three files per installed language: the mail body, the text message body and a small file carrying the subject. The mail body is a fragment, not a document. Two things reach that template. The **resolver** for the group turns what the caller passed (an invoice row, a service id, a ticket) into named variables. The platform then adds the installation's own values: logos, colours, company details, the recipient's profile. The template only prints. ### Structure #### The File Layout Three roots, each answering a different question. A subject line and a text message read the same whatever the mail looks like, so they live outside the designs entirely. The mail body belongs to the design that produces it. ```bash templates/notifications/ ├── content/ # SHARED TEXT — one copy serves every design │ └── en/invoice/ │ ├── invoice-created.json # {"subject": "..."} │ └── invoice-created.txt # the text message body, no shell at all ├── themes/ # DESIGN — one directory per installed design │ ├── aurora/ # the design that ships with the product │ │ ├── header.html # the outermost shell │ │ ├── content.html # the body slot, a single {$notifi_body} placeholder │ │ ├── footer.html # the closing shell │ │ └── en/invoice/ │ │ └── invoice-created.html # the mail body, a FRAGMENT of the shell │ └── ledger/ # a second design; may carry only some events ├── custom/ # the operator's own edits, one tree per design │ └── ledger/en/invoice/invoice-created.html └── .htaccess # nothing in this tree is served over HTTP ``` Never build one of these paths by hand; the helpers below are the only place that knows the order. A design carrying no file for an event still sends the mail. The body comes from the base design; the shell stays its own. - **View::notification_file()**: The mail body: `custom/{active}` → `themes/{active}` → `themes/aurora`. The first file that exists wins. - **View::notification_content_file()**: The subject and the text message, from `content/{lang}` — no design takes part. - **View::notification_write_path()**: Where an edit is saved: `custom/{active}` for any design other than the base one. Deleting that file restores the default. #### The Shell A mail body is never sent on its own. The platform concatenates three files into one shell. It assigns the finished event body to `notifi_body` and builds the shell with the same variables. The shell resolves through the designs in the same order as the body. A design that ships none frames its mails with the base one. - **header + content + footer**: Concatenated in that order and cached per language for the life of the process. The shipped `content.html` in each language is a single placeholder, so the frame is really the header and the footer. - **the event body is a fragment**: The header opens a table and leaves it open; the event body continues it and the footer closes it. An event body that opens its own document produces markup no mail client will lay out. - **text messages get no shell**: The concatenation is skipped for the text channel. A `.txt` body is delivered exactly as written, with no logo, footer or contact block. - **the subject goes through the engine too**: The `.json` file's `subject` goes through the same engine and the same variables, so it can carry placeholders. It is read for the mail channel only. #### Groups and Resolvers A group is a directory and a section of the configuration file at once. It decides which resolver builds the variables and which notification category the recipient's preferences are checked against. | Group | Events shipped | Resolver | Preference category | | --- | --- | --- | --- | | `invoice` | 10 | `resolve_invoice_context` | invoices | | `service` | 11 | `resolve_service_context` | product | | `order` | 4 | `resolve_order_context` | product | | `domain` | 13 | `resolve_domain_context` | domain | | `user` | 36 | `resolve_user_context` | general | | `user-tickets` | 8 | `resolve_ticket_context` | support | | `admin-tickets` | 4 | `resolve_ticket_context` | support | | `admin-messages` | 18 | `resolve_admin_message_context` | general | | `sms-intl` | 3 | `resolve_sms_intl_context` | product | | `newsletter` | 2 | none | not dispatched, see below | | `license-transfer` | 3 | none | not registered, see below | The last two rows are the two ways an event can exist outside the normal path. `newsletter` has configuration entries but no resolver, so a dispatch would answer `error`. Its templates are built directly and pushed onto the queue by the newsletter code, which supplies the variables itself. `license-transfer` is the opposite: three events sit on disk with no configuration section at all. They are sent through the low-level entry point, which falls back to mail-and-text-on when it finds no settings. #### The Delivery Path From a single call to an actual message the chain is fixed, and every step can end it. ```bash Notification::dispatch('invoice', 'invoice-created', ['entity' => $invoice]) 1. gate:notification.dispatch a listener may veto -> blocked 2. read notifications/invoice/invoice-created -> disabled if absent or off 3. run the group's resolver: entity -> variables + user_id -> error if it returns nothing 4. check the recipient's preference bitmask for the category -> opted_out 5. build the recipient list (owner, extra addresses, admins) -> no_recipients if empty 6. render per recipient, in THAT recipient's language and channel 7. deliver now if the event is in the sync list, otherwise queue -> sent | queued 8. write the in-app rows: the recipient's, and the admin copy 9. action:notification.dispatched ``` Step 6 reads the template once per recipient, not once per dispatch. Two recipients of the same event can be reading in different languages and on different channels. The synchronous list at step 7 is short and holds security-critical events; everything else waits for the queue. ### Reference #### The Two Entry Points ```php public static function dispatch(string $group, string $name, array $context = []): array; public static function send(array $params): array|string|bool; public static function get_recipients(string $group, string $name, array $context = []): array; ``` - **Notification::dispatch()**: The one to use. It honours the on/off switch, the recipient's preferences and the gate hook, and returns an array whose `status` says what happened. - **Notification::send()**: The low-level one. It takes a single array and skips the resolver entirely, which means **you** supply the variables. Legitimate for a body with no template, a raw address list, or the queue worker delivering a row that is already built. - **Notification::get_recipients()**: Runs the first half of a dispatch and stops, on the same arguments: returns who would be written to instead of writing. Useful while wiring up a new event. #### What a Dispatch Returns Never a boolean. The array always carries `status`, and a successful one also carries `batch_id` and one entry per recipient under `items`. | status | Meaning | Where it is decided | | --- | --- | --- | | `queued` | Rows written to the queue, delivery follows within a minute. | normal path | | `sent` | Delivered inline, because the event is in the synchronous list or the caller forced it. | normal path | | `blocked` | A listener vetoed the event. | the dispatch gate hook | | `disabled` | The event has no configuration entry, or its switch is off. This is what an unregistered event answers. | the configuration file | | `error` | The group has no resolver, or the resolver returned nothing (unknown invoice, deleted user). | the resolver map | | `opted_out` | The recipient's preference bitmask excludes the group's category. | the recipient's profile | | `no_recipients` | Nobody left after the channel switches and per-channel preferences were applied. | the recipient builder | #### The Template Engine Which engine parses the files is an installation-wide setting, read from `options/notification-template-engine`. It ships as Smarty, and every template in the tree is written for it. Two other values exist: Twig, and a plain string replacement mode that understands nothing but `{name}`. It is not the theme sandbox and does not behave like one: - **no automatic escaping**: Values print exactly as they arrive, markup included. A theme escapes by default; this one does not. Use the escape modifier on anything a customer typed. - **a restricted policy**: Thirteen platform classes are callable from a template and thirty nine language functions are allowed. Anything else is a compile error, and a compile error has consequences: see the pitfalls. #### The Queue - **NotificationQueue::add()**: Takes one row that is already built: channel, recipient, subject, body, attachments, priority, batch id, optional schedule. Returns its id. The dispatch writes one row per recipient. - **NotificationQueue::process()**: Delivers one row through the mail or text module. The panel's manual retry uses this same path, so a row that fails by hand fails the same way in the background. - **the scheduled drain**: Runs every minute, reclaims rows stranded by a crash, and hands out at most two hundred per tick. Because the body was built at dispatch time, editing a template does not change a message already waiting in the queue. ### Pitfalls > **A missing language file is silence, not an error** > > The path is built from the recipient's language with no fallback. If the file is not there nothing is produced and the recipient is skipped. The dispatch still reports success for everyone else. Measured in the shipped tree: eight events exist in English and are absent in German. > **One bad tag leaves the whole file unrendered** > > Compilation is all or nothing, and a failure is caught and answered with the raw source. A stray placeholder does not fail on its own line. Every other placeholder in the file ships unrendered too, and the customer receives a message full of visible braces. The warning goes to the log, not to the operator. > **Under the base design there is no override layer** > > The base design ships with the product. An edit made while it is active is written straight over the shipped file, and the next update replaces it. Any other design saves into `custom/`, which an update cannot touch. > **Files alone are not an event** > > Adding three files gives you nothing. Without a configuration entry the switch cannot be found and the dispatch answers `disabled`. Without a resolver case the variables never exist, and without a label the panel lists the raw key. The trio is one of several places that have to agree. ### Related Articles - [Writing an Email Template](https://dev.wisecp.com/en/writing-an-email-template) - [Writing an SMS Template](https://dev.wisecp.com/en/writing-an-sms-template) - [Notification Template Variables](https://dev.wisecp.com/en/notification-template-variables) - [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module) - [Writing an SMS Module](https://dev.wisecp.com/en/writing-an-sms-module) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Writing an Email Template https://dev.wisecp.com/en/writing-an-email-template Add a new mail to the platform by writing three small files and registering them in four other places. None of those steps can be skipped. ### Overview What you author for a mail is a **fragment** and a subject line. The document around it, the logo, the footer, the contact block and the colours all come from the shared shell. A template that opens its own document tag fights the frame. The rest of the work is registration. Without a configuration entry the event has no switch and is reported as disabled. Without a resolver case the variables the body prints do not exist, and without a label the panel lists the raw key. ### Prerequisites - **a group that has a resolver**: Put the event in an existing group whenever you can. A new group needs a resolver and an entry in the resolver map. A group without one answers every dispatch with an error. - **the engine setting**: Templates are written for the configured engine, and the shipped tree is Smarty. Check `options/notification-template-engine` before you copy placeholder syntax from somewhere else. - **every installed language**: There is no fallback between languages. Ship the trio in each language the installation has, or the recipients reading in the missing one get nothing at all. - **a way to read what was sent**: A sandbox mail module writes each outgoing message to disk instead of opening a connection. It is the fastest way to inspect a body, and the only safe one on an installation with real addresses in it. ### Structure #### The Fragment Contract The shell's header opens the outer table and leaves it open; the footer closes it. Your body sits between the two, which fixes what it may begin and end with. ```bash {lang}/header.html opens the page table, prints the logo and the site title, leaves it OPEN ↓ {lang}/content.html a single {$notifi_body} placeholder ↓ YOUR FILE one or more table rows: it continues the open table ↓ {lang}/footer.html the contact block, the links, the company details, then CLOSES everything ``` - **rows, not a page**: Begin with a table row and end with one. No document tag, no head, no body tag, and no stylesheet: the frame already opened all of them. - **presentation goes inline**: Mail clients drop stylesheets, so every shipped body carries its styling as an attribute on each element. Tables are used for layout for the same reason. - **colours come from the installation**: Three are injected: `theme_color1`, `theme_color2` and `theme_text_color`. Each holds the digits **without** the leading marker, so a template writes the marker itself and the value after it: `bgcolor="#{$theme_color1}"`. Never hard-code a brand colour. - **logic can hide in comments**: The shipped templates wrap loops and conditions in HTML comments so a visual editor does not mangle them. The engine still executes them; only the comment markers survive into the output. #### The Three Files | File | Read for | Contains | | --- | --- | --- | | `{name}.html` | mail | The body fragment. Wrapped in the shell before sending. | | `{name}.json` | mail | `{"subject": "..."}`. Built with the same variables, so it may carry placeholders. | | `{name}.txt` | text message | The text body, sent with no shell. Required even if the event never uses the text channel, because the file is what the panel edits. | ### Walkthrough #### 1. Write the Trio 1. Pick the group and a lower-case, dash-separated event name. The pair is the identity of the event everywhere else. 2. Create `{name}.html`, `{name}.json` and `{name}.txt` under every installed language directory. 3. Copy the closest shipped body rather than writing table markup from scratch. The spacing and the colour variables are already correct there. 4. Keep the placeholders in the engine's syntax. A placeholder written in another engine's syntax is not an error you will see. It is a compile failure that leaves the entire file unrendered. #### 2. Register the Event 1. Add an entry under the group in `coremio/configuration/notifications.php`. The keys are listed in the reference below. 2. Set `status` to 1 and turn on the channels the event actually uses. Everything left at 0 stays silent. 3. Reload and confirm the event now appears in the panel's template list. If it does not, the entry is under the wrong group key. #### 3. Feed the Variables 1. Open the group's resolver in `coremio/helpers/notification.php` and add a case for the event name. 2. Set only what the group's baseline does not already provide. The invoice, service, order, domain and ticket resolvers each build a full set before the switch runs. 3. If the mail must reach an address that is not the account's, override the recipient inside the case. Doing it there rather than at the call site keeps every existing caller behaving as before. 4. Call the dispatch and read the returned `status`. An `error` here means the resolver returned nothing, usually because the entity could not be loaded. #### 4. Decide When It Goes 1. By default the message is queued and leaves within a minute. 2. An event the customer is *waiting on* (a code, a link, an invitation) is delivered inline instead. Add its name to the `$sync_notifications` list, in the same file as the resolver. 3. A single call can override that list without changing it: pass `'_sync' => true` in the context. Use the list for an event that is always urgent, the context key for one caller that is. 4. Inline delivery happens in the request, so it also fails in the request. Only put an event there when the delay is worse than the risk. #### 5. Make It Previewable and Named 1. Add a human label for the event key to the admin notification language files, in every language. Without it the panel prints the raw key in its list. 2. Add sample values for the event's own variables to the preview operation in `coremio/operations/AdminNotifications.php`. The preview does not run the resolver, so anything it is not given comes out as an empty string. 3. Open the preview. It is also the only place a compile error is shown rather than logged. #### 6. Verify It 1. Trigger the real flow, not the preview. 2. Read the delivered message from the sandbox mail module's output directory and check that no placeholder survived into the text. 3. Repeat with an account in the other language. That catches a trio you created in only one language directory. ### Reference #### The Configuration Entry One array per event, under its group. Only `status` and the four channel switches decide whether anything is sent. The rest shape who receives it and what the panel shows. ```php return [ 'notifications' => [ 'invoice' => [ 'invoice-created' => [ 'variables' => '{invoice_idn},{invoice_total},{invoice_payment_link},{items}', 'emails' => '', 'phones' => null, 'departments' => ['4'], 'status' => 1, 'user-mail' => 1, 'admin-mail' => 0, 'user-sms' => 1, 'admin-sms' => 0, ], ], ], ]; ``` - **status**: The master switch. 0, or a missing entry, makes every dispatch answer `disabled` without touching the template at all. - **user-mail · user-sms**: Whether the account holder is written to on that channel. A text message also needs a mobile number on the account; without one the recipient drops out of the list. - **admin-mail · admin-sms**: Whether staff are written to as well. The recipients are resolved from the departments below, plus the addresses in the two fields after them. They fall back to the root administrator when the switch is on but nothing resolved. - **emails · phones**: Comma separated extra staff recipients, added on top of the departments. Read only when the matching admin switch is on. - **departments**: Support department ids whose staff receive the staff copy. An array of id strings, and the usual way to route an event to the right team instead of to everyone. - **user-notification**: Whether the recipient also gets an in-app row. Absent means on, which is why existing events keep their bell after a new key is introduced. - **admin-notification**: Whether staff get an in-app copy. When the key is absent the answer is derived. It is on for a short list of events staff must act on, and otherwise follows `admin-mail`. - **send-pdf**: Invoice group only. Absent means on, so an invoice mail attaches the generated document unless the entry says otherwise. - **variables**: Editor metadata, nothing more. It fills the badge list the operator sees while editing and is never consulted when a message is built. An out-of-date list misleads the operator but breaks nothing. #### Building a Message Directly The dispatch calls this once per recipient. Call it yourself only when there is no dispatch to make. That means a raw address list, or anything where you already hold the variables. ```php public static function notifications( $type = 'mail', // 'mail' | 'email' (alias) | 'sms' — picks .html or .txt $template_name = '', // "group/name", no extension $content = '', // pass a body to render THAT instead of reading the file $variables = [], // your variables; the platform's are added on top $lang = '', // empty falls back to the currently selected language $user = 0 // a user id fills every user_* variable from the account ): array; // ['content' => ..., 'subject' => ...]; EMPTY array on failure ``` - **the return is the failure signal**: An empty array means the file was not found or was empty. There is no exception and no log line, so a caller that does not check it sends nothing and reports success. - **what gets added on top**: Logos, colours, site title, company details, contact link, current year, and the whole recipient profile when a user id was passed. Your own variables win over none of these, so do not reuse their names. - **building is not sending**: The call returns a finished body; nothing has left the installation. Hand the result to the queue, or to the low-level sender, depending on whether it may wait. ### Example A complete event, in the order the platform reads it. ```json {"subject":"{$service_name} has reached {$quota_percent}% of its quota"} ``` ```smarty

    Dear {$user_greeting_name},

    {$service_name} has used {$quota_percent}% of its allowance on {$service_domain}.

    New uploads are refused until the allowance is raised.

    Open the service
    ``` ```php // coremio/helpers/notification.php, inside resolve_service_context(). // service_name, service_domain and service_detail_link are already built above // the switch; only what is specific to this event belongs inside it. switch ($name) { case 'acme-quota-reached': $variables['quota_percent'] = (int) ($context['percent'] ?? 0); break; } ``` ```php // 'entity' is what every resolver accepts; each group also takes its own alias // ('invoice', 'service', 'order', 'ticket'). A row or an id both work. $result = \Notification::dispatch('service', 'acme-quota-reached', [ 'entity' => $service, 'percent' => 92, ]); // Never treat the return as a boolean. 'disabled' means the operator turned the // event off and is not a failure; 'error' means the resolver could not build it. if (($result['status'] ?? '') === 'error') Logger::getInstance()->warning('quota notice not built', [ 'service_id' => $service['id'], 'message' => $result['message'] ?? '', ]); ``` ### Pitfalls > **Nothing is escaped for you** > > This engine prints values exactly as they arrive, markup included, unlike the theme engine which escapes by default. Anything a customer or a third party typed needs the escape modifier before it reaches a mail body. A ticket message above all. > **Some variables are real credentials** > > The service variable set includes the decrypted account password and the server login. Printing it puts a working credential in an inbox and in the mail log forever. Link to the service page instead, and reserve the credential for the one message whose entire purpose is delivering it. > **A preview that looks right proves less than it seems** > > The preview builds its own sample values and never calls the resolver. A body full of variables nobody supplies still looks perfect there. Only a real dispatch proves the wiring. > **One event, several bodies** > > The template is read once per recipient, in that recipient's own language. A staff copy of a customer event comes from the same file with the same wording. Avoid a sentence that only makes sense addressed to the customer. ### Related Articles - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) - [Notification Template Variables](https://dev.wisecp.com/en/notification-template-variables) - [Writing an SMS Template](https://dev.wisecp.com/en/writing-an-sms-template) - [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module) - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) ## Writing an SMS Template https://dev.wisecp.com/en/writing-an-sms-template The text message body of an event is a single plain file with no shell around it. That makes it the shortest template to write, and the easiest one to fill with markup by accident. ### Overview Every event carries a `.txt` alongside its mail body, and that file is the whole message: nothing is prepended or appended. Whatever the file produces is what arrives on the handset. It is the same engine, the same variables and the same event as the mail. The differences all follow from the channel. There is no frame to inherit context from and no markup to lean on. An account without a mobile number is not a recipient at all. ### Prerequisites - **an event that already exists**: A text body is one third of an event, not an event of its own. The configuration entry, the resolver case and the panel label are shared with the mail, so wire those first. - **the text channel switched on**: The file is read only when `user-sms` or `admin-sms` is set in the event's configuration entry. With both at 0 the file is inert no matter what it contains. - **a mobile number on the account**: The recipient's number and its dialling code come from the account profile. - **a sandbox text module**: A driver writes each message to disk instead of calling a gateway. That lets you read the exact delivered characters, including the ones a browser would have hidden. ### Structure #### No Shell, No Markup The mail path concatenates a header, a body slot and a footer first. The text path skips that entirely, so the file stands alone. ```smarty Your service has been activated. Service Information ------------------------------------ {$service_name} - {$service_amount} Service Details ------------------------------------ {$service_detail_link} ``` - **say who you are**: The only branding is the sender name the gateway shows and whatever the text says. A message that opens with "your service" and never names the site reads like a stranger's. - **plain text, and nothing escapes it**: The engine does not escape and does not strip. A variable holding rich text prints its tags verbatim into a message that cannot display them. - **links are long**: Detail and payment links are absolute and carry tokens, so one of them can be most of the message. Put it last, on its own line, and do not wrap it in punctuation a handset will swallow into the address. #### Which File the Channel Reads | Channel | Body file | Shell | Subject | | --- | --- | --- | --- | | mail | `{name}.html` | header, content slot, footer | `subject` from the `.json`, with variables filled in | | text message | `{name}.txt` | none | none: the `.json` is not read for this channel | The engine understands a `title` key in the `.json` and would return it for the text channel. The dispatcher does not carry it further, and no shipped template declares one. ### Walkthrough #### 1. Write the Body 1. Open the event's `.txt` in every installed language directory. It was created with the trio; if it is missing, the text channel has nothing to read. 2. Write the message as a customer would want to receive it: what happened, to which service, and where to look. One or two short lines and a link. 3. Use the same placeholder syntax as the mail body. The engine is the same, so a syntax error fails the same way and leaves the whole file unrendered. 4. Prefer the short variables. A description field or a ticket message is not sized for this channel even when it fits. #### 2. Enable the Channel 1. Set `user-sms` to 1 in the event's configuration entry for a message to the account holder. Set `admin-sms` for a copy to staff. 2. Staff numbers resolve from the event's departments plus the entry's own phone list. They fall back to the root administrator when the switch is on and nothing else resolved. 3. Leave both at 0 for any event whose text version is not worth a charge. Most events ship that way on purpose. #### 3. Keep It Plain 1. Strip anything that might carry markup at the point of printing: a ticket reply, a description, an operator-written note. 2. Watch the alphabet: one character outside the basic set changes how the whole message is counted. 3. Keep the wording independent of the mail. The text version is the message for a reader who will never open the mail. #### 4. Verify the Send 1. Trigger the real flow with an account that actually has a mobile number, and check the returned status. A `no_recipients` answer with the mail arriving normally means the number is what is missing. 2. Read the delivered file from the sandbox module's output directory. It records the sender name, the destination and the exact body, so trailing whitespace and stray tags become visible. 3. Repeat in the other language. A text body is small enough that a missing translation is easy to overlook and produces silence rather than an error. ### Reference #### Length and Encoding The notification path does not measure, split or truncate a text body. It reads the file and hands the string to the module. Any limit is the gateway's, and any cost is per message part. That arithmetic exists in the platform, in the credit-funded international messaging panel rather than in notification templates. It is still the right model to size a template against. | Encoding | When it applies | Single message | Per part once split | | --- | --- | --- | --- | | basic alphabet | every character is in the standard messaging alphabet | 160 | 153 | | wide | one single character outside it, anywhere in the message | 70 | 67 | - **Sms::analyze_message()**: Returns the encoding, the counted length, the number of parts and whether it had to be cut. Called by the panel's quoting path, never by a notification, so nothing here trims a template for you. - **some characters count twice**: In the basic alphabet a handful of symbols are transmitted as two units. In the wide encoding an emoji counts as two. A body sized by eye is routinely one unit over. - **the part ceiling is configurable**: The panel refuses to go beyond a configured number of parts, six by default. It cuts the text rather than letting the gateway drop it. A notification has no such guard. #### What the Module Receives A text module is handed a body, a destination and a dialling code, and is asked to submit. The notification path uses only this much of its surface. ```php $sms = new $smsModule(); // The already-rendered body. Passing a template name here instead is the module's // own shortcut and is what the low-level sender uses; the dispatch never does. $sms->body($item['body']); // One recipient, or a list. The dialling code is a separate argument because the // account stores it separately from the number. $sms->addNumber($item['recipient'], $item['recipient_cc']); $sent = $sms->submit(); if (!$sent) $error = $sms->getError(); ``` - **the sender name is the module's**: It is set from the module's own configuration when the module is constructed, and the delivery path never overrides it. A template cannot change who the message appears to be from. - **the dialling code travels separately**: Number and code are two fields on the account and two arguments here. A module that concatenates them itself decides its own format. That is why a number that works on one gateway can fail on another. - **a successful send is logged with its body**: The message log keeps the sender name, the text and the destinations. Useful for support, and a reason not to print anything secret into a text body. ### Example The text half of an event, next to the mail half it shares everything else with. ```smarty {$company_name}: {$service_name} has used {$quota_percent}% of its quota. {* A condition costs nothing in the output, so the message stays one line longer only when it has to. Comments like this one are stripped entirely. *} {if $quota_percent >= 100}New uploads are refused until the allowance is raised. {/if} {$service_detail_link} ``` ```smarty {* {$admin_reply} is the reply as it was stored: line breaks were turned into newlines, but every other tag the editor produced is still in there. *} Ticket {$ticket_num} has been answered. {$admin_reply|strip_tags|truncate:120} {$ticket_link} ``` ```php // One call. Which files are read depends on the recipient's channel, and both // channels can be produced for the same person in the same dispatch. $result = \Notification::dispatch('service', 'acme-quota-reached', [ 'entity' => $service, 'percent' => 100, ]); // Every recipient row says which channel it was written for, so a missing text // message is visible here rather than only in the gateway's report. foreach ($result['items'] ?? [] as $item) if (($item['channel'] ?? '') === 'sms') Logger::getInstance()->info('quota notice queued as text', [ 'recipient' => $item['recipient'] ?? '', 'queue_id' => $item['queue_id'] ?? 0, ]); ``` ```php // Overrides the four channel switches for this call only and drops the staff copy. // It also bypasses the recipient's category preference, so reserve it for a message // the account explicitly asked for, such as a verification code. $result = \Notification::dispatch('user', 'gsm-activation', [ 'entity' => $userId, 'code' => $code, '_force_channels' => ['sms'], '_sync' => true, // deliver inline: the customer is waiting on this ]); ``` ### Pitfalls > **Rich text reaches the text channel intact** > > Ticket messages are stored as the editor produced them; only the line breaks are normalised on the way out. Printing one into a text body sends the tags along, and the recipient pays for the characters. Strip at the point of printing rather than trusting the variable. > **No number, no recipient, no error** > > An account without a mobile number never enters the recipient list. The mail still goes out, the dispatch still reports success, and the text message that was never sent leaves no trace. When the text half seems missing, check the account before the template. > **One accented letter more than halves the room** > > The counting switches to the wide encoding as soon as a single character falls outside the basic alphabet. The limit drops from a hundred and sixty to seventy. A translation that reads as long as its English original routinely costs twice as many parts. > **An empty body is a decision, so make it one** > > A file that produces nothing is skipped silently. That is exactly right when the event has no text version, and indistinguishable from a mistake when it does. If an event should not send text, turn the channel off in its configuration entry. An empty file is not a way to say it. ### Related Articles - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) - [Writing an Email Template](https://dev.wisecp.com/en/writing-an-email-template) - [Notification Template Variables](https://dev.wisecp.com/en/notification-template-variables) - [Writing an SMS Module](https://dev.wisecp.com/en/writing-an-sms-module) ## Notification Template Variables https://dev.wisecp.com/en/notification-template-variables Exactly what a notification template can print, and which of the three layers each name comes from. Also which of them silently overwrite anything you set yourself. ### Overview A template receives one flat set of names. It is assembled from three sources in a fixed order. Knowing which source a name comes from answers the two questions that actually come up. Why did a placeholder come out empty, and why was the value you passed ignored? - **the installation layer**: Logos, colours, company details, contact links, the current year. Added to every template of every event, whether the event asked for them or not. - **the recipient layer**: The account the message is being built for. Present only when a user id reached the build call. That is why the same name is filled in one flow and empty in another. - **the event layer**: What the group's resolver built from the entity, plus whatever the resolver's case for that specific event added. This is the layer you extend when you add an event. ### Structure #### Assembly Order The event layer is assembled first and the other two are laid over it, not under it. That is the source of nearly every surprise in this article. ```bash the resolver entity -> the event's variables (yours) ↓ View::notifications adds template_name and template_type ↓ variables_handler adds the recipient block (user id > 0 only) adds the installation block (always) ↓ ^ both OVERWRITE what is already there, with four exceptions the engine renders the body with the merged set ↓ the rendered body becomes notifi_body ↓ the engine renders the shell, then the subject, with the same set ``` - **four names defer to you**: The recipient's display names are only filled when the resolver did not already set them. Those four are the full name, the first name, the surname and the greeting name. Every other name in both platform layers is written unconditionally. - **no user id, no recipient block**: The whole recipient layer is skipped when the build is called with a user id of zero. A message aimed at an address that is not an account leaves every one of those names empty. - **the body is a variable too**: The finished event body is assigned as a variable and the shell prints it. That name is meaningful in the shell files only. Printing it inside an event body prints nothing, because it does not exist yet. #### Declared Versus Injected Two lists exist and they are not the same list. One drives the badges an operator sees while editing a template; the other is what actually arrives. Neither is a filter: a name absent from both still prints if the resolver set it. | List | Built by | Used for | Effect at build time | | --- | --- | --- | --- | | the platform sets | `Notification::variables()` | the editor's badge list | none | | the group baseline | `Notification::group_variables()` | the editor's badge list | none | | the entry's own list | the `variables` key in the configuration entry | the editor's badge list | none | | what is actually injected | the resolver, then the build call | building the message | everything | Because the first three are documentation rather than mechanism, they drift. Measured against the shipped code: the recipient set declares seventeen names while nineteen are injected. The installation set declares one name that the build call adds separately. The gaps are listed with the tables below. ### Reference #### The Signatures ```php // coremio/helpers/notification.php // $type is 'system' | 'user'. Anything else returns $variables with the braces // stripped, which is how the configuration entry's CSV becomes a badge list. public static function variables(string $type = '', array $variables = []): array; // The group baseline the editor shows: 'invoice' | 'order' | 'service' | 'domain' // | 'user-tickets' | 'admin-tickets'. Every other group returns an empty array. public static function group_variables(string $group = ''): array; // coremio/classes/View.php public static function notifications($type = 'mail', $template_name = '', $content = '', $variables = [], $lang = '', $user = 0): array; // Merges the two platform layers into $variables, then renders $str IN PLACE. // $str is by reference: it is both the template source and the result. public static function variables_handler($type, $user_id = 0, $variables = [], &$str = '', $lang = ''): void; // coremio/classes/TemplateEngine.php // $engine is 'smarty' | 'twig' | 'none'. Returns the ORIGINAL string on any // compile error, so a failure looks like a template that did nothing. public static function render_notification($engine, $content, $variables = []): string; ``` - **Notification::variables()**: The two platform sets by name. With any other argument it becomes a small utility that strips the braces off a list. That is how the configuration entry's comma separated value turns into badges. - **Notification::group_variables()**: The group's baseline by name. The answer for a group without one is an empty array, not an error. An unbaselined group shows a short badge list rather than a broken editor. - **View::notifications()**: Reads the files, merges the layers, builds body, shell and subject. Everything in this article happens inside one call to it. - **View::variables_handler()**: Where both platform layers are actually written, and where the overwrite rule lives. It works in place through its by-reference argument and returns nothing. - **filter:notification.render_variables**: Runs once per recipient, immediately before that recipient's body is built. The way to add a name without touching a resolver, and the only one that can vary the value per recipient. The variable set arrives by reference: write into it, because the return value is ignored. - **filter:notification.template_merge_fields**: Editor time only. Adds a name to the badge list an operator sees while editing, and has no effect on the message whatsoever. The list arrives by reference too; append to it, and the return value is ignored. #### The Installation Layer Twenty two names are declared and all of them are injected every time. One more is added by the build call and never declared. | Name | Holds | Worth knowing | | --- | --- | --- | | `website_url` | The installation address. | Rewritten to a secure scheme when the installation forces one. | | `website_domain` | The host on its own, with no scheme. | For prose, not for building a link. | | `website_title` | The site title, in the message language. | Comes from the website translations, not from the company details. | | `company_name` | The legal name. | Falls back to the first line of the information block when unset. | | `website_infos` | The multi-line information block. | Line breaks are converted to markup for mail and left alone for text messages. | | `website_address` | The postal address, per language. | A language specific address overrides the general one. | | `website_emails` | Public addresses, joined into one string. | Already a string, not a list: it cannot be looped. | | `website_phones` | Public numbers, joined into one string. | Same shape as the addresses above. | | `website_contact_url` | The contact page, in the message language. | Localised per recipient, so it differs between two rows of one dispatch. | | `support_link` | The ticket creation page. | Pair it with the flag below before printing it. | | `is_enable_support` | Whether ticketing is on. | A boolean, for a condition. The shipped footer hides its whole contact block behind it. | | `website_header_logo` | The site's light logo. | An absolute address. | | `website_footer_logo` | The site's dark logo. | An absolute address. | | `notifi_header_logo` | The mail specific logo. | Falls back to the site logo. A vector file is swapped for a raster one when it exists, because mail clients cannot draw vectors. | | `notifi_footer_logo` | The mail specific dark logo. | Same fallback and the same substitution. | | `theme_color1` | The primary colour. | Digits only, with no leading marker: the template writes the marker itself. | | `theme_color2` | The secondary colour. | Same shape. | | `theme_text_color` | The body text colour. | Same shape. | | `social_links` | A list of social profiles. | A list of rows, see the shapes below. Empty when none are configured, so guard the loop. | | `current_year` | The year, four digits. | For a copyright line, so it never goes stale. | | `template_name` | The event, as `group/name`. | Set by the build call itself. | | `notifi_body` | The finished event body. | Declared here, but it only exists once the body is built. Usable in the shell, empty in an event body. | | `template_type` | The channel: mail or sms. | **Injected but not declared**, so it never appears in the editor's badge list. Lets one shared partial branch on the channel. | #### The Recipient Layer Present only when the build was given a user id. Seventeen names are declared; two more are injected without being declared. | Name | Holds | Worth knowing | | --- | --- | --- | | `user_greeting_name` | The company name if there is one, otherwise the full name. | The right one to open a message with. **Deferred**: a resolver that already set it wins. | | `user_full_name` | First and last name. | **Deferred** to the resolver. | | `user_name` | First name. | **Deferred** to the resolver. | | `user_surname` | Last name. | **Deferred** to the resolver. | | `user_company_name` | The company name, empty for an individual. | Overwritten unconditionally. | | `user_email` | The account address. | The address on the account, not necessarily the one this copy is going to. | | `user_phone` | The phone, prefixed when present. | Null rather than empty when the account has none. | | `user_id` | The account id. | Useful in a reference line, meaningless to a customer on its own. | | `user_group` | The customer group name. | Empty when the account is in no group. | | `user_country` | Country name from the primary address. | Null when there is no address on file. | | `user_city` | City. | Same source and same caveat. | | `user_state` | State or province. | Same source and same caveat. | | `user_address` | Street address. | Same source and same caveat. | | `user_zipcode` | Postal code. | Same source and same caveat. | | `user_ip` | The address recorded on the account. | Registration time, not the address of whatever triggered this message. | | `user_login_link` | The sign-in page, in the message language. | Localised per recipient. | | `user` | The whole account row plus its address. | A collection, see the shapes below. Reach for a named variable first. | | `admin_login_link` | The panel sign-in page. | **Injected but not declared.** For staff copies; do not print it in a customer facing body. | | `user_pass` | Five asterisks. | **Injected but not declared**, and a mask rather than a value. It exists so an older template that prints it shows a mask instead of a blank. | #### The Event Layer, by Group What the group's resolver builds before it looks at the event name. Only six groups declare a baseline; the rest build their set entirely inside the event's own case. - **invoice**: `invoice, invoice_idn, invoice_payment_link, invoice_subtotal, invoice_total, invoice_tax_rate, invoice_tax, invoice_date_created, invoice_date_due, invoice_date_paid, invoice_date_taxed, invoice_payment_method, invoice_remaining_day, invoice_delayed_day, invoice_refund_date, invoice_cancelled_date, legal_invoice_download_link, items`. Amounts arrive already formatted with their currency symbol, so do not format them again. The last four are set by their own events only. - **order**: `order, order_id, order_number, order_name, order_amount, order_currency, order_payment_method, order_status, order_detail_link, order_date_created, order_date_start, order_date_end, order_period, order_period_unit, order_group_name, order_category_name, order_services_summary, items`. The item collection has a different shape from the invoice one: one row per purchased product, with its add-ons nested. - **service**: `service, service_id, service_order_id, service_name, service_type, service_module, service_subscription_identifier, service_period, service_period_unit, service_period_time, service_cycle, service_amount, service_date_created, service_date_start, service_date_end, service_detail_link, service_group_name, service_category_name, service_domain, service_ip, service_requirements, service_addons, service_server_ip, service_server_hostname, service_server_port, service_ns1, service_ns2, service_ns3, service_ns4, service_username, service_password, service_assigned_ips`. The last group of names are live access details, including a decrypted password: see the pitfalls. - **domain**: The entire service set above, plus `domain, day, grace_days, redemption_days, redemption_fee, days_past_due, domain_transfer_code, reason`. A domain is a service, so its templates can print any service name as well. - **user-tickets and admin-tickets**: `ticket, ticket_id, ticket_num, ticket_link, ticket_subject, ticket_department, ticket_service, ticket_status, ticket_priority, ticket_admin_name, ticket_date, ticket_last_reply_date, ticket_assigned_by_admin, user_last_message, admin_last_message, user_reply, admin_reply`, plus the entire service set when the ticket is attached to one. The link differs by group: the staff set points into the panel, the customer set into the portal. - **user, admin-messages, sms-intl**: No baseline at all. Each event's case builds exactly what its template needs. Two events in the same group can share almost no names. #### Collections and Their Keys Four of the names are not strings. Printing one directly shows nothing useful; these are for a loop or for a keyed read. | Name | Shape | Keys on each row | | --- | --- | --- | | `items` (invoice) | rows, one per invoice line | `id, owner_id, user_id, user_pid, description, quantity, amount, total_amount, currency, rank, amountF, service_id, service_domain, service_ip, service_group_name, service_category_name`. A renewal line also carries `service_type, service_old_duedate, service_new_duedate`. | | `social_links` | list of rows | `name, url, icon`. The shipped shell builds its image file name from the lower-cased name. | | `user` | one row | The account columns, plus `address` holding the primary address. Every field worth printing already has a named variable. | | `service_addons` and `service_requirements` | lists of rows | The stored rows as they are, unformatted. Loop them only when the template really has to itemise; there is no ready formatting behind them. | A formatted amount ends in a capital F on the invoice item rows. The bare key is the raw number; the one ending in F is the string to print. Getting that pair the wrong way round prints an unformatted figure with no currency on it. #### Per Event Extras Beyond the baseline, a resolver's case adds what only that event needs. A representative sample, with what the caller has to pass for each. | Event | Adds | Fed by the context key | | --- | --- | --- | | `user/two-factor-verification` | `code` | `code` | | `user/email-activation` | `activation_code, activation_link` | `code`, `activation_link`, optional `to_email` to redirect the message | | `user/email-changed` | `old_email, new_email` plus the device block | `old`, `new`, optional `to_email` | | `user/password-changed` | `reset_password_link` plus the device block | `reset_link` | | `invoice/invoice-reminder` | `invoice_remaining_day` | `remaining_day` | | `invoice/invoice-overdue` | `invoice_delayed_day` | `delayed_day` | | `invoice/invoice-auto-payment-failed` | `error_message, card_ln4` | `error_message`, `card_ln4` | | `admin-messages/backup-completed` | whatever the caller built | `variables`, forwarded verbatim | The device block referred to above is a small fixed set added to the security events. It carries `browser`, `platform`, `ip`, `location_country`, `location_city` and `date`. The two location names are placeholders and are always empty, so a template that prints them prints nothing. ### Example All three layers in one fragment, and then the two supported ways to add a name of your own. ```smarty {* recipient layer *} Dear {$user_greeting_name}, {* event layer: already formatted with its currency, so it is printed as-is *} Invoice {$invoice_idn} for {$invoice_total} is due on {$invoice_date_due}. {* event layer, a collection: amountF is the formatted string, amount is the number *} {foreach from=$items item=item} - {$item.description} {$item.amountF} {if $item.service_domain != ""}({$item.service_domain}){/if} {/foreach} {* installation layer, guarded because the list can be empty *} {if $is_enable_support}Questions: {$support_link}{/if} {$company_name} {$current_year} ``` ```php // coremio/helpers/notification.php, in the group's resolver. // Do not reuse a platform name: user_email and website_url are written after // this runs and would overwrite whatever you put there. switch ($name) { case 'acme-quota-reached': $variables['quota_percent'] = (int) ($context['percent'] ?? 0); $variables['quota_limit'] = Money::formatter_symbol( (float) ($context['limit'] ?? 0), (int) ($service['amount_cid'] ?? 0), ); break; } ``` ```php // Runs once per recipient, right before that recipient's body is rendered, so it // can also vary the value per recipient. The first argument is by reference. Hook::add('filter:notification.render_variables', 1, function (&$variables, $group, $name, $recipient) { if ($group !== 'service') return; $variables['acme_portal_link'] = 'https://portal.example.com/s/' . (int) ($variables['service_id'] ?? 0); }); // Editor only: puts the name in the badge list the operator sees while editing. // It changes nothing at render time, so both listeners are needed for a name that // is meant to be discoverable as well as printable. Hook::add('filter:notification.template_merge_fields', 1, function (&$fields, $group, $name) { if ($group === 'service') $fields[] = 'acme_portal_link'; }); ``` ### Pitfalls > **One of these names is a working password** > > The service set carries the stored credential decrypted, next to the server address, the port and the username. Printing it puts a live login into an inbox and into the message log permanently. Link to the service page instead, and keep the credential for the single message whose whole purpose is to deliver it. > **Reusing a platform name loses your value** > > Both platform layers are laid over the resolver's set, and only the four recipient display names defer to what is already there. Set an address, a link or a colour under a platform name and it is overwritten before the template ever sees it. Nothing is logged. > **A name that does not exist prints as nothing** > > There is no warning and no marker in the output. A misspelt placeholder looks exactly like a value that happened to be empty. When a line disappears from a message, check the spelling against the tables here before looking at the resolver. > **The declared list is documentation, not a contract** > > The badge list an operator sees is assembled from three static declarations that nothing verifies against the resolver. A name can be missing from it and still print, and it can be listed and never arrive. Trust what the resolver sets, and update the declarations so the operator can trust them too. ### Related Articles - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) - [Writing an Email Template](https://dev.wisecp.com/en/writing-an-email-template) - [Writing an SMS Template](https://dev.wisecp.com/en/writing-an-sms-template) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) ## Shipping Templates with a Module https://dev.wisecp.com/en/shipping-templates-with-a-module Ship a notification with your module, so enabling it is all the operator has to do. ### Overview A module that sends its own mail installs two things. The **registration** puts the template on the Notification Templates screen, where the operator turns it on or off. The **files** put the subject, the body and the text message where they are read from. Skip the first and `dispatch()` returns `disabled`. Skip the second and the mail goes out with no body and no subject. One call does both. - **Notification::seed_templates()**: Adds the missing registration and writes the parts to their roots. An existing file is never overwritten, so an operator's edit survives every re-enable. - **Notification::dispatch()**: Sends it afterwards. A seeded template is dispatched like a core one. ### Prerequisites - A module with an `enable()` path. - A group name. Reuse a core group when the mail belongs to that domain, or use your own. - Read [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) first. It explains the roots this call writes into. ### Structure Keep the templates inside the module, so they travel and are removed with it. Two source shapes are read; use whichever suits you. ```bash coremio/modules/Servers/Acme/ ├── Acme.php └── notifications/ # flat form: one file per part ├── en/ │ ├── acme-quota-reached.json # {"subject": "..."} │ ├── acme-quota-reached.html # the mail body │ └── acme-quota-reached.txt # the text message └── tr/ ... coremio/modules/Servers/Acme/notifications/ # folder form: adds a per-design body └── en/acme-quota-reached/ ├── content.json ├── content.txt ├── content.html # the base design's body └── ledger.html # the Ledger design's own body ``` The folder form is what a release package uses, so a module and a release describe a template alike. ### Walkthrough 1. Write the mail body as a **fragment**. The shell around it comes from the active design. 2. Write one file per language you support. A language you skip has no message; the operator can fill it in. 3. Use Smarty placeholders (`{$service_name}`) and declare them in `settings`, so the panel offers them. 4. Call the seeder from `enable()`. Add a guard that also runs on update: an installation enabled long ago never toggles the module to receive a new template. 5. Send with `dispatch()` and read the returned `status`. `disabled` means the registration is missing or the operator turned it off. ### Reference ```php static function seed_templates(string $group, array $templates, array $options = []): array { // ... } ``` - **$templates[key]['settings']**: The config entry, written only when the key is absent. Anything you omit is defaulted: `status` 1, `user-mail` 1, `admin-mail`/`user-sms`/`admin-sms` 0, empty `emails`/`phones`/`departments`. **Leave the key out entirely** and the config file is not touched at all — for a module that maintains its own entry. - **$templates[key]['source']**: Directory holding the files. Both shapes above are read; the folder form is tried first because it is the one that can carry a per-design body. - **$templates[key]['text']**: Inline alternative to `source`: `[lang => ['subject' => …, 'html' => …, 'sms' => …, 'themes' => [design => html]]]`. A part you leave out is not written. - **$options['themes']**: `'base'` (default) writes the body to the design that ships with the product and lets every other design fall back to it. `'all'` copies that body into each installed design — see the pitfall below before choosing it. - **return**: `['written' => string[], 'skipped' => int, 'registered' => string[]]` — paths written this run, files left alone because they already existed, and the `group/key` pairs added to the config. ### Example ```php private function ensure_notification_template(): void { static $checked = false; if ($checked) return; $checked = true; \Notification::seed_templates('service', [ 'acme-quota-reached' => [ 'settings' => [ 'variables' => '{service_id},{service_name},{quota_usage}', 'status' => 1, 'user-mail' => 1, ], 'source' => __DIR__ . DS . 'notifications', ], ]); } ``` ```php $this->ensure_notification_template(); $result = \Notification::dispatch('service', 'acme-quota-reached', [ 'user_id' => (int) $service['owner_id'], 'variables' => [ '{service_id}' => $service['id'], '{service_name}' => $service['name'], '{quota_usage}' => $usage . '%', ], ]); // 'disabled' is a decision, not a failure: the operator turned this mail off. if (($result['status'] ?? '') === 'error') \Logger::error('Acme quota mail failed'); ``` ### Pitfalls > **A design you do not ship is not a gap** > > A design with no file for the event shows the base body inside *its own* shell. Copying the body into every design is not the same thing. The copy outranks the fallback, so that theme's author can never ship a design for the mail afterwards. > **Registration alone is not enough** > > A config entry with no files sends an empty mail. The first sign of it is a customer complaint. If you maintain the entry yourself, still call the seeder for the files. Pass no `settings` key and your entry is left alone. > **Never build the path yourself** > > The parts live in different roots, and the body's root depends on the active design. Code that joins `templates/notifications/` to a language folder writes where nothing reads. The write succeeds and the mail stays empty. ### Related Articles - [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work) - [Writing an Email Template](https://dev.wisecp.com/en/writing-an-email-template) - [Notification Template Variables](https://dev.wisecp.com/en/notification-template-variables) # Marketplace ## Publishing a Product https://dev.wisecp.com/en/publishing-a-marketplace-product Turn a finished module or theme into a listing customers can find, buy from your own site and install from their panel. ### Overview The marketplace is a catalogue. You keep the sale: the listing links to your own purchase page, and no money passes through this panel. What the panel does is publish the card, hold the package and hand it to buyers who already hold a WISECP licence. A listing is reviewed once. After it goes live, every new version travels through a submission of its own, covered in [Shipping a Release](https://dev.wisecp.com/en/shipping-a-marketplace-release). ### Prerequisites - **An active developer plan**: The portal opens from your customer account. Without a live subscription the editor stays read only and nothing can be submitted. - **A verified identity**: When the operator asks for documents, submitting offers to add them to your account. The listing waits until they are approved. - **An installable package**: One archive holding the whole product. Layout and the files that must never travel with it: [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution). - **A purchase address, for paid listings**: Where the card sends a buyer. A price shown here is a quote; the checkout is yours. ### Structure The editor is a stack of sections with a readiness meter above them. A section counts as done when its own answer is complete, and the server checks that same meter again when you submit. On a draft the sections open in order: the next one waits until the one above it is answered. A published listing is exempt, so you can edit any section of it directly. A Localisation section appears when your installation serves more than one language; it is optional and never counted. | Section | What completes it | | --- | --- | | 1 · Product Identity | Title, category and the one line a customer reads first | | 2 · Product Pitch | A description of at least 120 characters in your primary language, a logo and at least one screenshot | | 3 · Pricing | Free, or commercial with at least one priced offer | | 4 · Licensing | Either no check at all, or a declared check that has passed its test. Asked whatever the product costs | | 5 · Compatibility | Your version, the oldest WISECP it runs on, and the purchase address on a paid listing | | 6 · Installation File | The package | | 7 · Review & Submission | Three confirmations: rights, tested, guidelines. Not asked once the listing is live | ### Walkthrough #### Open a draft 1. Open **Products** in the developer portal and start a new listing. Your first save stores it as a draft, and a draft is private. 2. Fill Product Identity first. Category and the summary line feed the catalogue search, so they are worth the time. 3. Choose the pricing model. Licensing is a step of its own and stays open either way. A free product can lock its downloads behind a check too. #### Add the files 1. Upload the logo and the screenshots buyers will judge the product by. 2. State your version and the oldest WISECP it runs on. The highest supported version is a ceiling. Installations above it cannot download the package; leave it empty for no upper bound. 3. Upload the installation package last, so the build already carries the licence integration settled above it. 4. The panel compiles your upload. Installation File then offers that build back — the exact file your customers receive. #### Submit for review 1. Tick the three confirmations in the Review section. 2. Click **Submit for review**. The listing moves to *Submitted* and the editor becomes read only. 3. While it is only submitted you can withdraw it. Once a moderator picks it up the state becomes *In review*, and withdrawal closes. #### After the decision 1. Approval publishes the card, and a notice reaches your account. 2. A refusal carries the reason a moderator wrote. Fix what it names and submit again. 3. To take a live product out of the catalogue, retire it. Reviews, ratings and download history stay, and buyers keep their copy. 4. A live listing can still take a new package. Sending it starts a review, and the card leaves the catalogue until the decision comes back. The editor says so above the button. ### Reference Where a listing can stand, and what you can do from there. | State | Meaning | What you can do | | --- | --- | --- | | Draft | Never submitted | Edit everything, submit | | Submitted | Waiting in the queue | Withdraw | | In review | A moderator is reading it | Wait | | Published | Live in the catalogue | Ship releases, retire | | Rejected | Refused, with a reason | Edit, submit again | | Cancelled | Closed from the panel side | Edit, submit again | | Retired | Taken down by you | The record stays, the card goes | ### Example The archive you upload holds the product at the path it belongs at, and nothing else. ```bash coremio/ └── modules/ └── Addons/ └── SamplePro/ ├── SamplePro.php ├── config.php ├── hooks.php ├── logo.png └── lang/ ├── en.php └── tr.php ``` Every module lives at `coremio/modules/{Type}/{Name}/`, and the type folder is the only part that changes: a provisioning module under `Servers/`, a registrar under `Registrars/`, a gateway under `Payment/`. A theme is not a module and ships at `templates/website/{ThemeName}/`, with its `theme.php` manifest at the root of that folder. ### Pitfalls > **Buyers never receive the file you uploaded** > > The panel compiles your upload and serves the compiled copy. Publishing waits for that compiled file, so an approved card can still sit unpublished for a while. > **An incomplete listing is refused at submit** > > The meter in the editor is a copy of a rule, not the rule. The server counts the sections again and answers 422 when one is missing. > **Downloads need a WISECP licence** > > Even a free product is handed only to an account holding an active WISECP service. A visitor without one has nothing to install it into. ### Related Articles - [Licensing a Marketplace Product](https://dev.wisecp.com/en/marketplace-product-licensing) - [Shipping a Release](https://dev.wisecp.com/en/shipping-a-marketplace-release) - [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) ## Licensing a Marketplace Product https://dev.wisecp.com/en/marketplace-product-licensing Tell the marketplace how to ask your own server whether an installation holds your product. ### Overview WISECP publishes no licence protocol for the products you sell. You choose the address, what travels with the question, and what a yes looks like. Entitlement is not the panel's to assert, so it asks you, about one thing: the domain of the installation that is downloading. Declaring a check is optional. A paid listing without one publishes, and its download stays open at the same bar as a free product. ### Prerequisites - **A commercial listing**: Appears the moment the pricing model asks for money. A free product has nothing to license. - **A reachable https address**: Public and answering directly. Private addresses are refused, and a redirect is never followed. - **An answer that turns on a domain**: The question is always about one domain. A server that cannot decide from one cannot be wired to this. - **A domain you know is licensed**: The test needs a real yes: an address that refuses everything is still reachable. ### Structure Turn on **Declare a licence check**, then pick which kind. The two are not interchangeable settings; they are two different integrations, and each asks for its own fields. | Mode | Who it fits | What you supply | | --- | --- | --- | | WISECP | You run WISECP yourself and sell the product as software with a licence key | The check address your own build already embeds | | Custom | Anything else: your own API, a licence service, a static allow list | The request and what a positive answer looks like | The panel asks at two moments, never on a page view: a buyer starting a download, and a customer beginning a review. A positive answer is remembered for ten minutes. ### Walkthrough #### Wire the WISECP mode 1. Open your software product in your own panel and go to its **Licensing** tab. The check address is shown there, ready to copy: `https://your-site.example/license/checking/{token}/{product-id}`. 2. Paste it into **Check address**. Nothing else is asked: the request and the answer are both known. 3. The panel calls it with the buyer's domain. Your installation answers with the plain word `OK`, or with the newer signed envelope. Both are read as a yes. > **Copy the address, do not type it** > > That address is the controller path, not the translated route visitors see. The route name changes with the language; this one does not. #### Wire a custom endpoint 1. Enter the address and choose **POST** or **GET**. 2. Name the field the domain travels in. Leave it empty and `domain` is used. 3. Add any constant fields your endpoint needs, one `name: value` per line, and the same for request headers. 4. Describe a yes. Give the status you expect, the body type, the path into it, and the value that means licensed. #### Test it 1. Enter a domain that really holds a licence on your server. 2. Click **Test connection**. The call is made from the panel, not your browser, so your credentials never travel to the page. 3. A pass stamps the declaration and completes the section. Editing any part of the declaration drops that stamp, so test again after a change. #### Check the licence inside your product The declaration above is the download gate. It says nothing about the copy already installed, so the product enforces itself. 1. Ask your own server from inside your code. `License::remote_check()` sends the question and remembers the answer for a day, so a licensed product does not phone home on every page view. 2. Read the verdict yourself. It does not judge the answer or verify a signature; `null` means the server could not be reached, not that the copy is unlicensed. 3. Put the decision in a file you marked for encryption, so the line cannot be deleted. Marking and packaging: [Licensing a Module](https://dev.wisecp.com/en/licensing-a-module). ### Reference The fields of a custom declaration. - **Address**: Required. Redirects are not followed, so paste the final address. - **Method**: POST or GET. A GET carries the fields as a query string. - **Domain field**: The name the domain arrives under. Defaults to `domain`. - **Extra fields**: Optional constants sent on every call, one `name: value` per line. - **Headers**: Optional request headers, same form. Keep any token you send narrow. - **Expected status**: A mismatch is a refusal. Use 0 when your endpoint answers 200 either way. - **Body type**: JSON or plain text. Plain text judges the body itself and asks for no path. - **Path**: A dot separated route into the JSON, such as `data.valid`. JSON with no path is a refusal. - **Expected value**: Compared without case. Leave it empty to accept any truthy value; `0`, `false`, `null`, `no` and an empty value are refusals. What a failed check reports back to you. | Reason | What it means | | --- | --- | | Unreachable | No answer arrived. A declared check that cannot be reached refuses. | | Status | Your server answered, with a status other than the expected one. | | Refused | Your server answered, and the answer did not match what you described. | | Bad address | WISECP mode. The token belongs to another product, or the address carries no token. | | Bad request | WISECP mode. Your endpoint says required fields did not arrive, so something in front of it dropped them. | ### Example A JSON endpoint that answers 200 with a nested verdict. ```json {"data": {"valid": true, "expires_at": "2027-06-01"}} ``` ```ini Address = https://vendor.example/api/licence Method = POST Domain field = domain Extra fields = product: sample-pro Headers = Authorization: Bearer Expected status = 200 Body type = JSON Path = data.valid Expected value = true ``` The same server can answer in plain text instead. Then the body type is text, the path stays empty, and the expected value is the word itself, such as `OK`. Inside the product, the same question is asked with the carrier. The answer is the raw response, so your code decides what it means. ```php $answer = \License::remote_check('https://vendor.example/api/licence', [ 'product' => 'sample-pro', 'domain' => \Utility::getDomain(), ]); // Unreachable is not a refusal: do not lock a customer out over your own downtime. if ($answer === null) return true; $body = \Utility::jdecode((string) ($answer['body'] ?? ''), true); return (int) ($answer['status'] ?? 0) === 200 && ($body['data']['valid'] ?? false) === true; ``` ### Pitfalls > **Silence is not permission** > > Once a check is declared, an unreachable server refuses the download. You said "ask me", and no answer is not a yes. > **The answer is taken at its word** > > This call is made from the panel to your server, and the customer who would gain from a forged yes is not on that path. A signature in the answer is not checked. > **Editing the declaration drops the proof** > > A stamp says "the declaration stored right now answers". Change a header, a path or the address, and the section returns to incomplete until you test again. ### Related Articles - [Publishing a Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Shipping a Release](https://dev.wisecp.com/en/shipping-a-marketplace-release) - [Licensing a Module](https://dev.wisecp.com/en/licensing-a-module) ## Shipping a Release https://dev.wisecp.com/en/shipping-a-marketplace-release Ship a new version of a published product: what it carries, how it is reviewed, and how buyers hear about it. ### Overview A listing is reviewed once. A product keeps changing. Every version after the first travels as a release: a number, a changelog and the whole package. Each one is handed to the moderators and approved on its own. Approval is what publishes. Until a moderator says yes, nothing about the live listing moves and no installation is told anything. After it, the listing states the new version and the catalogue hands out the new package. ### Prerequisites - **A published listing**: Only a live product has a next version. A draft is still its first submission, and a refused or retired listing has nothing to update. - **A number above the live one**: Compared segment by segment, not as text, so 1.10.0 is above 1.9.0. A number already used is refused as well. - **The whole product**: The package is a complete build, never a patch. It replaces the directory on the customer's side. - **A changelog you can write in lines**: Up to 40 lines, each up to 300 characters, each one typed. ### Structure The release editor has the same shape as the product editor. Sections with a readiness meter, and a submit action that counts them again on the server. | Section | What completes it | | --- | --- | | Details | The product, the version, and at least one changelog line | | Package | The full build for that version | | Licensing | Inherited from the listing, so it is already complete unless you change it. Asked whatever the product costs | | Review | The confirmations, as on a listing | Changelog lines carry a kind, and every surface that shows a changelog groups them by it: **features**, **improvements**, **security**, **fixes**, **changed**. One free paragraph reads as prose nobody scans; a typed list says at a glance whether a version carries a security fix. ### Walkthrough #### Prepare the version 1. Open **Releases** in the developer portal and start a new one. Pick the product; the picker offers published listings only. 2. Enter the version number. The live one is shown next to the field, so "must be higher" is a fact on the page. 3. Write the changelog line by line and give each line its kind. 4. Upload the package for that version, and update the compatibility range if this build changed it. #### Check the licence declaration 1. The version carries the declaration of its listing, free or paid. Leave it alone and the section is already done. 2. Change it when the address or the credentials moved, then test it here. Wiring and fields: [Licensing a Marketplace Product](https://dev.wisecp.com/en/marketplace-product-licensing). 3. What you change is parked on this version. It reaches the listing only when a moderator approves, so the key and the build that carries it travel together. #### Submit and wait 1. Submit the release. It moves to *Submitted*, the package freezes and the editor becomes read only. 2. Withdraw is open while it is only submitted. Once a moderator picks it up the state becomes *In review* and it closes. 3. A refusal carries a reason and reaches your account as a notice. Fix it and submit again. #### After approval 1. The listing starts stating this version, and hands out the compiled package built from yours. 2. Installations that hold the product ask once a day and are offered the update. 3. Nothing installs itself. The operator reads the changelog and decides. ### Reference What approval moves from the release onto the listing, at the same instant. - **Version**: The listing starts stating this number, and it becomes the one clients compare against. - **Package**: The download and the update feed both start serving this build. - **Compatibility range**: Moves only when this version set one. A listing never advertises a requirement its own package lacks. - **Licence declaration**: Only when this version parked one. This is the single way a published listing's verified declaration changes. An update is offered only when three things hold at once. Any one failing answers "nothing published" rather than an error, because the buyer did nothing wrong. | Gate | Why | | --- | --- | | The listing is live | A retired or refused product has nothing to push to anyone. | | The developer account is active | A studio switched off here does not ship code into customer panels. | | The customer account behind it is active | You are a customer here too, and a closed account no longer delivers files. | ### Example An installed marketplace product carries a manifest naming the listing it came from. A directory name is your own choice and two studios may pick the same one, so identity travels as the listing id. ```json {"type": "marketplace", "id": 42, "version": "1.2.0", "last_updated": "2026-08-06"} ``` The installation asks about that id and reads back what is published for it. ```bash GET /api/v1/marketplace/releases/42?version=1.2.0&lang=en ``` ### Pitfalls > **A patch is not a release** > > The package replaces the product directory. Ship only the changed files and everything you left out disappears from the customer's copy. > **A submitted release is frozen** > > The package cannot be replaced while a decision is pending. Withdraw it while it is still only submitted, or wait for the answer. > **Your schema changes have to apply themselves** > > Nothing in the update path runs a script of yours. Migrations run from a normal code path, safely, on every request. ### Related Articles - [Publishing a Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Licensing a Marketplace Product](https://dev.wisecp.com/en/marketplace-product-licensing) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) - [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution) ## Protecting a Theme https://dev.wisecp.com/en/licensing-and-protecting-a-theme A theme has no module class, and its templates cannot be encrypted. What can be protected, where the licence check goes, and what a template can honestly enforce. ### Overview Only PHP can be encrypted. In a theme that is `hooks.php`, `theme.php`, and the views themselves only when the theme declares `engine: 'php'`. > **No secrets and no decisions in templates** > > A template prints. The value it compares against and the code that decides live in encrypted PHP. ### Prerequisites - **A licence server of your own**: Where the question goes and what an answer means are yours: [Licensing a Marketplace Product](https://dev.wisecp.com/en/marketplace-product-licensing). - **A hooks.php in the theme**: Optional for an ordinary theme, required for a licensed one. It is the only file that runs before the first page is shown. - **Accepting that assets travel readable**: CSS, JavaScript and images ship as they are. They can be copied; a theme that runs on them alone cannot be installed. ### Structure Three layers, and the first two are the same file. | Layer | Where | What it does | | --- | --- | --- | | Check | `hooks.php`, encrypted | Asks your server. It is included once before the first page is shown, so an unlicensed copy is judged first | | Publish | `hooks.php`, encrypted | Puts a seal into the template data when the answer was yes, and nothing when it was not | | Enforce | every protected template | Compares against the literal seal it carries. The packager encrypts nothing here, so the comparison is the whole of it | The file is included only if it exists. A deleted `hooks.php` publishes no seal, and every protected template fails closed. ### Walkthrough #### Put the check in hooks.php 1. Ask your server from `hooks.php`. It runs once, before the first page is shown, which is the only such place a theme has. 2. Cache the answer. A theme is on every page, so a check without a cache is a request per page view. 3. Decide there, in the encrypted file. What reaches the templates is a value, never a reason. #### Publish the seal from one handler 1. Register a single `filter:template.variables` handler for the theme and fold everything the theme needs into it. 2. Add the seal to the array it returns when the licence answered yes, and leave it out when it did not. 3. Never register a second handler. Only the last returned array survives, so a second registration silently drops the first one's data. #### Compare the value, not a flag 1. Compare against the literal string the template carries. A boolean is worthless here: anyone can register a hook of their own and publish a true. 2. Put the comparison in every template that must not run unlicensed, not in one shared wrapper. One check is one file to find and edit. 3. Make the licensed path produce something the page needs, so removing the check breaks the design instead of hiding a warning. #### Full protection means a PHP theme 1. Declare `engine: 'php'` in the manifest. The views become PHP and can be encrypted with the rest. 2. You give up the sandbox and take on escaping yourself, which is the trade the engine choice states plainly. 3. Assets stay readable either way. ### Reference - **hooks.php**: Encryptable. The check, the cache and the publish live here. - **theme.php**: Encryptable. Manifest: engine, meta and the settings schema. - **views, engine php**: Encryptable, because they are PHP. - **views, tpl or twig**: Not encryptable. Compiled to plain PHP on disk before any check can run. - **assets**: Not encryptable. CSS, JavaScript, fonts and images ship as they are. ### Example ```php \Hook::add('filter:template.variables', 1, function ($template, $data) { $answer = \License::remote_check('https://vendor.example/api/licence', [ 'product' => 'aurora-theme', 'domain' => \Utility::getDomain(), ]); $body = \Utility::jdecode((string) ($answer['body'] ?? ''), true); $ok = $answer === null || ($body['data']['valid'] ?? false) === true; // The seal is added only on a yes. Everything else this theme publishes goes in the // same array: a second handler would drop all of it. if ($ok) $data['aurora_seal'] = 'a7f3c1d90e5b'; return $data; }); ``` ```smarty {if $aurora_seal|default:'' !== 'a7f3c1d90e5b'} {include file='partials/unlicensed.tpl'} {else} {* the section *} {/if} ``` ### Pitfalls > **The seal is not a secret** > > Anyone holding a licensed copy can watch what the licensed path publishes. It is a key with the lifetime of one version. Write a new one in every release, so a value taken from an old build dies at the first update. > **One handler per theme** > > Only the last returned array survives. A second registration drops the seal, the menus and everything else the first one published. > **Weight belongs on the two real defences** > > Make the licensed path carry something the page needs, and report installations so you can see where copies run. Encryption alone was never the theme's protection; the licence is. ### Related Articles - [Licensing a Marketplace Product](https://dev.wisecp.com/en/marketplace-product-licensing) - [Publishing a Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Theme Hooks and Output Filters](https://dev.wisecp.com/en/theme-hooks-and-output-filters) - [The Theme Engine](https://dev.wisecp.com/en/the-theme-engine) # Marketplace / Releasing a Module ## Licensing a Module https://dev.wisecp.com/en/licensing-a-module Whether your module refuses to run unlicensed is your decision, and so is the server that decides it. ### Overview WISECP publishes no licence protocol for the products it sells for you. You choose the address, the fields that go with the question, and what an answer means. Two things are ours. Encryption: mark the files that must not ship readable and the packager encodes them. The carrier is optional, and it remembers the answer for a day. ```php $answer = \License::remote_check('https://your-site.example/license/check', ['domain' => \Utility::getDomain()]); if ($answer === null) return true; // could not ask if (trim($answer['body']) !== 'OK') return false; // your server said no return true; ``` Your own curl is equally welcome. What matters is where the decision sits: inside a file you marked, so the operator cannot delete it. ### Prerequisites - **A module that already works**: Licensing is the last thing you add. Build and test the feature unlicensed, then gate it. - **A server of your own that answers**: Any address you control, in any shape you like. WISECP's own products answer at `/license-verify/checking/{token}/{id}` with the plain words `OK` or `ERROR`. - **curl on the customer's host**: Absent, the carrier returns `null` rather than failing. ### Structure Four pieces carry the feature; the first three are yours. | Piece | Where it lives | What it does | | --- | --- | --- | | Your server | an address you control | Answers in the shape you chose. | | Your check | a private method of your module class | Asks, reads the answer, decides what it means. | | Your gate | where the paid behaviour starts | Calls that method and refuses, degrades or unlocks. | | The answer cache | `coremio/storage/licenses` | One encrypted file per address and question, outside the cache directory. | The cache sits in its own folder on purpose. Admin operations clear the cache with no arguments several times a day. An answer parked there would go with the rest. ### Walkthrough #### Put the gate where the value is Gate the thing the customer paid for, not the module's existence. Treat "could not ask" as its own answer: a host with no curl sends exactly that. ```php /* @wisecp-protected */ private function licensed(): bool { $answer = \License::remote_check('https://your-site.example/license/check', [ 'product' => 'aurora-backup', 'domain' => \Utility::getDomain(), ]); // Could not ask. Your outage is not the customer's fault, so this stays open. if ($answer === null) return true; return $answer['status'] === 200 && trim($answer['body']) === 'OK'; } public function adminArea(): array { if (!$this->licensed()) return [ 'page_title' => $this->lang['name'] ?? 'Aurora Backup', 'content' => $this->view('unlicensed.php'), ]; return [ 'page_title' => $this->lang['name'] ?? 'Aurora Backup', 'content' => $this->view('index.php'), ]; } ``` #### Mark every file that must not ship readable The packager cannot know which file carries your check. Your helper's name is yours and the call appears in one file only. So you declare it. Put the marker on its own line at the top of each file you would not hand over. ```php /* @wisecp-protected */ // the module class, hooks.php, a helper — any PHP file ``` **PHP only.** Templates are not encoded, whatever engine they use. The engine compiles them to plain PHP on disk first, so a secret written into one is readable there. Keep decisions in PHP. The marker is a promise: a build that cannot protect a marked file is refused. > **Five files at most** > > A submission may mark up to five files. Past that the release is held back and publishing becomes a priced extra. Mark the file that decides, not everything that touches it. That is usually three: the class, its helper, and the view. ### Reference #### What the carrier remembers | Window | Length | Behaviour | | --- | --- | --- | | Disk cache | 86400 seconds | No network. One encrypted file per address and field set, under `coremio/storage/licenses`. | | Every call | none | Pass `true` as the third argument when yesterday's answer will not do. | | Unreachable | none | `null`, and nothing is written. A blip cannot freeze a day of silence into the cache. | The cache file is encrypted and signed with the installation's own secret, and the envelope names the question. A file lifted from another installation and renamed answers for nothing. There is no grace window. #### Asking an endpoint of your own Where the check goes is your decision, and so is what an answer means. This carries the question and holds traffic to one call a day. ```php public static function remote_check(string $url, array $fields = [], bool $always = false, array $options = []): ?array; ``` - **$url**: The address to ask. Only `http` and `https`. Addresses inside the customer's own network are refused before the socket opens: loopback, private ranges, cloud metadata. - **$fields**: Sent as the request body. - **$always**: Left alone, the answer is remembered for a day. Pass `true` to ask on every call. - **$options**: `headers` request headers, either `'Name: value'` lines or a name => value map. `method` defaults to POST. `timeout` in seconds, 1 to 60, default 10. - **Returns**: `['status' => int, 'headers' => array, 'body' => string]`. Header names are lower-cased, the body verbatim. `null` means unreachable, and nothing is cached. The address, fields, method and headers together are the cache identity. An authenticated call is remembered apart from an anonymous one. ```php $answer = \License::remote_check( 'https://your-site.example/license/check', ['product' => 'aurora-backup', 'domain' => \Utility::getDomain()], false, ['headers' => ['Authorization: Bearer ' . $this->vendor_key()]] ); if ($answer === null) return true; // could not ask; your outage, not the customer's fault if ($answer['status'] !== 200) return false; // 401, 403, 404 — your server said something else $data = \Utility::jdecode($answer['body'], true); return (bool) ($data['valid'] ?? false); ``` > **It does not judge the answer or verify a signature** > > Anything the customer's host resolves your address to can write that answer, status code included. If the answer decides money, sign it on your server and check that signature in the code that reads it. ### Example A complete paid module, gated end to end. The admin screen and the scheduled run ask the same question. A gate on one alone is not a gate. ```php 'aurora-backup', 'domain' => \Utility::getDomain(), ]); // Could not ask. Your outage is not the customer's fault. if ($answer === null) return true; return $answer['status'] === 200 && trim($answer['body']) === 'OK'; } public function adminArea(): array { $action = Filter::init('REQUEST/action', 'route') ?: 'index'; if (!$this->licensed()) $action = 'unlicensed'; return [ 'page_title' => $this->lang['name'] ?? 'Aurora Backup', 'content' => $this->view($action . '.php'), ]; } /** The scheduled half. A cron run must not act on an expired licence either. */ public function run_backup(): bool { if (!$this->licensed()) throw new \Exception($this->lang['error-not-licensed'] ?? 'Not licensed.'); // ... return true; } } ``` The scheduled path is gated too. A gate on the admin screen alone leaves the paid work running for a licence that lapsed months ago. ### Pitfalls > **Unreachable is not unlicensed** > > Treating every falsy answer as theft switches your module off at the first network hiccup. Branch on the reason and let grace do its job. > **Shipping without a public key makes the check optional** > > With the field empty an answer is accepted on nonce and clock alone. Anyone who can point your hostname elsewhere answers valid to everything. Fill it in before the first paid release. > **A domain left in the settings ships the developer's answer** > > The domain option overrides the host being asked about and is for local work only. Left filled in a release, every copy asks about your machine and comes back licensed. > **A long timeout turns your outage into their outage** > > Five seconds is already generous for a call that happens once a day. Raise it and every page of the customer's panel waits on your server the moment it goes down. > **Test the refusal path, not only the happy one** > > Point the address at a host that does not exist, then at one that returns an unsigned answer. Look at your module in both states. Every unpaid customer sees the refusal screen. ### Related Articles - [Licensing a Marketplace Product](https://dev.wisecp.com/en/marketplace-product-licensing) - [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [Module Configuration](https://dev.wisecp.com/en/module-configuration) - [Security Practices](https://dev.wisecp.com/en/security-practices) ## Shipping Module Updates https://dev.wisecp.com/en/shipping-module-updates Get a new version onto installations that already have it: one manifest, one version number, one operator decision. ### Overview The installation asks once a day what is published, and the publisher answers with a version, a changelog and a product card. A newer answer raises a notice; the update runs only on a click. A directory becomes updatable by carrying `manifest.json`; without it the copy is never touched. ```json {"type":"marketplace","id":42,"version":"1.2.0","last_updated":"2026-08-03"} ``` ### Prerequisites - **A published listing**: Detection asks by identity: marketplace numeric id, store key name. - **A version that only goes up**: PHP version comparison, so `2.10.0` is newer than `2.4.0`. A published number cannot be reused. - **ZipArchive on the customer's host**: Without it the extract step fails with `zip_unavailable`. - **A writable temp directory**: Each job lives under `temp/module-update`: archive, payload and rollback copy. - **Migrations that can run twice**: The update path runs no script of yours. ### Structure Detection, decision and installation are three separate things. | Stage | Who runs it | What happens | | --- | --- | --- | | Discovery | a daily scheduled task | Every manifest is collected and asked about in one call per source | | Notice | the panel | A bell notice snapshotting version, changelog and card, with Update Now and Later | | Wizard | the operator | Four resumable requests: download, extract, apply, finish, addressed by a token | There is no automatic install step. The changelog you write is frozen the day it is discovered. > **Editing a changelog does not reach a pending notice** > > Deduplication is on module plus version and looks at unread notices only, so one in the bell keeps its text; a later run raises a fresh row. ### Walkthrough #### Ship the manifest Put `manifest.json` next to the class file, or beside `theme.php` for a theme. ```bash coremio/modules/Addons/AuroraBackup/AuroraBackup.php coremio/modules/Addons/AuroraBackup/config.php coremio/modules/Addons/AuroraBackup/manifest.json <- makes the directory updatable templates/website/Aurora/theme.php templates/website/Aurora/manifest.json <- beside theme.php, not instead of it ``` A manifest that cannot be understood counts as absent: unknown source, missing version, marketplace with no id, store with no name. #### Bump the version The manifest version is the only thing compared, not the one in `config.php`. The installation rewrites its copy after a successful apply. ```php // Both sides are normalised first: the first line only, and only the characters a // version is made of, so a stray space or a comment cannot produce a version that // compares equal to nothing. public static function normalize(string $version): string; public static function is_newer(string $remote, string $local): bool; // is_newer('2.10.0', '2.4.0') === true version_compare, not string ordering // is_newer('1.2.0', '1.2.0') === false equal is not newer // is_newer('1.2.0', '') === false an unreadable side never triggers an update ``` #### Shape the archive The archive is unpacked and searched; the first matching layout wins. 1. A folder named exactly like the module key at the archive root. 2. The full tree, `coremio/modules/{Type}/{Key}` or `templates/website/{Key}`. 3. A single unnamed directory at the root, or the root itself. Looking right means carrying `{Key}.php` or `theme.php`; otherwise the step stops with `package_mismatch`. #### Migrate your own tables The update copies files: no script of yours runs and the enable path is not called again. ```php public function enable(): bool { // The fresh install path. $this->check_database(); return true; } public function adminArea(): array { // The upgrade path: enable() already ran, years ago, on the previous version. $this->check_database(); // ... return ['page_title' => 'Aurora Backup', 'content' => $this->view('index.php')]; } /** * Creates what is missing and adds what was added later. Safe to call on every * request: the existence checks are the migration. */ private function check_database(): void { if (!WDB::hasTable('AuroraBackup_jobs')) { WDB::exec('CREATE TABLE `AuroraBackup_jobs` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `user_id` INT UNSIGNED NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL, PRIMARY KEY (`id`), KEY `owner` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci'); // Created with every column already present: nothing below has to run. return; } // Added in 1.2: jobs written before it could not be traced back to a schedule. $col = WDB::query("SHOW COLUMNS FROM `AuroraBackup_jobs` LIKE 'schedule_id'"); if (!$col || !WDB::getAssoc($col)) WDB::exec('ALTER TABLE `AuroraBackup_jobs` ADD `schedule_id` INT UNSIGNED NOT NULL DEFAULT 0 AFTER `user_id`'); } ``` Keep creation and column repair on separate branches; a seed in the creation branch dies on an older table. #### Write the changelog The dialog groups notes under headings, so each line carries one of four types; an unknown type prints last. ```json [ {"type": "features", "text": "Scheduled jobs can now target a second storage account."}, {"type": "improvements", "text": "Listing a bucket with many objects no longer times out."}, {"type": "security", "text": "Restore tokens are now single use."}, {"type": "fixes", "text": "A job cancelled during upload left a partial archive behind."} ] ``` ### Reference #### The manifest fields | Field | Required | Rule | | --- | --- | --- | | `type` | always | `wstore` or `marketplace`; anything else means not managed | | `id` | marketplace only | The numeric listing id; directory names are not unique | | `name` | store only | The product key; blank rejects the whole manifest | | `version` | always | The installed version, and the only thing compared | | `last_updated` | no | Shown to the operator, never part of a decision | #### The updater surface ```php // reading what is on disk public static function manifest(string $dir): array; public static function version(string $type, string $key): string; public static function normalize(string $version): string; public static function installed(bool $activeOnly = true): array; public static function dir(array $item): string; // asking the publisher public static function releases(array $items): array; public static function pending(bool $activeOnly = true): array; public static function issue(string $source, string $ident): array; public static function notes(array $changelog): array; public static function is_newer(string $remote, string $local): bool; // running one update public static function token(string $source, string $ident, string $version): string; public static function begin(string $source, string $ident, string $version): array; public static function job(string $token): array; public static function step(string $token, string $step): array; public static function abandon(string $token): void; public static function write_manifest(string $dir, array $manifest): bool; // STEPS is the fixed order: download, extract, apply, finish. // PRESERVED is the list of filenames apply refuses to overwrite: config.php. ``` - **ModuleUpdater::installed()**: Every managed directory: kind, type, key, dir, active and `ident`. - **ModuleUpdater::pending()**: The installed entry plus `release`, `product`, `developer`, `changelog`, `available`. - **ModuleUpdater::manifest()**: Empty for anything it cannot make sense of; use it to check your package. - **ModuleUpdater::step()**: Runs one named step, and can be called again for it. - **ModuleUpdater::token()**: Derived from source, identity and version, so a retry resumes the job. #### What apply does - **A rollback copy is taken first**: The whole target directory is copied aside before any file is written. - **Files are merged, not replaced**: The package tree is copied over the target; missing directories are created. - **config.php is skipped when it already exists**: Operator settings survive; a new component's own config still installs. - **Nothing is ever deleted**: A file you removed stays on disk: there is no delete list. - **The manifest is written last**: A run that dies halfway does not look like a finished one. #### Failure codes Every step throws a bare code so the panel can translate it. | Code | Raised by | What it usually means | | --- | --- | --- | | `not_managed` | begin, apply | No readable manifest: hand installed, or malformed | | `already_current` | begin | Not newer; usually a reused number | | `download_failed` | download | The signed address returned nothing | | `zip_unavailable` | extract | The host has no ZipArchive | | `archive_unreadable` | extract | The archive would not open; often a nested one | | `package_mismatch` | extract, apply | No layout held the expected class file | | `install_failed` | apply | A copy failed; the rollback has been put back | ### Example One release, from the two files that change to what the installation reads back. ```json // what the customer has on disk {"type": "marketplace", "id": 42, "version": "1.1.0", "last_updated": "2026-05-14"} // what you put in the 1.2.0 package {"type": "marketplace", "id": 42, "version": "1.2.0", "last_updated": "2026-08-03"} ``` ```bash AuroraBackup-1.2.0.zip └─ AuroraBackup/ # the module key, exactly ├─ AuroraBackup.php # the file the extractor looks for ├─ manifest.json # version 1.2.0 ├─ config.php # shipped, but skipped where one already exists ├─ lang/ │ └─ en.php # a directory, never a flat lang.php └─ views/ ├─ index.php └─ schedules.php # new in 1.2.0, arrives as a plain copy ``` ```php **Bumping the version in config.php changes nothing** > > An update never writes that file, so the number there freezes at the first install. > **Zipping the wrong level** > > A root holding the class file works, and so does one holding the module folder. A version-named folder or a nested archive stops at package_mismatch. > **A migration only in enable() misses upgrades** > > Enabling happens once, and every upgrading customer did it long ago, so a change wired only there misses every existing install. > **Removing a file does not remove it** > > The old file stays on disk and stays loadable, so the replacement has to neutralise it. > **A published version number cannot be corrected** > > A wrong number is fixed by deleting the release and republishing, never by editing it in place. ### Related Articles - [Shipping a Release](https://dev.wisecp.com/en/shipping-a-marketplace-release) - [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution) - [Licensing a Module](https://dev.wisecp.com/en/licensing-a-module) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) - [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema) - [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade) ## Packaging a Module for Distribution https://dev.wisecp.com/en/packaging-a-module-for-distribution Turn a module directory into an archive somebody else can install: what belongs in it and what must never be. ### Overview A package is one directory, zipped, with its path preserved. Two consumers read it differently. | Consumer | Accepts | Result | | --- | --- | --- | | Manual upload | The full tree: `coremio` at the root | Merged into the installation root, file by file | | The update wizard | The module folder, the tree, or an unnamed folder | Into the module's directory, after a rollback copy | | A store download | The module folder at the root | Into the module directory, then a manifest | One shape satisfies all three: **ship the full tree**, rooted at `coremio/modules/{Type}/{Name}/`. ### Prerequisites - **A module that runs from a clean copy**: Install into an empty installation first; a hand-added dependency is missing elsewhere. - **No absolute paths anywhere**: Build paths from the constants. - **A neutral config file**: Blank every setting, then check the archive. - **A version number you will not reuse**: The identity installations compare against; decide it before you build. - **An English language file**: The loader falls back to it for untranslated languages. ### Structure Everything your module owns lives in one directory named like its class. ```bash AuroraBackup-1.2.0.zip └─ coremio/ └─ modules/ └─ Addons/ └─ AuroraBackup/ ├─ AuroraBackup.php # the class; the loader looks for this exact name ├─ config.php # settings and meta; skipped by an update if present ├─ manifest.json # enrols the copy in the update check ├─ hooks.php # optional, loaded by the hook system ├─ router.php # optional, registers an admin page ├─ AdminArea.php # optional, the admin page itself ├─ logo.png # optional, picked up by name ├─ lang/ │ ├─ en.php # the fallback, always ship it │ └─ tr.php ├─ views/ │ └─ index.php └─ src/ └─ ApiClient.php # your own helper classes ``` Rename the directory and the class lookup, update detection and logo all stop working. ### Walkthrough #### Lay out the directory Four files are read from fixed places. 1. `{Name}.php` declares the class; the factory tries `{Name}_Module`, `{Name}`, then the namespaced form. 2. `config.php` returns an array: `status` switches an addon, `meta` carries name, author, icon. 3. `lang/{code}.php` returns a flat array; active language first, English as fallback. 4. `manifest.json` makes the installed copy updatable. #### Archive shape Zip the full tree from the installation root, only your own directory. ```bash # From the root of a clean installation, so the stored paths are root relative. zip -r AuroraBackup-1.2.0.zip coremio/modules/Addons/AuroraBackup \ -x '*/.git/*' '*/.DS_Store' '*/node_modules/*' '*.log' '*.map' # Read the archive back and look at it. This is the only way to see what you # actually shipped rather than what you meant to ship. unzip -l AuroraBackup-1.2.0.zip ``` A zip inside the zip is unpacked once and matches nothing. #### Strip what must not ship Leftovers bloat the package; credentials and stray files damage the target. Read the built archive, not the working directory. #### What happens on the target system The operator uploads from the panel; what follows is fixed. 1. The upload is validated: `.zip` or `.tar.gz`, under the size ceiling. 2. It is stored under `temp` and unpacked into a working folder. 3. The `coremio` root makes the whole tree move onto the installation root. 4. The working folder and the archive are deleted either way. 5. An addon can be switched on in the same action, which calls your `enable()`. ```php public function enable(): bool { // Everything the module needs in order to exist: its tables, its notification // templates, its seed rows. Idempotent, because an operator can switch a module // off and on again as often as they like. $this->check_database(); // Column repair runs after table creation, never inside it: a seed that writes a // column added later would die on an installation whose table predates it. $this->check_columns(); // Returning false aborts the switch-on and leaves the module disabled, so use it // for a genuine blocker and never for a warning. return true; } ``` No manifest is written, and no `enable()` runs for types with no switch: a server or registrar module creates what it needs elsewhere. #### Verify before publishing Unpack the built archive into an empty installation and read it back. ```php $value) if (is_string($value) && $value !== '') $fail[] = 'setting not blank: ' . $name; // 4. Update enrolment. if (!ModuleUpdater::manifest($dir)) $fail[] = 'manifest.json unreadable: no updates would ever be offered'; echo $fail ? implode("\n", $fail) . "\n" : "package looks shippable\n"; ``` ### Reference #### What the platform reads | File | Read by | Without it | | --- | --- | --- | | `{Name}.php` | the loader; the wizard's proof | Nothing matches, nothing can be instantiated | | `config.php` | the loader, on every registry build | No settings, no status: loaded but inert | | `lang/en.php` | the loader, as the fallback | Labels resolve to nothing in untranslated languages | | `lang/{code}.php` | the loader, on the active language | That language falls back to English | | `manifest.json` | the daily update check | The copy reads as hand installed, never updated | | `logo.*` | the logo resolver, by glob | No logo, unless the config names one | | `hooks.php` | the hook system, at boot | Nothing registers | #### Upload limits - **Extension**: Only `.zip` and `.tar.gz`; extraction uses ZipArchive. - **Size**: A ceiling of 50 MB, before the host's limits. - **A coremio directory at the archive root**: The module key comes from the first directory under the type folder. - **Existing files are overwritten**: Per file: same paths replaced, others left alone. - **Temp is always cleaned**: On success and on failure, so a failed install leaves nothing. #### What must never be in the archive | Do not ship | Why | | --- | --- | | Anything outside `coremio/modules/{Type}/{Name}/` | It replaces a core file, silently | | Your API keys and test accounts in `config.php` | The settings array ships verbatim | | A licence public key you have not rotated | It is what makes a forged answer worthless | | Version control and editor directories | History, branch names, credentials still in the log | | Dependency and build directories, source maps | A source map undoes the build | | Development scratch files and probe scripts | A bootstrapping script becomes reachable on their server | | Logs, dumps, backups and storage output | Somebody else's data, stale and possibly personal | | A second archive inside the archive | Unpacking happens once, so nothing matches | #### Entry points ```php // The archive handler both upload paths share. $name empty means "work the key out // from the tree", which is why the manual path needs the full tree. public function extract_archive($file = '', $name = '', $group = 'Addons'): string; // Registry build: reads config.php, then lang/{active}.php with lang/en.php as the // fallback, then includes {Name}.php unless the class is already declared. public static function add($file, $type, $nominc = false, $status = ''); public static function Load($type = '', $name = '', $nominc = false, $status = ''); // getInstance takes the FIRST of these three class names that exists, in this order: // {Name}_Module -> {Name} -> WISECP\Modules\{Ucfirst Type}\{Name} // Missing constructor arguments are padded with null, so a required parameter your // class cannot cope with as null is a crash at instantiation, not a helpful error. public static function getInstance(string $type, string $name, array $params = []): ?object; // Switching an addon on. Calls activate() then enable() and writes the flag only if // the last one returned true. public function change_addon_status($arg = ''); // Written by the store install path, and by you inside the package for every other route. public static function write_manifest(string $dir, array $manifest): bool; ``` - **AdminTools::extract_archive()**: One folder with a known key, the whole tree without one. - **Modules::add()**: Where the four fixed filenames are read. - **Modules::getInstance()**: The canonical way to get an instance; never `new`. - **AddonModule::change_addon_status()**: The only caller of enable and disable. - **ModuleUpdater::write_manifest()**: Store path only, so the manifest travels inside your archive. ### Example A paid addon, packaged: three files decide whether it installs and updates. ```bash $ unzip -l AuroraBackup-1.2.0.zip coremio/modules/Addons/AuroraBackup/AuroraBackup.php coremio/modules/Addons/AuroraBackup/config.php coremio/modules/Addons/AuroraBackup/manifest.json coremio/modules/Addons/AuroraBackup/hooks.php coremio/modules/Addons/AuroraBackup/logo.png coremio/modules/Addons/AuroraBackup/lang/en.php coremio/modules/Addons/AuroraBackup/lang/tr.php coremio/modules/Addons/AuroraBackup/views/index.php coremio/modules/Addons/AuroraBackup/views/unlicensed.php coremio/modules/Addons/AuroraBackup/src/ApiClient.php # Nothing above coremio/modules/Addons/AuroraBackup. No .git, no node_modules, # no temp scripts, no second archive, no storage output. ``` ```php 1785312000, 'meta' => [ 'name' => 'Aurora Backup', 'version' => '1.2.0', 'author' => 'Aurora Systems', 'opening-type' => 'normal', 'icon_type' => 'font', 'icon' => 'bi bi-cloud-arrow-up', 'slug' => 'aurora-backup', ], 'show_on_adminArea' => true, 'show_on_clientArea' => false, // Ships disabled: the operator switches it on, and that is what calls enable(). 'status' => false, 'access_ps' => [], // Every value blank. This array is written back verbatim from your disk. 'settings' => [ 'api_endpoint' => '', 'api_key' => '', 'bucket' => '', ], ]; ``` ```json { "type": "marketplace", "id": 42, "version": "1.2.0", "last_updated": "2026-08-03" } ``` The version appears twice: config is what the operator reads, the manifest is what updates compare against. ### Pitfalls > **Zipping the folder instead of the tree** > > An archive rooted at the module folder installs through the wizard; the manual upload refuses it. > **Shipping your test config** > > Nothing blanks it for you. Blank the settings as a build step and check the archive. > **A missing manifest freezes the copy** > > Nothing fails and the module works, but it is never offered an update again. > **Shipping only your own language** > > English is the fallback, so a module with only a Turkish file shows blank labels elsewhere. > **Install your own package first** > > Upload it as a customer would, switch it on, then update from the previous version. ### Related Articles - [Publishing a Product](https://dev.wisecp.com/en/publishing-a-marketplace-product) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) - [Licensing a Module](https://dev.wisecp.com/en/licensing-a-module) - [Module Anatomy](https://dev.wisecp.com/en/module-anatomy) - [Module Language Files](https://dev.wisecp.com/en/module-language-files) - [Module Assets and Logo](https://dev.wisecp.com/en/module-assets-and-logo) - [Module Lifecycle](https://dev.wisecp.com/en/module-lifecycle) # Hooks ## How Hooks Work https://dev.wisecp.com/en/how-hooks-work The way into the system's flow without touching a core file: a name, a running order and a function to run. ### Overview A hook is the point where the core stops and asks **"has anyone here got something to say?"** An order is saved and it asks. An invoice total is worked out and it asks. A management screen is about to be drawn and it asks. You leave a function behind, and the core calls it on reaching that point. What this buys you: behaviour added without editing the core. An edited core file comes back on the first update. A hook listener sits in your own file, and the update leaves it alone. This is also how modules bind themselves to the system. This installation holds **984 hook points** spread over seventeen domains by subject. The word in front of the name says what it was opened for. - **action**: Something happened and you are being told. Your return is not read — write to an outside system, keep a record, send word. **301 hooks.** - **filter**: A value passes through your hands and you can **change it**: an amount, a list, a query, template data. **199 hooks.** - **gate**: Permission is asked before an action runs and you can stop it by saying **no**. **131 hooks.** - **ui**: You put your own markup at a named place on a screen. The largest family: **342 hooks.** - **register**: You make a new capability known to the system: a route, a dashboard piece, a report. **11 hooks.** ### Prerequisites - A file for your listener to live in: a `.php` file in the installation's own `coremio/hooks` directory, or a module's `hooks.php`. - A finished installation. No listener loads before the setup wizard is done (see the pitfalls below). - The **exact name** of the hook you are binding to. A wrong name does nothing at all and reports nothing either. ### Structure Listener files are read **on their own** and you register them nowhere. Two places are swept and both load together. ```bash coremio/hooks/*.php # the installation's own listeners coremio/modules/{Type}/{Name}/hooks.php # every module's own listeners ``` Loading happens **when the first hook runs**, not at page start. The files are read once and stay in memory for that request. So adding a listener restarts nothing: you drop the file in and it runs on the next request. A hook takes as many listeners as you like. They all run **by running order**, and the smaller number goes first. ### Reference #### Registering a listener ```php // The Hook class — coremio/classes/Hook.php static function add(string $name, int $priority, callable|array $properties): void; ``` - **$name**: The full name of the hook you bind to. It is not checked, and a wrong name binds silently to nothing. - **$priority**: The running order, smaller first. **The same number cannot be taken twice**: a second listener slides to the next free number, so the order of registering decides. - **$properties**: The thing to run. One of four forms (below). #### The four listener forms ```php // 1 — a closure: the common one Hook::add('action:order.created', 10, function ($order) { Crm::push($order['id']); }); // 2 — a class method: the class is built ONCE and shared across every hook Hook::add('filter:invoice.late_fee_amount', 10, ['class' => 'AcmeBilling', 'method' => 'adjust']); // 3 — a static method: no object is built Hook::add('gate:order.checkout', 10, ['class' => 'AcmeGuard', 'method::static' => 'allow']); // 4 — the constructor: with NO method key the class is built and THE OBJECT is the return Hook::add('register:routes', 10, ['class' => 'AcmeRoutes']); ``` #### How the core runs a hook ```php static function run(string $name, mixed ...$args): array; static function runRefs(string $name, mixed &...$args): array; static function runDetailed(string $name, mixed ...$args): array; ``` - **Hook::run()**: Passes a **copy** of the arguments. A change the listener makes to the variable never reaches the caller, and the returns are gathered into an array. - **Hook::runRefs()**: Passes the arguments **by reference**: a listener writing `&$x` in its signature changes the caller's variable. This is how the `filter` family works. - **Hook::runDetailed()**: Returns each listener's **source file, line, return and error**. The "who is listening to this" screen in the panel uses it, and it is the right tool for diagnosis too. What all three share: a listener that throws is **caught, recorded, and the next one runs**. A single broken listener stops neither the core nor the other listeners. ### Example One file, three listeners, three different jobs: catching an event, changing a value and stopping an action. ```php **A wrong name says nothing** > > Binding a listener to a hook name that does not exist **reports nothing**; the listener never runs. Where a listener "does not work", the first place to look is not the code but **the name**. Check it against the hook index rather than writing it from memory. > **An empty argument is skipped and the rest slide** > > The core binds a listener's parameters **in order** and skips an empty (`null`) argument. The gap does not close: **everything after it slides one to the left** and the second parameter lands where the first was. Never pass an optional context value as empty; pass nothing, or a placeholder such as an empty string or an empty array. > **The returned array does not line up with the listeners** > > The gathered array carries the **non-empty** returns alone. Where one of three listeners returns empty, the array holds two items and **the first item is no longer the first listener**. Taking "the first result" is safe on hooks with a single listener and nowhere else. > **One parameter too many drops the listener** > > Writing a three-parameter listener where the hook throws two values makes the call fail. The failure is caught and recorded, and **the listener is skipped**. What you see from outside is "it does not work". Check how many values the listener takes against the hook's entry. Writing **fewer** parameters is safe; writing more is not. > **The prefix states intent, not mechanism** > > A name starting with `filter:` is **no guarantee** the value arrives by reference. On measuring, part of the `filter` family was being called with a copy. Do not read off its name whether a hook can truly change its value. Read it from the mechanism line in the hook's entry. > **No listener loads before setup finishes** > > While the setup stamp is empty, **none** of the hook files are read. Do not expect to reach into the setup wizard's flow with a hook; that ground comes before hooks exist. ### Related Articles - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) - [Common Hook Scenarios](https://dev.wisecp.com/en/common-hook-scenarios) ## Writing a Hook Listener https://dev.wisecp.com/en/writing-a-hook-listener Writing the function that binds to a hook: taking the parameters in order, giving the return its contract asks for. ### Overview A listener is three things: the **name** you bind to, the **number** that is your place in the queue, the **function** that runs. The hard part is none of those. The hard part is getting the function's **signature and return** right. Every hook carries its own contract. One hands you the value by reference and waits for you to change it. One prints the text you return. One throws that text as an error and stops the action. One never looks at your return. Reading the contract wrong **reports nothing**: the listener runs and nothing happens. ### Prerequisites - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) read first. - The name of the hook you bind to, and its **entry**. The parameters get their meaning there. - A file for the listener: one under `coremio/hooks`, or your module's `hooks.php`. ### Step by Step #### 1. Read the contract 1. A hook's entry holds **three lines**: *Mechanism*, *Parameters*, *Return contract*. Read all three. 2. **Do not** decide from the word in front of the name. On measuring, part of the `filter` family was called with a copy. 3. The **Ref** column decides. Take a marked parameter with `&` and you can change it. #### 2. Write the signature 1. Take the parameters **in the order the hook gives them**. The names are yours, the order is not. 2. **Leave out** trailing parameters you do not need. Fewer is safe, more drops the listener. 3. Take the parameter you mean to change with `&`. ```php // filter:invoice.late_fee_amount — the entry's order: &$fee, $invoice, $cycle Hook::add('filter:invoice.late_fee_amount', 10, function (&$fee, $invoice) { if ((int) ($invoice['user_id'] ?? 0) === 1) $fee = 0.0; // third parameter left out }); ``` #### 3. Give the return 1. Do what the entry's *Return contract* line says. The spread below helps, but that line decides. 2. Return `null` where you mean to touch nothing. 3. Where you stop an action, return **the sentence the user will read**. #### 4. Prove it ran 1. Put the file in place and do the work that fires the hook. No restart is needed. 2. Where nothing happened, read the error log. A `Hook execution error` record says the listener ran and threw. 3. A `Hook listener unresolved` record says the class or method name is wrong. 4. An empty log means the listener was never called: the name is wrong, or the file sits in neither swept place. 5. For a definite answer, run the hook in diagnosis mode. ```php foreach (Hook::runDetailed('action:service.created', $id, $data) as $row) { echo $row['source']['file'] . ':' . $row['source']['line'] . "\n"; if ($row['error'] !== null) echo ' FAILED: ' . $row['error'] . "\n"; } ``` ### Reference The spread below was counted from **the entries of 984 hooks**. It shows what to expect in a family. It is not the truth for any single hook: every row holds a minority that behaves otherwise. - **action**: On **297** hooks the return is ignored. **4** gather telemetry and keep what you return. - **filter**: On **186** hooks you **change the value by reference** and the return is not read. On **7** you **return** the changed array; the last non-empty return wins. - **gate**: On **128** hooks a **non-empty string** stops the action and is thrown as the error. `null` or an empty string lets the flow carry on. - **ui**: On **329** hooks you return **the HTML to print**; empty returns are skipped. On **12** you change the object handed to you. - **register**: On **5** hooks you return a registration array. On **4** you widen the array handed to you, on **2** you return a single value. #### The running order A smaller number runs first. **The same number cannot be taken twice**: a second listener slides to the next free number. Pick the number against the other listeners. Take a large one to change a value with **the last word**. Take a small one to stop an action **before anyone else**. ### Example One installation's rules for corporate customers: no late fee, a word to an outside system when a service opens, no deleting a service while the account owes money. ```php **The ampersand is wasted where the hook offers none** > > Writing `&$value` is not enough on its own; the value has to have been **sent** by reference. Where the entry does not mark that parameter, your change stays in a local copy. There is no symptom: no error, only no effect. > **A by-reference hook takes no literal** > > Where **you** open a hook in its by-reference form, every argument has to be a variable. Passing an array literal, a text literal, a function's return or a `??` expression is a **fatal error** and takes the page down. Put context values in a variable first. > **The class form shares one object** > > Registering a listener as a class method builds that class **once**. Every hook using it shares one object. Work in the constructor then happens **once per request** rather than per hook, and state you keep carries to the other hooks. Use the static form where you do not want that. > **With equal numbers the registering order decides** > > Giving two listeners the same number does not make them equal; the second **slides** to the next free one. Which slides depends on the order the files load in, and that changes as modules are added. Give listeners whose order matters **numbers of their own**. > **Do not give a gate work to do** > > Blocking hooks run on **every attempt** and their only job is to say yes or no. Calling a remote service or writing data inside one slows the flow down. Worse, another listener may already have stopped the action, leaving what you wrote as a record of **something that never happened**. ### Related Articles - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) - [Common Hook Scenarios](https://dev.wisecp.com/en/common-hook-scenarios) ## Hook Domains https://dev.wisecp.com/en/hook-domains Finding which domain holds the hook you want: seventeen domains, what each covers, and how to read the names. ### Overview Hooks are split by subject into **seventeen domains**. A domain is a directory. Every hook stands there with its own entry: parameters, return contract, and the file and line it fires at. The domain says what a hook is **about**. It does not say where the hook fires. A hook about a customer account may fire in the management panel. It still lives in the account domain. ### Structure A hook name is two parts: the **family** and the **name**. The family says what it was opened for, the name says where it binds. ```bash filter : invoice.late_fee_amount ^ ^ ^ | | the event or the spot | the subject the family: action · filter · gate · ui · register ``` The middle part of a name usually matches the domain, but **not always**. On measuring, **138** of 984 hooks had the two diverge. Do not guess the domain from the name; read it from the directory. The short way to the hook you want is the hook index. Every name is listed family by family, and each row links to its own entry. ### Reference The seventeen domains, by hook count. The numbers come from a 2026-08-04 measurement; read the current value from the hook index. - **client (237)**: The site the customer sees: pages, the cart, account screens, the sign-in flow. - **admin (142)**: The management panel: lists, detail screens, staff actions, the dashboard itself. - **user (103)**: The customer account itself: signing up, signing in, verifying, blocking, deleting, affiliates. - **service (96)**: A sold service's life: opening, suspending, cancelling, upgrading, moving a licence. - **domain (80)**: Domain work: registering, transfers, name servers, records, contacts, forwarding. - **ticket (60)**: Support tickets and announcements: opening, replying, locking, routing to a department. - **product (54)**: The product catalogue: products, groups, add-ons, requirements, stock. - **invoice (46)**: Invoicing: issuing, formalising, recording a payment, late fees, renewal amounts. - **module (41)**: Modules themselves: installing, switching on and off, settings, languages, API routes. - **order (28)**: The order flow: cart inputs, the total, the checkout gate, status changes. - **money (27)**: Currencies, exchange rates, coupons and discounts. - **cron (23)**: Scheduled work: the start of a run, registering a task, running one by hand, telemetry. - **knowledgebase (17)**: The knowledge base: article and category actions, content filters, views. - **payment (16)**: Payment gateways: capture, callbacks, storing a card, collecting a subscription. - **notification (12)**: Notifications: recipients, templates, the delivery gate, attachment names. - **ssl (1)**: The list of SSL services about to expire. - **template (1)**: The variables reaching a template — it runs each time a page is built. #### Where the weight falls The first three domains carry **half** of all hooks. All three lean towards the screen: the customer site 237, the management panel 142, the account 103. A developer adding something to a screen will most likely find the job there. The last two domains hold one hook each. Being small does not make them unimportant: the single hook in `template` runs every time a page is built and hands you every variable going to the template. ### Pitfalls > **The middle of the name is not the domain** > > A hook whose name starts with `admin.` can live in the account domain. One starting with `api.` can live in the module domain. Measured: on **138 hooks** the middle of the name and the domain diverge. Search by name and read the domain off the entry. > **The customer site and the customer account are two domains** > > The **screens** a customer sees are in the site domain. The **work** on the account itself — signing up, signing in, verifying, blocking — is in the account domain. A developer looking in the wrong one finds nothing and concludes no such hook exists. It does exist, one directory over. > **Domain size is not priority** > > A crowded domain does not mean its hooks matter more. Screen hooks are many because every screen holds several spots. A single hook standing between a service and its cancellation weighs far more in practice. ### Related Articles - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Common Hook Scenarios](https://dev.wisecp.com/en/common-hook-scenarios) ## Common Hook Scenarios https://dev.wisecp.com/en/common-hook-scenarios Six common jobs under six different contracts: catching an event, blocking, adding to a screen, changing a value, carrying a variable, registering a route. ### Overview The six scenarios below cover all five families and all six behave **differently**. Two have you change a value by reference and one has you return it. Two have you touch an object handed to you. One has you return text and cut the action short. The spread is deliberate. The shortest way to show that a hook's expectations do not follow from its family is two different contracts inside one family. ### Prerequisites - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) read first. - The hook names in the examples are real in this installation; use the [domain directory](https://dev.wisecp.com/en/hook-domains) when adapting them to your own job. ### The Scenarios #### 1. Tell the outside when an order is placed This runs the moment an order is placed. **The invoice is still unpaid here**: a zero-total order, or one settled from the balance, closes later in the same request. Use the payment hook where you want an order that is paid for. ```php Hook::add('action:order.checkout_completed', 10, function ($order_id, $invoice_id, $pmethod, $member) { Crm::orderPlaced($order_id, [ 'invoice' => $invoice_id, // not paid yet 'method' => $pmethod, // 'Free' on a zero-total order 'email' => $member['email'] ?? '', ]); }); ``` #### 2. Block a sign-in This runs **after** the credentials check: the password is already right and you stop the sign-in for some other reason. The hook takes plain text as well as the `['message' => …]` form, and an empty return lets the sign-in carry on. ```php Hook::add('gate:user.login', 10, function ($user_id, $check, $ip) { if (!OfficeNetwork::covers($ip)) return 'The panel is reachable from the office network only.'; return null; }); ``` #### 3. Add a tab to the customer detail This is a screen hook, and yet you **return no HTML**: you are handed a tab object and you add to it. The return is not read. Most hooks in this family want HTML. This one wanting an object is the clearest case of a contract you cannot read off the family. ```php Hook::add('ui:admin.client_detail.tabs', 10, function ($tab, $user, $user_id) { $tab->add('acme', 'Acme Records', AcmePanel::render($user_id)); }); ``` #### 4. Change an order total The totals array arrives by reference, and the `total` key is the amount that goes on the order. Touch the tax keys and the invoice lines follow them. ```php Hook::add('filter:order.total', 10, function (&$tax_calc, $subtotal, $total_discount) { if ($subtotal >= 1000) $tax_calc['total'] = round($tax_calc['total'] * 0.95, 2); }); ``` #### 5. Carry a variable into every template This one does **not** work by reference: you return the changed array. What you return is not merged with the existing variables, it **replaces** them. So build on the array you were given and return **all** of it. ```php Hook::add('filter:template.variables', 10, function ($template_path, $data) { $data['acme_banner'] = AcmeBanner::current(); // built on what we were given return $data; // ALL of it returned }); ``` #### 6. Register a new route You are handed the router object and you register on it; the return is not read. This is how modules open pages of their own. ```php Hook::add('register:routes', 10, function ($router) { $router->add('acme-report', 'acme/report/(?)', 'Acme:report'); }); ``` ### Example All six can sit in one file. An add-on's whole set of bindings usually lives in a single `hooks.php`, with every line under a different contract. ```php add('acme-report', 'acme/report/(?)', 'Acme:report'); }); ``` ### Pitfalls > **On template variables the return REPLACES the array** > > Returning only the key you added on the variables hook **wipes every other variable** going to the template. The return is not merged, it replaces. With more than one listener the **last return wins**, so a listener after yours can wipe yours too. Build on the array you were given and return all of it. > **The order hook is not the payment hook** > > The hook running when an order is placed sees the invoice **unpaid**. Orders settled from the balance, and zero-total ones, close later in that same request. Bind to the payment hook where the work depends on money arriving. Asking "is it paid" inside the order hook **always answers no**. > **A screen hook does not always want HTML** > > Most hooks in the screen family expect HTML to print. Some hand you an object and want you to **touch that** instead, ignoring your return. Tab, menu and page-part hooks are usually of the second kind. Returning HTML and seeing nothing is most often this. > **A gate does not check the password** > > The sign-in gate runs **after** the credentials check. The user record there is real and verified; the gate's job is not checking a password but stopping the sign-in **for another reason**: a network limit, working hours, a maintenance window. ### Related Articles - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) # Hooks / Customer Site ## Hooks on the Customer Site https://dev.wisecp.com/en/hooks-on-the-customer-site The 156 places on the customer-facing site that take your own HTML. Where they sit, how the names read, and what the return has to be. ### Overview The customer site holds **156** screen hooks and they all do one job. The HTML you return appears where that point sits in the template. The hooks are placed inside theme templates with a `{hook}` tag. Measured: all three shipped themes carry **the same 154 calls**, so changing theme does not drop your listeners. The return contract is one kind throughout. Non-empty string returns are joined in order and shown; empty and `null` returns are skipped. ### Structure A name is three parts: the family, the screen, the spot. The last part says **where** on the screen the point falls. ```twig {* templates/website/{Theme}/views/account/domain-detail.tpl *} {hook name='ui:client.domain_detail.hero.after'} {hook name='ui:client.domain_detail.tabs.end'} ``` - **after**: Right after a section. **46** - **bottom**: The foot of a section, still inside it. **34** - **top**: The head of a section. **31** - **end**: After the last item of a list or a strip. **17** - **before**: Right before a section. **10** ### Reference How the points spread across the screens. The domain and service details are the richest, and an add-on's customer-facing side is usually built there. - **account/domain-detail (22)**: The domain detail - **account/service-detail (11)**: The service detail - **account/ticket-detail (7)**: The ticket detail - **account/settings (5)**: Account settings - **account/domains (5)**: The domain list - **account/invoice-detail (4)**: The invoice detail - **account/ticket-create (4)**: The new ticket form - **products/detail (4)**: The product page - **checkout/* (8)**: Cart, configure, pay, order done - **content/* (15)**: Knowledge base, blog, contact, add-on pages - **auth/* (3)**: Password reset and invitation screens - **partials + layouts (13)**: Header, footer, checkout shell, page body #### The listener ```php // Most screen hooks take NO parameters: the name tells you which screen you are on. Hook::add('ui:client.domain_detail.hero.after', 10, function () { if (!AcmeBanner::active()) return null; // touch nothing return '
    ' . AcmeBanner::text() . '
    '; }); ``` ### Pitfalls > **The HTML you return is not escaped** > > These points take **raw HTML**. Embedding a value straight from the customer, the address bar or the database injects code into the site. **Escape** every outside piece yourself; the hook will not do it for you. > **Your own theme has to carry the hooks** > > The hooks live inside theme templates. The three shipped themes carry the same calls, and **a theme you write from scratch** carries only the points you put in it. An add-on not showing on the site is often a missing **theme** point rather than a missing hook. > **The spot you pick can break the layout** > > One point sits inside a box and another outside it. Dropping a full-width block into a grid shifts the page. The last part of the name says where it falls, and the way to be sure is **opening the template at that line**; the hook's entry gives the file and the line. > **An empty return is the clean way out** > > Return `null` where you have nothing to add: empty returns are skipped and leave no trace. Returning an empty `
    ` instead opens a real gap in the grid. ### Related Articles - [Customer Site Data and Gates](https://dev.wisecp.com/en/customer-site-data-and-gates) - [Hooks in the Management Panel](https://dev.wisecp.com/en/hooks-in-the-management-panel) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Customer Site Data and Gates https://dev.wisecp.com/en/customer-site-data-and-gates The customer site away from the screen. 41 filters change data, 10 gates stop an action, and 30 hooks report an event. ### Overview Screen hooks add HTML. The hooks here touch the data deciding **what a page shows**, stop an action, or report what happened. Telling the three apart decides where your work goes. Adding a row to a list is a filter. Stopping a spam comment is a gate. Telling an outside system is an event hook. ### Reference #### The ones that change data The most used of the forty-one filters. Most hand the value over **by reference**, and the *Ref* column of an entry says which one you can change. - **filter:client.page.output**: The finished output of a page — the widest place to step in. Changed in place by reference; the return is not used. - **filter:client.menu**: The site menu tree. Changed by reference; the return is not used. - **filter:client.breadcrumb**: The breadcrumb trail. Changed by reference; the return is not used. - **filter:client.dashboard.panels**: The panels on the account dashboard. Changed by reference; the return is not used. - **filter:client.dashboard.alerts**: The alert strips on the dashboard. Changed by reference; the return is not used. - **filter:client.list.rows**: List rows — services, invoices, domains. Changed by reference; the return is not used. - **filter:client.service_detail.data**: The data behind a service detail. Changed by reference; the return is not used. - **filter:client.domain_detail.data**: The data behind a domain detail. Changed by reference; the return is not used. - **filter:client.invoice_view_data**: The data behind an invoice view. Changed by reference; the return is not used. - **filter:client.payment_methods**: The payment methods offered to the customer. Changed by reference; the return is not used. - **filter:client.theme**: The theme in use. Changed by reference; the return is not used. - **filter:client.routes**: The site routes. Changed by reference; the return is not used. #### The ones that stop Ten gates. They share one contract: returning a non-empty string stops the action, and that text reaches the customer as the error. - **gate:client.page_access**: Access to a page. A filled return cuts the request off; a listener that redirects must end the request itself. - **gate:client.contact_submit**: Saving a contact form message. The first filled return vetoes the save. - **gate:client.blog_comment**: Saving a blog comment. The first filled return vetoes the save. - **gate:client.api_key_create**: The customer opening a new API key. A filled return stops it. #### The ones that report Most of the thirty event hooks report what the customer did on their account. Opening a key, writing a comment, sending a form. - **action:client.api_key_created**: The customer opened a new API key. The return is ignored. - **action:client.api_key_revoked**: A key was revoked. The return is ignored. - **action:client.blog_comment_added**: A blog comment was saved. The return is ignored. - **action:client.data_prepared**: The page data is ready and drawing is about to start. The return is ignored; add data with `addData()`. #### The three contracts side by side ```php // A FILTER: add your own panel to the dashboard (by reference) Hook::add('filter:client.dashboard.panels', 10, function (&$panels) { $panels[] = ['title' => 'Acme', 'body' => AcmePanel::html()]; }); // A GATE: stop the comment where the spam score is high Hook::add('gate:client.blog_comment', 10, function ($owner_id, $parent_id, $message) { if (Spam::score($message) > 80) return 'Your comment was not saved.'; return null; }); // AN EVENT: write the opened key to the audit trail Hook::add('action:client.api_key_created', 10, function ($owner_id, $new_id, $perms) { Audit::keyOpened($owner_id, $new_id, $perms); }); ``` ### Pitfalls > **The owner comes from the hook, not the session** > > Most customer hooks hand you the **account id** in the first parameter. Use it. Reading from the session brings the wrong person on sub-account and account-switch flows. On a call arriving through the API there is **no session at all**. > **A gate is not the only defence** > > A gate runs on **that flow** alone. Where the same work can be done through the API, that path may not pass this gate. To hold a rule everywhere, check from the hook's entry which call paths the gate covers. > **The page-output hook runs on every request** > > The filter touching a page's finished output runs on **every page view**. A heavy query or an outside call inside it is paid for by the whole site. Cache the heavy work and use only the ready answer in the listener. ### Related Articles - [Hooks on the Customer Site](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Blog and Comment Hooks https://dev.wisecp.com/en/client-blog-hooks The seven hooks over blog comments: moderation, editing, deletion, votes and the list. ### Overview The path of a blog comment lives here: it is written, filtered, listed, moderated and if need be deleted. Two distinctions to hold from the start. **On a guest comment the author id is zero.** And the moderation gate hands you the **request** while the event hands you the **result**: because pinning is a toggle, the two are not the same. ### Reference #### Stopping a comment moderation gateclient.blog_comment_moderate `ClientBlog` the request, not the result Runs before a comment is moderated. Parameters 5 $cidintThe comment id. $actionstringThe operation **requested**: approve, spam or pin. ? Pinning is a **toggle** request: whether it ends as pinning or unpinning depends on the current state. This value states the request, not the result. $actor_idintThe id of the administrator doing it. $author_idintThe author of the comment. It is **zero** on a guest comment: test before doing anything tied to a member. $owner_idintThe article the comment belongs to. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:client.blog_comment_moderate', 10, function ($cid, $action, $actor_id, $author_id, $owner_id) { // 'pin' is a TOGGLE request: you cannot tell the result from here. if ($action === 'spam' && Acme::protectedAuthor($author_id)) return 'Comments from this author cannot be marked as spam.'; return null; }); ``` #### Following a comment moderation actionclient.blog_comment_moderated `ClientBlog` the result, not the request Runs after the moderation is applied. Unlike the gate, the value here is the operation that **actually happened**. Parameters 5 $cidintThe comment id. $actionstringThe operation that **happened**: approved, spam, pinned or unpinned. Against the three values in the gate there are **four** here: the toggle request has split into two separate results. $actor_idintThe administrator who did it. $author_idintThe author of the comment. It is **zero** on a guest comment: test before doing anything tied to a member. $owner_idintThe article the comment belongs to. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.blog_comment_moderated', 10, function ($cid, $action, $actor_id, $author_id, $owner_id) { // FOUR values here: the toggle has split in two. if ($action === 'approved' && $author_id) Acme::thankAuthor($author_id); }); ``` #### Following a comment being edited actionclient.blog_comment_edited `ClientBlog` the body is encoded Runs after a comment is edited by an administrator. Parameters 5 $cidintThe comment id. $actor_idintThe administrator who edited it. $author_idintThe author of the comment. It is **zero** on a guest comment: test before doing anything tied to a member. $oldstringThe body before the edit. Both arrive **encoded**: showing them directly turns markup into visible text. $newstringThe body after the edit, as saved. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.blog_comment_edited', 10, function ($cid, $actor_id, $author_id, $old, $new) { // Both bodies arrive ENCODED. Acme::auditEdit($cid, $actor_id, html_entity_decode($old), html_entity_decode($new)); }); ``` #### Following a comment being deleted actionclient.blog_comment_deleted `ClientBlog` the replies go too Runs after a comment is deleted. Parameters 5 $cidintThe id of the deleted comment; the record is gone. $actor_idintThe administrator who deleted it. $author_idintThe author of the comment. It is **zero** on a guest comment: test before doing anything tied to a member. $owner_idintThe article the comment belongs to. $parent_idintThe comment replied to. ? **A zero means a root comment** went, **and its replies went with it** — with no separate call for them. Clear the whole branch in your own store. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.blog_comment_deleted', 10, function ($cid, $actor_id, $author_id, $owner_id, $parent_id) { // On a root comment the replies went too, with no separate call. if ($parent_id === 0) Acme::dropBranch($cid); else Acme::dropComment($cid); }); ``` #### Following a comment vote actionclient.blog_comment_voted `ClientBlog` a toggle Runs when a comment is marked helpful or the mark is taken back. Parameters 4 $cidintThe comment voted on. $viewer_idintThe member voting. This flow is members only, so the value is **always filled**. $activeboolThe direction: true for a vote, false for taking it back. $countintThe current total after the vote. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.blog_comment_voted', 10, function ($cid, $viewer_id, $active, $count) { if ($active && $count >= 10) Acme::promoteComment($cid); }); ``` #### Changing a comment body before it is saved filterclient.blog_comment_message `ClientBlog` raw text Runs before a comment is saved. The place to put a filter of your own. Parameters 2 $messagestringby linkThe comment body, **raw** and not yet encoded. What you write over it is saved. $ctxarrayby linkContext: the article, the comment replied to, the member id and whether it is a guest. The member id is zero for a guest. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.blog_comment_message', 10, function (&$message, &$ctx) { // The body is RAW: encoding happens afterwards. $message = Acme::stripLinks($message); }); ``` #### Changing the comment list filterclient.blog_comment_list `ClientBlog` an admin sees more Runs before the comments reach the screen. It runs separately for reply lists too. Parameters 2 $itemsarrayby linkThe comments to show, with their formatted fields. $ctxarrayby linkContext: whether this is a reply list, which article, and whether an administrator is looking. ? **When an administrator looks, comments awaiting approval are in the list too.** A listener ignoring that counts or exports unpublished content. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.blog_comment_list', 10, function (&$items, &$ctx) { // When an administrator looks, pending comments are in the list too. if ($ctx['is_admin'] ?? false) return; $items = Acme::hideFlagged($items); }); ``` ### Pitfalls > **The gate states the request, the event the result** > > In the moderation gate pinning is a **toggle** request: you cannot tell from there whether it ends as pinning or unpinning. In the event the value arrives already split in two. A rule assuming the result at the gate misjudges an unpinning. > **Deleting a root comment takes its replies** > > A zero parent id in the delete event means a **root comment** went and every reply beneath it went too. But **no separate call arrives** for those replies: you must clear the whole branch on your side, or orphan records remain. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Knowledge Base Display Hooks](https://dev.wisecp.com/en/knowledge-base-display-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Site Content Hooks https://dev.wisecp.com/en/client-content-hooks The twelve hooks over pages, news, slides, testimonials and the menu. ### Overview The written content of the site lives here: pages, contracts, news, articles, references, slides, testimonials and the menu. The save path splits into two filters: the **main record** passes once, the **language data** once per language. If you clean the body, the second is the right place. ### Reference #### Stopping content being deleted gateclient.content_delete `ClientContent` five content types Runs before a piece of site content is deleted. Pages, contracts, news, articles and references share this gate. Parameters 2 $pagearrayThe record about to be deleted. $page_typestringThe content type. Deleting a contract can affect the order flow tied to it: be stricter for some types. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:client.content_delete', 10, function ($page, $page_type) { // Deleting a contract can affect the order flow. if ($page_type === 'contract') return 'Contracts cannot be deleted; switch them off instead.'; return null; }); ``` #### Following content being deleted actionclient.content.deleted `ClientContent` after deletion Runs after a piece of site content is deleted. Parameters 3 $idintThe id of the deleted record. $page_typestringThe content type. $pagearrayThe record as read before deletion. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.content.deleted', 10, function ($id, $page_type, $page) { Acme::dropFromIndex('page', $id); }); ``` #### Following content being viewed actionclient.content.viewed `ClientContent` on every visit Runs when a piece of site content is visited. The visitor may not be logged in. Parameters 3 $idintThe id of the record viewed. $typestringThe content type. $recordarrayThe record data, with the visit counter already increased. Its shape follows the type: a blog record carries a category, a plain page does not. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.content.viewed', 10, function ($id, $type, $record) { // It runs on every visit: keep it light. Acme::trackView($type, $id); }); ``` #### Changing the content detail data filterclient.content_detail.data `ClientContent` passed by link Runs before the content reaches the screen. The place to resolve your own shortcodes or adjust the search headings. Parameters 3 $recordarrayby linkThe record data: title, body, link and search headings. $idintby linkThe record id. $typestringby linkThe content type. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.content_detail.data', 10, function (&$record, &$id, &$type) { if ($type !== 'articles') return; $record['content'] = Acme::resolveShortcodes($record['content'] ?? ''); }); ``` #### Changing the content data before it is saved filterclient.content_save_data `ClientContent` options are text Runs before the main record of a piece of content is written. Parameters 3 $set_dataarrayby linkThe data to be written. ? The options field is **text**, not an array: to change it you must decode, edit and encode again. Writing to it as an array corrupts the record. $page_typestringThe content type. $idintThe record id; **zero on a new record**. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.content_save_data', 10, function (&$set_data, $page_type, $id) { // The options are TEXT: decode, edit, encode again. $opt = Utility::jdecode($set_data['options'] ?? '', true) ?: []; $opt['acme_reviewed'] = 1; $set_data['options'] = Utility::jencode($opt); }); ``` #### Changing the language data before it is saved filterclient.content_lang_data `ClientContent` per language Runs separately for each language of the content. The body is raw markup, which makes this the right place to clean it. Parameters 3 $set_lang_dataarrayby linkThe data of that language: title, address, body and search headings. Leave the address empty and it falls back to the record id. $lkeystringThe language key. $page_typestringThe content type. Return 1 voidThe return is ignored; you write over the data. The hook runs once per language: on a two-language record you are called twice. Listener PHP ```php Hook::add('filter:client.content_lang_data', 10, function (&$set_lang_data, $lkey, $page_type) { // The body is raw markup: this is the right place to clean it. $set_lang_data['content'] = Acme::sanitize($set_lang_data['content'] ?? ''); }); ``` #### Following a page being saved actionclient.page_saved `ClientContent` create and edit Runs after a page is saved. Parameters 3 $idintThe record id; on a new record the real id is passed. $page_typestringThe page type. $is_newboolTrue when newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.page_saved', 10, function ($id, $page_type, $is_new) { Acme::reindex('page', $id); }); ``` #### Following a blog record being saved actionclient.blog.saved `ClientContent` create and edit Runs after a blog record is saved. Parameters 2 $idintThe record id; on a new record the real id is passed. $is_newboolTrue when newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.blog.saved', 10, function ($id, $is_new) { Acme::reindex('page', $id); }); ``` #### Following a news item being saved actionclient.news.saved `ClientContent` create and edit Runs after a news item is saved. Parameters 2 $idintThe record id; on a new record the real id is passed. $is_newboolTrue when newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.news.saved', 10, function ($id, $is_new) { Acme::reindex('page', $id); }); ``` #### Following a slide being saved actionclient.slide.saved `ClientContent` create and edit Runs after a slide is saved. Parameters 2 $idintThe record id; on a new record the real id is passed. $is_newboolTrue when newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.slide.saved', 10, function ($id, $is_new) { Acme::reindex('page', $id); }); ``` #### Following a testimonial being saved actionclient.feedback.saved `ClientContent` the approval state Runs after a customer testimonial on the site is saved. Parameters 3 $idintThe testimonial id. $statusstringIts state: approved or awaiting approval. One awaiting approval **does not appear** on the site: check before doing anything tied to publication. $is_newboolTrue when newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.feedback.saved', 10, function ($id, $status, $is_new) { // One awaiting approval does not appear on the site. if ($status === 'approved') Acme::publishTestimonial($id); }); ``` #### Changing menu edits before they are saved filterclient.menu_save_changes `ClientContent` a batch of operations Runs before changes to the site menu are applied. Parameters 1 $changesarrayby linkThe changes to apply. It is not one record but a **batch**: each entry carries an operation type and its data. Adding, editing, deleting and reordering can sit together in one request. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.menu_save_changes', 10, function (&$changes) { // A batch: adding, editing, deleting and reordering can sit together. $changes = array_values(array_filter($changes, fn ($c) => ($c['type'] ?? '') !== 'delete' || Acme::menuDeletable($c['data'] ?? []))); }); ``` ### Pitfalls > **The options field is text, not an array** > > In the content save filter the options field arrives as **encoded text**. Writing a key into it as though it were an array corrupts the field and leaves the record unreadable. The fix: decode, edit, encode again. > **The language filter runs once per language** > > The language data filter is called **per language**: three times on a three-language record. A listener keeping a counter or doing one-off work runs three times here. Use the main record filter for anything that should happen once. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Knowledge Base Display Hooks](https://dev.wisecp.com/en/knowledge-base-display-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Customer Account and Contact Hooks https://dev.wisecp.com/en/client-account-and-contact-hooks The fourteen hooks over the customer account and site contact: API keys, the profile, currency, the contact form and the newsletter. ### Overview The traces a customer or visitor leaves on the site live here: API keys, the profile, the currency preference, the contact form and the newsletter. In most of these hooks the data comes from an **unverified source**: the visitor filling in the contact form, the address signing up, the bot requesting a page that does not exist. Check it yourself before carrying it to an outside service. ### Reference #### Following API key permissions changing actionclient.api_key_updated `AccountApiKeys` the new permissions only Runs after the permissions of an API key change. A widening of permissions is a security event. Parameters 3 $owner_idintThe owner of the key. $idintThe id of the key record. $permsarrayThe **new** permission set. ? The old set is not passed: to answer "did permissions widen" you must have **kept the previous state yourself**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.api_key_updated', 10, function ($owner_id, $id, $perms) { // The old set is not passed: keep it yourself to compare. Acme::recordScopes($id, $perms); }); ``` #### Following an API key being regenerated actionclient.api_key_regenerated `AccountApiKeys` the record stays Runs after the value of an API key is regenerated. **The record stays, the credential changes**: calls made with the old value no longer pass. Parameters 2 $owner_idintThe owner of the key. $idintThe record id; it is the same after the regeneration. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.api_key_regenerated', 10, function ($owner_id, $id) { // The old value no longer passes: refresh your own cache. Acme::invalidateCachedKey($id); }); ``` #### Following an API key being deleted actionclient.api_key_deleted `AccountApiKeys` the record is gone Runs after an API key is deleted. Parameters 2 $owner_idintThe owner of the key. $idintThe id of the deleted record; the row is gone. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.api_key_deleted', 10, function ($owner_id, $id) { Acme::revokeIntegration($id); }); ``` #### Following a customer profile being updated actionclient.profile_updated `AccountProfile` the account, not the login Runs after a customer profile is updated. Parameters 3 $uidintThe id of the **account** whose profile changed. ? It is **not** the id of whoever signed in: a sub-user with the right permission can update another account’s profile. If you record the actor, read it from the session separately. $data_updatesarrayThe fields written to the main record, **new values only**: name, phone, language, currency. $info_updatesarrayWhat was written to the extra record: customer kind, company details, address fields. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.profile_updated', 10, function ($uid, $data_updates, $info_updates) { // $uid is the ACCOUNT id, not the login. Acme::syncCrm($uid, $data_updates + $info_updates); }); ``` #### Following a currency change actionclient.currency_changed `website` empty for a guest Runs when a visitor changes the site currency. The change is **already applied**. Parameters 3 $new_cidintThe new currency. $old_cidintThe previous currency. $memberarrayThe session of the signed-in member. It is **empty for a guest**: this hook runs for visitors who are not signed in too. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.currency_changed', 10, function ($new_cid, $old_cid, $member) { // For a guest the member data arrives EMPTY. if (!$member) return; Acme::rememberCurrency((int) ($member['id'] ?? 0), $new_cid); }); ``` #### Following a page that was not found actionclient.page_not_found `website` bot traffic included Runs when a requested address does not resolve. It is the most direct way to find broken links. Parameters 1 $urlstringThe full address requested. ? This hook also runs on addresses tried by **scanning bots**, and on a site those can outnumber human traffic. A listener writing a record on every call fills its own table with noise. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.page_not_found', 10, function ($url) { // Bot traffic can outnumber human traffic: filter it. if (Acme::looksLikeScanner($url)) return; Acme::noteBrokenLink($url); }); ``` #### Following a contact form submission actionclient.contact_submitted `ClientContact` comes from a visitor Runs after a visitor submits the contact form. Parameters 6 $message_idintThe id of the message record created. $full_namestringThe sender’s name. $emailstringThe sender’s address. $phonestringTheir phone; it **can be empty**. $messagestringThe message body, stripped of markup. $ipstringTheir network address. All of these **come from a visitor** and none are verified: check them yourself before sending them to an outside service. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.contact_submitted', 10, function ($message_id, $full_name, $email, $phone, $message, $ip) { // All of these come FROM A VISITOR and are unverified. Acme::pushToCrm($message_id, $email, $message); }); ``` #### Following a contact message being answered actionclient.contact_message_replied `ClientContact` after the reply Runs after an administrator answers a contact message. Parameters 2 $messagearrayThe original message answered. $admin_messagestringThe reply text sent. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.contact_message_replied', 10, function ($message, $admin_message) { Acme::closeCrmCase((int) ($message['id'] ?? 0)); }); ``` #### Following a message being reported as spam actionclient.contact_message_spam_reported `ClientContact` two separate flags Runs after a contact message is reported as spam. The two flags beside it say **how far the action went**. Parameters 3 $messagearrayThe message moved to spam, with its address and phone. $block_emailsintWhether the address and phone went onto the block list. $report_spamintWhether the network address was blocked. The two flags are independent: one can be set and the other not. A listener treating them as one reports the wrong thing. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.contact_message_spam_reported', 10, function ($message, $block_emails, $report_spam) { // The two flags are independent. if ($report_spam) Acme::shareBadAddress($message['ip'] ?? ''); }); ``` #### Stopping a message becoming a ticket gateclient.contact_message_to_ticket `ClientContact` before the conversion Runs before a contact message is turned into a support ticket. Parameters 3 $messagearrayThe message to be converted. $departmentintThe target department. $staffintThe staff member to assign; a **zero** means none. Return 1 string|null**A non-empty text blocks the operation** and is shown to the visitor as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:client.contact_message_to_ticket', 10, function ($message, $department, $staff) { if (Acme::blockedSender($message['email'] ?? '')) return 'No ticket can be opened for this sender.'; return null; }); ``` #### Changing the contact page data filterclient.contact_page_data `ClientContact` added keys reach the template Runs before the contact page is shown. The offices and support hours are already resolved for the active language. Parameters 2 $page_dataarrayby linkThe page data. Every key you add **becomes a template variable**: you can use it directly in your theme. $langstringby linkThe active language. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.contact_page_data', 10, function (&$page_data, &$lang) { // A key you add becomes a template variable. $page_data['acme_map'] = Acme::mapEmbed($lang); }); ``` #### Stopping a newsletter sign-up gateclient.newsletter_subscribe `ClientNewsletter` before the record Runs before an address joins the newsletter list. The place to keep disposable addresses out. Parameters 2 $emailstringThe candidate address, lower-cased with its format checked. $langstringThe site language at the moment of signing up. Return 1 string|null**A non-empty text blocks the operation** and is shown to the visitor as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:client.newsletter_subscribe', 10, function ($email, $lang) { if (Acme::disposableDomain($email)) return 'This address is not accepted.'; return null; }); ``` #### Following a newsletter sign-up actionclient.newsletter_subscribed `ClientNewsletter` the language is kept too Runs after an address joins the newsletter list. Parameters 3 $emailstringThe subscriber address. $langstringThe language at sign-up. It is kept and **decides the language of bulk mailings**: get it wrong and the subscriber receives an email they cannot read. $addedintThe id of the record created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.newsletter_subscribed', 10, function ($email, $lang, $added) { // The language decides the content language of bulk mailings. Acme::syncMailingList($email, $lang); }); ``` #### Following a newsletter unsubscribe actionclient.newsletter_unsubscribed `ClientNewsletter` through a link Runs after an address leaves the list. It happens through the link in the email, with no sign-in needed. Parameters 1 $emailstringThe address removed from the list. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.newsletter_unsubscribed', 10, function ($email) { // Remove it from your own list too, or mailings carry on. Acme::dropFromMailingList($email); }); ``` ### Pitfalls > **The id in the profile hook is the account id** > > The id handed to the update hook belongs to the **account being changed**, not to whoever made the change. A sub-user with the right permission can update another account’s profile. If you record "who did it", read it from the session separately. > **The not-found hook fills with bot traffic** > > Scanning bots try addresses that do not exist all day, and on a site those requests easily outnumber human ones. A listener writing a record on every call inflates its own table and buries the real broken links. Put a filter in front of it. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Account and Contact Hooks](https://dev.wisecp.com/en/account-contact-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Customer Panel Data Hooks https://dev.wisecp.com/en/client-panel-data-hooks The fifteen data filters of the customer panel: account tabs, home page panels, invoice lists, and the partner and reseller screens. ### Overview The filters over the data the customer panel carries to the screen live here: the account tabs, the home page panels, the invoice lists, and the partner and reseller screens. Two things recur in this group. First the **active account**: when a sub-user looks at another account, the id you hold belongs to that account rather than to whoever signed in. Second, amounts arrive **formatted**: read the raw value separately if you compare. ### Reference #### Hiding account tabs filterclient.account.tabs `ClientAccount` a visibility map Runs before the tabs of a customer account appear. Closing a tab takes the work behind it out of sight too. Parameters 3 $tabsarrayby linkTab against visibility: profile, contacts, activity, messages, notifications, security and verification. ? This is **visibility only**: closing a tab does not close the operation behind it. To really block access, use the matching gate as well. $uidintby linkThe id of the **active account**. It is not the id of whoever signed in: when a sub-user is looking at another account, that account’s id arrives. $selfboolby linkWhether the active account is the person’s own. A false means a sub-user is looking at another account. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.account.tabs', 10, function (&$tabs, &$uid, &$self) { // Visibility ONLY: it does not close the operation. if (!$self) $tabs['security'] = false; }); ``` #### Widening the data that reaches the template filterclient.predefined_data `Controllers` on every page Runs before **every page** of the customer site appears. Every key you add here becomes a template variable. Parameters 2 $dataarrayby linkThe data bag of the page. ? Overwrite an existing key and **the page loses its own data**. Give your keys a prefix. $ctxarrayby linkContext: the active route and controller name. The hook runs on every page, so check which one you are on before doing anything heavy. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.predefined_data', 10, function (&$data, &$ctx) { // It runs on EVERY page: check the route first. if (($ctx['route'] ?? '') !== 'my-account') return; $data['acme_banner'] = Acme::accountBanner(); }); ``` #### Changing the home page panels filterclient.homepage_panels `website/home` four panels Runs once the panels of the home page are prepared. Parameters 1 $panelsarrayby linkThe data of four panels: spotlight extensions, product cards, feature blocks and announcements. All four arrive in one array: change it without breaking the shape, since the template expects all four keys. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.homepage_panels', 10, function (&$panels) { // All four keys must stay. $panels['product_cards'] = Acme::reorderCards($panels['product_cards'] ?? []); }); ``` #### Changing the maintenance decision filterclient.maintenance `Kernel` reads backwards Runs after the maintenance decision is made and before the page is served. This is how you keep certain addresses out of maintenance. Parameters 3 $passive_maintenanceboolby linkWhat the core decided. ? It **reads backwards**: true means maintenance is **skipped**, false means the maintenance page is shown. Setting it true because the name sounds right takes the site out of maintenance. $controllerstringThe name of the requested controller. $foundAdminboolWhether the request is on the admin address. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.maintenance', 10, function (&$passive_maintenance, $controller, $foundAdmin) { // It reads BACKWARDS: true = maintenance is SKIPPED. if ($controller === 'status') $passive_maintenance = true; }); ``` #### Changing the invoice list rows filterclient.invoice_list_data `ClientInvoices` formatted rows Runs before the customer invoice list reaches the screen. Parameters 2 $rowsarrayby linkThe invoice rows: number, badge, payment link, amount and due line. The amounts are formatted text: read the invoice separately if you need to compare. $uidintThe customer whose list is on screen. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.invoice_list_data', 10, function (&$rows, $uid) { foreach ($rows as $i => $r) $rows[$i]['acme_note'] = Acme::noteFor($r['id'] ?? 0); }); ``` #### Changing the invoice summary filterclient.invoice_list_summary `ClientInvoices` tiles and the notice Runs once the summary above the invoice list is prepared. Parameters 2 $summaryarrayby linkTwo parts: the summary tiles (all, paid, unpaid, overdue) and the outstanding notice. If you change the numbers, update the notice text too: the two are read from different places and can part ways. $uidintThe customer whose summary is on screen. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.invoice_list_summary', 10, function (&$summary, $uid) { // If you change the numbers, update the notice text too. $summary['outstanding']['show'] = Acme::hideOutstanding($uid) ? false : ($summary['outstanding']['show'] ?? false); }); ``` #### Changing the invoice share address filterclient.invoice_share_url `ClientInvoices` access without login Runs after the address is produced when a customer shares an invoice. Parameters 3 $shareUrlstringby linkThe share address produced. ? That address **needs no sign-in**: whoever sees it can open the invoice and the payment page. Writing it into your own store copies the access too. $idintThe invoice id. $ownerIdintThe owner of the invoice. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.invoice_share_url', 10, function (&$shareUrl, $id, $ownerId) { // The address needs no sign-in: keep it out of your own store. $shareUrl = Acme::shorten($shareUrl); }); ``` #### Changing the invoice transaction list filterclient.invoice_transactions `ClientInvoices` refunds included Runs before the payment movements of an invoice reach the screen. Parameters 3 $rowsarrayby linkThe movement rows: method, reference, date, whether it is a refund, and the amount. Refunds are in this list too: mind the flag when totalling. $idintThe invoice id. $invCidintThe currency of the invoice. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.invoice_transactions', 10, function (&$rows, $id, $invCid) { // Refunds are in this list too. foreach ($rows as $i => $r) if ($r['refund'] ?? false) $rows[$i]['method'] = 'Acme refund'; }); ``` #### Changing the bulk payment list filterclient.bulk_pay_rows `ClientInvoices` the active account Runs on the screen where a customer pays several invoices together. Parameters 2 $rowsarrayby linkThe invoice rows that can be paid. An invoice you remove becomes unpayable here: the customer has to pay it separately. $uidintby linkThe id of the **active account**. It is not the id of whoever signed in: when a sub-user is looking at another account, that account’s id arrives. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.bulk_pay_rows', 10, function (&$rows, &$uid) { // An invoice you remove becomes unpayable here. $rows = array_values(array_filter($rows, fn ($r) => !Acme::onHold($r['id'] ?? 0))); }); ``` #### Changing the commission list filterclient.affiliate_commissions `ClientAffiliate` three states Runs before a partner’s commission list reaches the screen. Parameters 2 $commissionsarrayby linkThe commission rows; each carries a **state**: available, clearing or rejected. Keep the three apart when totalling: adding them all shows the partner money they will never receive. $commissionsCtxarrayContext: the account id, the partner record and the currency. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.affiliate_commissions', 10, function (&$commissions, $commissionsCtx) { // Three states: do not total them all. $commissions = array_values(array_filter($commissions, fn ($c) => ($c['flag'] ?? '') !== 'rejected')); }); ``` #### Changing the payout channel list filterclient.affiliate_gateways `ClientAffiliate` a list of labels Runs once the payout channels a partner may pick are prepared. Parameters 2 $outarrayby linkThe channel **labels**. It is a list of plain text, not ids, and the choice is stored as that text. Changing a label can break the match with older records. $gatewaysCtxarrayContext: the language the labels were resolved in. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.affiliate_gateways', 10, function (&$out, $gatewaysCtx) { // Changing a label can break the match with older records. $out[] = 'Acme Wallet'; }); ``` #### Changing the referral destination filterclient.affiliate_referral_redirect `ClientAffiliate` invalid codes arrive too Runs when a visitor clicks a partner link, before the redirect happens. Parameters 2 $redirectstringby linkThe redirect destination. $redirectCtxarrayContext: the raw code in the address, the resolved partner and whether the code is valid. ? The hook **runs on an invalid code too**: a listener using the partner id without checking the valid flag works with a zero. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.affiliate_referral_redirect', 10, function (&$redirect, $redirectCtx) { // It runs on an invalid code too: check the flag first. if (!($redirectCtx['valid'] ?? false)) return; $redirect = Acme::landingFor((int) ($redirectCtx['owner_id'] ?? 0)) ?: $redirect; }); ``` #### Changing the payout request amount filterclient.affiliate_withdraw_amount `ClientAffiliate` the partner currency Runs while a partner requests a payout, before the amount is saved. What you write is what gets recorded. Parameters 4 $amountfloatby linkThe amount requested. $uidintThe account requesting. $cidintThe **partner’s** currency. It is not the currency selected in the store: use this one if you convert. $amountCtxarrayContext information. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.affiliate_withdraw_amount', 10, function (&$amount, $uid, $cid, $amountCtx) { // The currency is the PARTNER'S, not the store's. $amount = Acme::roundToPayoutStep($amount, $cid); }); ``` #### Changing the reseller statistics filterclient.reseller_stats `ClientReseller` worked out from invoices Runs once the figures on the reseller board are worked out. Parameters 2 $statsarrayby linkThe reseller figures: total sales, turnover and discounts, each with today’s counterpart. $statsCtxarrayContext: the account id of the reseller. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.reseller_stats', 10, function (&$stats, $statsCtx) { $stats['acme_target'] = Acme::monthlyTarget((int) ($statsCtx['uid'] ?? 0)); }); ``` #### Changing the reseller tiers filterclient.reseller_tiers `ClientReseller` two separate surfaces Runs once the reseller tier list is prepared. The hook is used on **two separate screens**. Parameters 2 $tiersarrayby linkThe tier rows. $tiersCtxarrayContext: which screen and the account id. ? On the programme page the account id is **zero** (the visitor may not be signed in); on the board the reseller’s id arrives. A rule depending on the id must behave differently on the two. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.reseller_tiers', 10, function (&$tiers, $tiersCtx) { // On the programme page the account id arrives as ZERO. if (($tiersCtx['scope'] ?? '') === 'program') return; $tiers = Acme::highlightCurrent($tiers, (int) ($tiersCtx['uid'] ?? 0)); }); ``` ### Pitfalls > **The maintenance flag reads backwards** > > The value in the maintenance filter answers not "is maintenance on" but **"will maintenance be skipped"**. A listener setting it true because the name sounds right **opens the site to visitors** while you meant it closed. Confirm the direction before changing it. > **Hiding a tab does not close access** > > The account tab filter governs **visibility** only. Closing a tab does not close the operation behind it: somebody who knows the address can still request it directly. Use the matching gate to really block access. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Partner Programme Hooks](https://dev.wisecp.com/en/affiliate-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) ## Customer Service and Message Hooks https://dev.wisecp.com/en/client-service-and-message-hooks The thirteen hooks over the service detail and bulk messaging: the module panel, add-ons, package changes, the usage chart and sending. ### Overview What a customer sees on the service detail, and bulk message sending, live here. The service filters share a pattern around **visibility flags**: hiding a section and emptying it give different outcomes. On the message side everything is counted in **parts**; the recipient count does not give you the cost. ### Reference #### Changing the module panel filterclient.service_management_content `ClientServices` raw markup Runs before the panel produced by the server module is shown to the customer. It is the only place to touch the output of the remote panel. Parameters 2 $contentstringby linkThe **raw markup** the module produced. ? That output can carry values from the remote server and is shown **unescaped**. If you add a value from outside, clean it yourself. $ctxarrayContext: the service record and the panel page requested. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.service_management_content', 10, function (&$content, $ctx) { // The output is shown UNESCAPED. if (($ctx['page'] ?? '') !== 'dashboard') return; $content .= '
    Acme
    '; }); ``` #### Changing the add-on lists filterclient.service_detail.addons `ClientServices` two lists Runs once the add-on lists on the service detail are prepared. **Two separate lists** arrive: the ones owned and the ones on offer. Parameters 3 $addonsOwnedarrayby linkThe add-ons the service holds, active and pending together. $addonsAvailablearrayby linkThe add-on offers that can be bought. Removing one from the offers also blocks the purchase: the customer never sees it. $ctxarrayContext: the service record and the account id. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.service_detail.addons', 10, function (&$addonsOwned, &$addonsAvailable, $ctx) { // Removing an offer also blocks the purchase. $addonsAvailable = array_values(array_filter($addonsAvailable, fn ($a) => Acme::offerAllowed($a, $ctx['service'] ?? []))); }); ``` #### Changing the package change catalogue filterclient.service_detail.upgrade_plans `ClientServices` a visibility flag Runs once the package upgrade and downgrade options are prepared. Parameters 2 $updownarrayby linkThe plan catalogue and its state flags: visibility, the plan list and prices. Closing the visibility flag hides the section entirely; emptying the plan list shows an empty section. The two are different outcomes. $ctxarrayContext: the service record. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.service_detail.upgrade_plans', 10, function (&$updown, $ctx) { // Hiding and emptying are different outcomes. if (Acme::locked($ctx['service'] ?? [])) $updown['visible'] = false; }); ``` #### Changing the licence transfer section filterclient.service_detail.license_transfer `ClientServices` the fee note included Runs once the transfer section of a software licence is prepared. Parameters 2 $ltarrayby linkThe transfer data: visibility, mode, whether a transfer is pending, who pays the fee and the fee note. $ctxarrayContext: the service record and its type. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.service_detail.license_transfer', 10, function (&$lt, $ctx) { // Do not show the section at all for a licence that cannot move. if (Acme::nonTransferable($ctx['service'] ?? [])) $lt['visible'] = false; }); ``` #### Changing the service timeline filterclient.service_detail.activity `ClientServices` the timeline Runs before the history rows of a service reach the screen. The place to put your own events among the core ones. Parameters 2 $activityarrayby linkThe timeline rows. $ctxarrayContext: the service id. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.service_detail.activity', 10, function (&$activity, $ctx) { foreach (Acme::events((int) ($ctx['service_id'] ?? 0)) as $e) $activity[] = $e; }); ``` #### Changing the usage chart filterclient.service_metric_chart `ClientServices` null marks a gap Runs once the data of the usage chart is prepared. Parameters 2 $chartarrayby linkThe chart data: day labels and per-day values. ? A day with no data arrives as **empty**, not zero: turning those into zeros draws a drop that never happened. $ctxarrayContext: the service, the metric key and the month being charted. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.service_metric_chart', 10, function (&$chart, $ctx) { // A day with no data arrives EMPTY: do not turn it into a zero. $chart['acme_limit'] = Acme::planLimit($ctx['metric'] ?? ''); }); ``` #### Stopping a message being sent gateclient.sms_send `ClientSms` before sending Runs before a customer sends messages in bulk. The cost is worked out but **not yet charged**. Parameters 4 $uidintThe account sending. $originstringThe sender identity. $quotearrayThe send summary: recipient count, total and countries. $sendCtxarrayExtra context: the list, the group and the currency. Return 1 string|null**A non-empty text blocks the operation** and is shown to the customer as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:client.sms_send', 10, function ($uid, $origin, $quote, $sendCtx) { // The cost is worked out but NOT yet charged. if ((int) ($quote['recipients'] ?? 0) > Acme::dailyCap($uid)) return 'This exceeds your daily sending limit.'; return null; }); ``` #### Following messages being sent actionclient.sms_sent `ClientSms` what was actually billed Runs after messages go out in bulk. Parameters 4 $uidintThe account sending. $originstringThe sender identity. $quotearrayThe summary of the send. This is **what was actually billed**: recipient count, part count, length and encoding. A long message takes more parts than recipients, and the charge follows the parts. $sentCtxarrayContext: the currency, the sender record, the module and the batch reference. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.sms_sent', 10, function ($uid, $origin, $quote, $sentCtx) { // The charge follows the PARTS, not the recipient count. Acme::recordUsage($uid, (int) ($quote['total_parts'] ?? 0)); }); ``` #### Stopping a sender identity being added gateclient.sms_sender_add `ClientSms` format already checked Runs before a customer adds a new sender identity. Parameters 2 $uidintThe account asking. $namestringThe sender name requested. Its format is already checked: at most eleven letters or digits, no spaces. Your check belongs on the **content**, not the format. Return 1 string|null**A non-empty text blocks the operation** and is shown to the customer as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:client.sms_sender_add', 10, function ($uid, $name) { // The format is already checked: look at the content. if (Acme::reservedBrand($name)) return 'This name cannot be used.'; return null; }); ``` #### Following a sender identity being added actionclient.sms_sender_created `ClientSms` added, not approved Runs after a new sender identity is added. **Being added does not mean it can be used**: some countries need a separate application. Parameters 2 $uidintThe account that added it. $originarrayA summary of the record created: its id and name. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.sms_sender_created', 10, function ($uid, $origin) { // Being added does not mean it can be used. Acme::noteSender($uid, $origin['name'] ?? ''); }); ``` #### Following a sender application actionclient.sms_sender_requested `ClientSms` a country list Runs after an application is made to pre-register a sender identity. Parameters 3 $uidintThe account applying. $originarrayA summary of the sender identity. $codesarrayThe country codes applied for. A first application can cover several countries; a resubmission is usually one. It always arrives as a **list**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.sms_sender_requested', 10, function ($uid, $origin, $codes) { // It always arrives as a list. foreach ($codes as $iso) Acme::trackApplication($uid, $origin['name'] ?? '', $iso); }); ``` #### Following contacts being imported actionclient.sms_contacts_imported `ClientSms` skipped counted separately Runs after a customer imports a contact list. Parameters 4 $uidintThe account importing. $importedintHow many contacts were actually written; **always at least one**. $skippedintHow many rows were skipped for an invalid name or number. The header row skipped automatically is **not** in this number: do not conflate the two when reporting. $group_idintThe target group; a **zero** means the contacts were saved without one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:client.sms_contacts_imported', 10, function ($uid, $imported, $skipped, $group_id) { // The header row is not counted among the skipped. if ($skipped > 0) Acme::warnImportQuality($uid, $imported, $skipped); }); ``` #### Changing the country price table filterclient.sms_rate `ClientSms` per part Runs once the country prices shown to the customer are prepared. Parameters 2 $outarrayby linkCountry against price, with the fee and whether pre-registration is needed. ? The fee is **per part**, not per message: a long message splits into several parts and is charged for each. $ctxarrayContext: the currency the prices were resolved in. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:client.sms_rate', 10, function (&$out, $ctx) { // The fee is PER PART. foreach ($out as $iso => $row) $out[$iso]['rate'] = Acme::applyMargin((float) ($row['rate'] ?? 0)); }); ``` ### Pitfalls > **An empty day on the chart is not a zero** > > A day with no data collected arrives **empty** in the usage chart. Turning it into a zero draws a drop that never happened and shows the customer a false picture of their usage. Leave the gap as a gap. > **Message cost is counted per part** > > A long message splits into several parts and **each part is charged**. A calculation treating the recipient count as the cost falls far short on long messages. Use the part count from the send summary. ### Related Articles - [Service Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [Customer Panel Data Hooks](https://dev.wisecp.com/en/client-panel-data-hooks) - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) ## Customer Domain Screen Hooks https://dev.wisecp.com/en/client-domain-screen-hooks The twenty-seven placement points of the domain detail and list. ### Overview The placement points on the customer domain screens live here: every tab of the detail page, the hero block, the modals and the list. They are all **parameter-less**: the hook does not tell you which domain is on screen. If you need that context, resolve it from the address or from your own side. ### Reference #### Below the hero block uiclient.domain_detail.hero.after `website/domains/detail` no parameters Appears immediately below the hero block of the domain detail. It suits a warning specific to an extension. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.hero.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the hero actions uiclient.domain_detail.hero_actions.end `website/domains/detail` no parameters Appears at the end of the action buttons in the hero block. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.hero_actions.end', 10, function () { return '
    Acme
    '; }); ``` #### The end of the tab bar uiclient.domain_detail.tabs.end `website/domains/detail` no parameters Appears at the end of the tab bar on the domain detail. The place to add the label of your own tab. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.tabs.end', 10, function () { return '
    Acme
    '; }); ``` #### The top of the overview tab uiclient.domain_detail.overview.top `website/domains/detail` no parameters Appears at the very top of the overview tab. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.overview.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the overview tab uiclient.domain_detail.overview.bottom `website/domains/detail` no parameters Appears at the very bottom of the overview tab. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.overview.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the name servers tab uiclient.domain_detail.nameservers.top `website/domains/detail` no parameters Appears above the name server form. It suits a reminder that a wrong setting makes the domain unreachable. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.nameservers.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the name servers tab uiclient.domain_detail.nameservers.bottom `website/domains/detail` no parameters Appears below the name server form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.nameservers.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the DNS tab uiclient.domain_detail.dns.top `website/domains/detail` no parameters Appears above the DNS record table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.dns.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the DNS tab uiclient.domain_detail.dns.bottom `website/domains/detail` no parameters Appears below the DNS record table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.dns.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the contacts tab uiclient.domain_detail.contacts.top `website/domains/detail` no parameters Appears above the domain contact details. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.contacts.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the contacts tab uiclient.domain_detail.contacts.bottom `website/domains/detail` no parameters Appears below the contact details. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.contacts.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the forwarding tab uiclient.domain_detail.forwarding.top `website/domains/detail` no parameters Appears above the address and email forwarding settings. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.forwarding.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the forwarding tab uiclient.domain_detail.forwarding.bottom `website/domains/detail` no parameters Appears below the forwarding settings. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.forwarding.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the transfer tab uiclient.domain_detail.transfer.top `website/domains/detail` no parameters Appears above the transfer section. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.transfer.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the transfer tab uiclient.domain_detail.transfer.bottom `website/domains/detail` no parameters Appears below the transfer section. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.transfer.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the billing tab uiclient.domain_detail.billing.top `website/domains/detail` no parameters Appears above the billing section of the domain. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.billing.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the billing tab uiclient.domain_detail.billing.bottom `website/domains/detail` no parameters Appears below the billing section. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.billing.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the invoice list uiclient.domain_detail.billing_invoices.before `website/domains/detail` no parameters Appears immediately above the list of invoices for the domain. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.billing_invoices.before', 10, function () { return '
    Acme
    '; }); ``` #### The top of the activity tab uiclient.domain_detail.activity.top `website/domains/detail` no parameters Appears above the domain history. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.activity.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the activity tab uiclient.domain_detail.activity.bottom `website/domains/detail` no parameters Appears below the domain history. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.activity.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the protection section uiclient.domain_detail.protection.after `website/domains/detail` no parameters Appears after the privacy and transfer lock settings. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.protection.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the modals uiclient.domain_detail.modals.end `website/domains/detail` no parameters Appears at the end of the modals on the page. The right place to add one of your own, since it disturbs no layout. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain_detail.modals.end', 10, function () { return '
    Acme
    '; }); ``` #### After the attention banner uiclient.domains_list.attention.after `website/domains` no parameters Appears after the attention banner on the domain list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domains_list.attention.after', 10, function () { return '
    Acme
    '; }); ``` #### After the summary tiles uiclient.domains_list.tiles.after `website/domains` no parameters Appears after the summary tiles above the list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domains_list.tiles.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the header actions uiclient.domains_list.header_actions.end `website/domains` no parameters Appears at the end of the action buttons in the list header. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domains_list.header_actions.end', 10, function () { return '
    Acme
    '; }); ``` #### The end of the toolbar uiclient.domains_list.toolbar.end `website/domains` no parameters Appears at the end of the list toolbar. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domains_list.toolbar.end', 10, function () { return '
    Acme
    '; }); ``` #### The end of the list modals uiclient.domains_list.modals.end `website/domains` no parameters Appears at the end of the modals on the list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domains_list.modals.end', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **Which domain is on screen is not passed** > > None of these points hand you the domain. To show something that depends on it, resolve the id **from the address**. Relying on a "last viewed" value from the session shows the wrong domain’s details to a customer with two tabs open. > **Put a modal at the modal point** > > A listener placing its own modal in the middle of a tab pane disturbs the layout, and the modal disappears when the tab changes. There is a **modal point** for this: there it sits outside the page flow. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Customer Service and Message Hooks](https://dev.wisecp.com/en/client-service-and-message-hooks) - Domain Hooks ## Customer Service Screen Hooks https://dev.wisecp.com/en/client-service-screen-hooks The eighteen placement points of the service detail, list and dashboard. ### Overview The placement points on the customer service screens live here: the detail tabs, the resource gauges, the quick actions and the dashboards. Adding a tab takes **two points together**: the label goes on the tab bar and the content at the end of the panes. Using only one leaves half a tab. ### Reference #### The end of the hero actions uiclient.service_detail.hero.actions.end `website/services/detail` no parameters Appears at the end of the action buttons in the hero block of the service detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.hero.actions.end', 10, function () { return '
    Acme
    '; }); ``` #### The end of the tab bar uiclient.service_detail.tabs.end `website/services/detail` no parameters Appears at the end of the tab bar on the service detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.tabs.end', 10, function () { return '
    Acme
    '; }); ``` #### The end of the tab panes uiclient.service_detail.panes.end `website/services/detail` no parameters Appears at the end of the tab panes. **The content of a tab you added to the bar goes here**: the two are used together. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.panes.end', 10, function () { return '
    Acme
    '; }); ``` #### Above the overview uiclient.service_detail.overview.before `website/services/detail` no parameters Appears at the very top of the overview tab. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.overview.before', 10, function () { return '
    Acme
    '; }); ``` #### Below the overview uiclient.service_detail.overview.after `website/services/detail` no parameters Appears at the very bottom of the overview tab. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.overview.after', 10, function () { return '
    Acme
    '; }); ``` #### After the resource gauges uiclient.service_detail.overview.gauges.after `website/services/detail` no parameters Appears after the disk and traffic gauges. The place to add a resource measure of your own. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.overview.gauges.after', 10, function () { return '
    Acme
    '; }); ``` #### Above the quick actions uiclient.service_detail.overview.quick_actions.before `website/services/detail` no parameters Appears above the quick action buttons. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.overview.quick_actions.before', 10, function () { return '
    Acme
    '; }); ``` #### After the quick actions uiclient.service_detail.overview.quick_actions.after `website/services/detail` no parameters Appears after the quick action buttons. It puts an action of your own beside the core ones. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.overview.quick_actions.after', 10, function () { return '
    Acme
    '; }); ``` #### After the panel link on the overview uiclient.service_detail.overview.sso.after `website/services/detail` no parameters Appears after the remote panel link on the overview. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.overview.sso.after', 10, function () { return '
    Acme
    '; }); ``` #### The top of the management tab uiclient.service_detail.management.top `website/services/detail` no parameters Appears above the remote panel tools. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.management.top', 10, function () { return '
    Acme
    '; }); ``` #### After the panel link on the management tab uiclient.service_detail.management.sso.after `website/services/detail` no parameters Appears after the remote panel link on the management tab. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.management.sso.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the modals uiclient.service_detail.modals.end `website/services/detail` no parameters Appears at the end of the modals on the page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_detail.modals.end', 10, function () { return '
    Acme
    '; }); ``` #### The top of the service list uiclient.services_list.top `website/services` no parameters Appears at the very top of the customer service list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.services_list.top', 10, function () { return '
    Acme
    '; }); ``` #### After the summary tiles uiclient.services_list.tiles.after `website/services` no parameters Appears after the summary tiles above the list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.services_list.tiles.after', 10, function () { return '
    Acme
    '; }); ``` #### Above the service dashboard uiclient.service_dashboard.before `website/services` no parameters Appears above the service-specific dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_dashboard.before', 10, function () { return '
    Acme
    '; }); ``` #### Below the service dashboard uiclient.service_dashboard.after `website/services` no parameters Appears below the service-specific dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_dashboard.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the service configuration uiclient.service_config.bottom `website/services` no parameters Appears at the bottom of the service configuration screen. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_config.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The transfer approval page uiclient.service_transfer_approve.body `website/services` no parameters Appears in the body of the service transfer approval page. That page can open for somebody **not signed in**: whoever approves the transfer may have no account. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.service_transfer_approve.body', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **Adding a tab takes two points** > > Put a label on the tab bar and skip the pane point and the customer sees a tab that is **empty when clicked**. Use both, and give them the same identifier. > **The transfer approval page can open without a sign-in** > > Whoever approves a service transfer may hold no account at all. Anything tied to a session **comes out empty** on that page, and showing customer details there opens them to somebody you cannot identify. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Customer Service and Message Hooks](https://dev.wisecp.com/en/client-service-and-message-hooks) - Domain Hooks ## Customer Cart and Checkout Hooks https://dev.wisecp.com/en/client-cart-and-checkout-hooks The twenty-nine placement points from the catalogue to the completed order. ### Overview The placement points along the customer purchase path live here: the catalogue, the product detail, configuration, the basket, checkout and the completed order. Everything you add along this path stands **in front of a buying decision**. A slow script at the payment step turns directly into orders that never complete; put tracking code on the completion page. ### Reference #### Above the plan list uiclient.catalog.plans.before `website/products` no parameters Appears above the plan cards on the catalogue page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.catalog.plans.before', 10, function () { return '
    Acme
    '; }); ``` #### Below the plan list uiclient.catalog.plans.after `website/products` no parameters Appears below the plan cards. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.catalog.plans.after', 10, function () { return '
    Acme
    '; }); ``` #### The foot of a plan card uiclient.plan_card.footer `website/products` no parameters Appears at the foot of **every plan card**, separately. The card itself is not passed: you cannot tell which plan it is from here. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.plan_card.footer', 10, function () { return '
    Acme
    '; }); ``` #### After the product gallery uiclient.product_detail.gallery.after `website/products` no parameters Appears after the image area on the product detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.product_detail.gallery.after', 10, function () { return '
    Acme
    '; }); ``` #### Above the purchase box uiclient.product_detail.purchase.before `website/products` no parameters Appears above the price and the purchase button. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.product_detail.purchase.before', 10, function () { return '
    Acme
    '; }); ``` #### Below the purchase box uiclient.product_detail.purchase.after `website/products` no parameters Appears below the purchase button. It suits a reassuring note or an extra condition. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.product_detail.purchase.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the product tabs uiclient.product_detail.tabs.end `website/products` no parameters Appears at the end of the tab bar on the product detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.product_detail.tabs.end', 10, function () { return '
    Acme
    '; }); ``` #### After the software store grid uiclient.software_store.grid.after `website/products` no parameters Appears after the cards in the software store. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.software_store.grid.after', 10, function () { return '
    Acme
    '; }); ``` #### After the add-on page uiclient.addon_page.after `website/products` no parameters Appears after the content of the add-on purchase page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.addon_page.after', 10, function () { return '
    Acme
    '; }); ``` #### After the configuration sections uiclient.configure.sections.after `website/cart` no parameters Appears after the sections in the order configuration step. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.configure.sections.after', 10, function () { return '
    Acme
    '; }); ``` #### After the configuration add-ons uiclient.configure.addons.after `website/cart` no parameters Appears after the add-on selection in the configuration step. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.configure.addons.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the configuration summary uiclient.configure.summary.bottom `website/cart` no parameters Appears below the price summary in the configuration step. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.configure.summary.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the basket items uiclient.cart.items.top `website/cart` no parameters Appears above the items in the basket. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.cart.items.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the basket summary uiclient.cart.summary.bottom `website/cart` no parameters Appears below the basket total summary. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.cart.summary.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the empty basket message uiclient.cart.empty.after `website/cart` no parameters Appears after the message shown when the basket is empty. It runs **only when the basket is empty**, which makes it right for a suggestion area. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.cart.empty.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the checkout header uiclient.checkout.header.end `website/checkout` no parameters Appears at the end of the header area in the checkout step. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.header.end', 10, function () { return '
    Acme
    '; }); ``` #### After the billing details uiclient.checkout.billing.after `website/checkout` no parameters Appears after the billing address section. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.billing.after', 10, function () { return '
    Acme
    '; }); ``` #### After the payment methods uiclient.checkout.methods.after `website/checkout` no parameters Appears after the payment method choice. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.methods.after', 10, function () { return '
    Acme
    '; }); ``` #### Above the saved cards uiclient.checkout.cards.top `website/checkout` no parameters Appears above the list of saved cards. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.cards.top', 10, function () { return '
    Acme
    '; }); ``` #### After the terms box uiclient.checkout.terms.after `website/checkout` no parameters Appears after the terms checkbox. If you add a consent of your own, **you must enforce it yourself**: the core only knows its own box. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.terms.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the checkout summary uiclient.checkout.summary.bottom `website/checkout` no parameters Appears below the total summary in the checkout step. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.summary.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Before the payment step uiclient.checkout.payment.before `website/checkout` no parameters Appears immediately before the payment begins. It suits a final warning. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.payment.before', 10, function () { return '
    Acme
    '; }); ``` #### The end of the checkout footer uiclient.checkout.footer.end `website/checkout` no parameters Appears in the footer of the checkout step. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.checkout.footer.end', 10, function () { return '
    Acme
    '; }); ``` #### The top of the payment page uiclient.pay.body.top `website/checkout` no parameters Appears at the very top of the payment page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.pay.body.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the payment page uiclient.pay.body.bottom `website/checkout` no parameters Appears at the very bottom of the payment page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.pay.body.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the card form uiclient.pay.card.after `website/checkout` no parameters Appears after the card details form. This area handles card data: remember that a script you add here can reach those fields. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.pay.card.after', 10, function () { return '
    Acme
    '; }); ``` #### After the paid notice uiclient.order_complete.paid.after `website/checkout` no parameters Appears after the payment confirmation on the order complete page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.order_complete.paid.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the order summary uiclient.order_complete.paid.details.end `website/checkout` no parameters Appears at the end of the summary details of the completed order. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.order_complete.paid.details.end', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the complete page uiclient.order_complete.body.bottom `website/checkout` no parameters Appears at the very bottom of the order complete page. The right place for conversion tracking. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.order_complete.body.bottom', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **The core does not enforce a checkbox you add** > > Put your own consent box at the terms point and the core **never checks** whether it is ticked: the order completes with the box empty. You must enforce it on your side. > **Every delay on the payment path is a lost sale** > > A script at the payment step that calls an outside service makes the customer wait **before they pay**. Move work like conversion tracking to the order completion page, where waiting costs nothing. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - Order Hooks - Support Ticket Hooks ## Customer Support Screen Hooks https://dev.wisecp.com/en/client-support-screen-hooks The fourteen placement points of the ticket list, form and conversation. ### Overview The placement points on the customer support screens live here: the list, the new ticket form, the conversation and the reply box. Two points run **for every row**: each message in the conversation and each ticket in the list. Heavy work in those slows the page noticeably. ### Reference #### Above the ticket list uiclient.ticket_list.before `website/tickets` no parameters Appears above the customer ticket list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_list.before', 10, function () { return '
    Acme
    '; }); ``` #### The ticket list actions uiclient.ticket_list.actions `website/tickets` no parameters Appears in the action area of the list header. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_list.actions', 10, function () { return '
    Acme
    '; }); ``` #### The top of the ticket form uiclient.ticket_create.form.top `website/tickets` no parameters Appears above the new ticket form. It suits a notice that should be read before opening one. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_create.form.top', 10, function () { return '
    Acme
    '; }); ``` #### After the form fields uiclient.ticket_create.fields.after `website/tickets` no parameters Appears after the fields on the ticket form. If you add a field of your own here, **you must handle the submitted value yourself**. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_create.fields.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the ticket form uiclient.ticket_create.form.bottom `website/tickets` no parameters Appears below the ticket form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_create.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the ticket form sidebar uiclient.ticket_create.sidebar.bottom `website/tickets` no parameters Appears at the bottom of the column beside the ticket form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_create.sidebar.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the ticket sidebar uiclient.ticket_detail.sidebar.top `website/tickets` no parameters Appears at the top of the side column on the ticket detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.sidebar.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the ticket sidebar uiclient.ticket_detail.sidebar.bottom `website/tickets` no parameters Appears at the bottom of the side column. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.sidebar.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The ticket action area uiclient.ticket_detail.actions `website/tickets` no parameters Appears in the action area of the ticket detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.actions', 10, function () { return '
    Acme
    '; }); ``` #### The top of the conversation uiclient.ticket_detail.thread.top `website/tickets` no parameters Appears above the reply thread. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.thread.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the conversation uiclient.ticket_detail.thread.bottom `website/tickets` no parameters Appears below the reply thread. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.thread.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the reply form uiclient.ticket_detail.reply_form.top `website/tickets` no parameters Appears above the customer reply box. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.reply_form.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the reply form uiclient.ticket_detail.reply_form.bottom `website/tickets` no parameters Appears below the reply box. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_detail.reply_form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After a single message uiclient.ticket_message.body.after `website/tickets` no parameters Appears after **every message** in the conversation, separately. The message itself is not passed, so you cannot tell which one it is. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ticket_message.body.after', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **The message point runs on every message** > > The message point in the conversation is called **separately for every message** in the thread. On a long ticket that means dozens of calls, and a listener querying inside it slows the page markedly. > **A form field you add is not saved by itself** > > A field you put on the ticket form is **only visible**. To handle the submitted value you must also listen on the matching hook; otherwise the customer fills it in and the value disappears. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - Order Hooks - Support Ticket Hooks ## Customer Site Shell Hooks https://dev.wisecp.com/en/client-site-shell-hooks The sixteen placement points shared by every page: the head, the body, menus and the footer. ### Overview The placement points present on every page of the site live here: the head section, the body, the menus, the footer and the system pages. What they share is **scale**: what you place here runs on the home page, at the payment step and on an error page alike. Weigh the cost at that scale. ### Reference #### Adding a style to the site uiclient.head.css `website/inc` no parameters Appears in the head section of the site, on **every page**. Link a stylesheet of your own here. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.head.css', 10, function () { return '
    Acme
    '; }); ``` #### Adding a script to the site uiclient.head.js `website/inc` no parameters The place to add a script on every page. It is the first thing a visitor meets: a heavy file delays the page opening. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.head.js', 10, function () { return '
    Acme
    '; }); ``` #### The start of the body uiclient.body.begin `website/inc` no parameters Appears at the very start of the page body. It suits an announcement banner across the site. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.body.begin', 10, function () { return '
    Acme
    '; }); ``` #### The end of the body uiclient.body.end `website/inc` no parameters Appears at the very end of the page body. The right place for modals and tracking scripts. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.body.end', 10, function () { return '
    Acme
    '; }); ``` #### The end of the top bar uiclient.topbar.end `website/inc` no parameters Appears at the end of the top bar. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.topbar.end', 10, function () { return '
    Acme
    '; }); ``` #### The header action area uiclient.header.actions `website/inc` no parameters Appears in the action area of the site header. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.header.actions', 10, function () { return '
    Acme
    '; }); ``` #### The main menu items uiclient.nav.items `website/inc` no parameters Appears among the items of the main menu. Use the same item markup so the menu structure holds. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.nav.items', 10, function () { return '
    Acme
    '; }); ``` #### The sub-menu items uiclient.subnav.items `website/inc` no parameters Appears among the items of the sub-menu. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.subnav.items', 10, function () { return '
    Acme
    '; }); ``` #### The user menu items uiclient.user_menu.items `website/inc` no parameters Appears in the menu of a signed-in user. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.user_menu.items', 10, function () { return '
    Acme
    '; }); ``` #### The drawer items uiclient.drawer.items `website/inc` no parameters Appears among the items of the mobile drawer. It is **separate from the main menu**: add to both if you want it in both places. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.drawer.items', 10, function () { return '
    Acme
    '; }); ``` #### Above the footer uiclient.footer.before `website/inc` no parameters Appears immediately above the footer. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.footer.before', 10, function () { return '
    Acme
    '; }); ``` #### The footer columns uiclient.footer.columns `website/inc` no parameters Appears among the footer columns. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.footer.columns', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the footer uiclient.footer.bottom `website/inc` no parameters Appears at the very bottom of the footer. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.footer.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the call-to-action band uiclient.cta_band.before `website/inc` no parameters Appears above the call-to-action band at the foot of the page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.cta_band.before', 10, function () { return '
    Acme
    '; }); ``` #### The maintenance page body uiclient.maintenance.body `website/inc` no parameters Appears in the body of the maintenance page. It runs **while the site is closed**: a listener reaching the database or an outside service here can take down the page at an already bad moment. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.maintenance.body', 10, function () { return '
    Acme
    '; }); ``` #### The system page head uiclient.system_page.head `website/inc` no parameters Appears in the head of the error and blocked pages. Those pages are shown **even when the core has failed**: give simple output with no dependencies. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.system_page.head', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **Shell points run at the payment step too** > > The head and body points run on **every page** of the site, the basket and the payment step included. A slow script there delays not one page but the purchase path itself. > **Build no dependencies on the maintenance and error pages** > > The maintenance and system page points run at the moment the site is **already in trouble**. A listener reaching for the database or an outside service there takes down the last page the user would have seen. Give simple output with no dependencies. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Customer Panel Data Hooks](https://dev.wisecp.com/en/client-panel-data-hooks) - [Site Content Hooks](https://dev.wisecp.com/en/client-content-hooks) ## Customer Account Screen Hooks https://dev.wisecp.com/en/client-account-screen-hooks The twenty-nine placement points of the dashboard, the account pages and invoices. ### Overview The placement points on the screens a signed-in customer sees live here: the dashboard, the account pages, the balance, subscriptions, the partner programme and invoices. Two points fall outside that pattern: the invoice detail and the licence transfer result can open for somebody **not signed in**. Your output there must not depend on a session. ### Reference #### The top of the dashboard uiclient.dashboard.top `website/account` no parameters Appears at the very top of the customer dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.dashboard.top', 10, function () { return '
    Acme
    '; }); ``` #### After the dashboard panels uiclient.dashboard.panels.after `website/account` no parameters Appears after the panels on the dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.dashboard.panels.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the dashboard uiclient.dashboard.bottom `website/account` no parameters Appears at the very bottom of the dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.dashboard.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the account tabs uiclient.account.tabs.after `website/account` no parameters Appears after the tab bar on the account page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.account.tabs.after', 10, function () { return '
    Acme
    '; }); ``` #### After the account cards uiclient.account.cards.after `website/account` no parameters Appears after the account summary cards. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.account.cards.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the profile form uiclient.account.profile.form.bottom `website/account` no parameters Appears below the profile form. If you add a field, **you must wire up its saving too**. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.account.profile.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the security section uiclient.account.security.after `website/account` no parameters Appears after the account security section. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.account.security.after', 10, function () { return '
    Acme
    '; }); ``` #### After the settings uiclient.account.settings.after `website/account` no parameters Appears after the account settings. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.account.settings.after', 10, function () { return '
    Acme
    '; }); ``` #### After the sub-account list uiclient.sub_accounts.list.after `website/account` no parameters Appears after the sub-user list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.sub_accounts.list.after', 10, function () { return '
    Acme
    '; }); ``` #### After the API keys uiclient.api_credentials.after `website/account` no parameters Appears after the API key list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.api_credentials.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the balance overview uiclient.balance_overview.bottom `website/account` no parameters Appears below the balance overview. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.balance_overview.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the balance history uiclient.balance_history.top `website/account` no parameters Appears above the balance movement list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.balance_history.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the top-up form uiclient.add_funds.bottom `website/account` no parameters Appears below the wallet top-up form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.add_funds.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the subscriptions list uiclient.subscriptions.top `website/account` no parameters Appears above the list of payment agreements. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.subscriptions.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the partner dashboard uiclient.affiliate_dashboard.top `website/account` no parameters Appears at the top of the partner programme dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.affiliate_dashboard.top', 10, function () { return '
    Acme
    '; }); ``` #### After the partner statistics uiclient.affiliate_stats.after `website/account` no parameters Appears after the partner statistics. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.affiliate_stats.after', 10, function () { return '
    Acme
    '; }); ``` #### The top of the reseller dashboard uiclient.reseller_dashboard.top `website/account` no parameters Appears at the top of the reseller dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.reseller_dashboard.top', 10, function () { return '
    Acme
    '; }); ``` #### The certificate action area uiclient.ssl_dashboard.actions `website/account` no parameters Appears in the action area of the certificate dashboard. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.ssl_dashboard.actions', 10, function () { return '
    Acme
    '; }); ``` #### The top of the message panel uiclient.sms_panel.top `website/account` no parameters Appears at the top of the bulk message panel. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.sms_panel.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the message form uiclient.sms_send.top `website/account` no parameters Appears above the message sending form. It suits a reminder that the charge is counted per part. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.sms_send.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the message form uiclient.sms_send.bottom `website/account` no parameters Appears below the message sending form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.sms_send.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the invoice page uiclient.invoice_detail.top `website/invoices` no parameters Appears at the very top of the invoice detail. It runs on an invoice opened **through a share link** too, where the visitor may not be signed in. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.invoice_detail.top', 10, function () { return '
    Acme
    '; }); ``` #### After the invoice lines uiclient.invoice_detail.items_after `website/invoices` no parameters Appears after the invoice lines. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.invoice_detail.items_after', 10, function () { return '
    Acme
    '; }); ``` #### After the transaction list uiclient.invoice_detail.transactions.after `website/invoices` no parameters Appears after the payment movements. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.invoice_detail.transactions.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the invoice page uiclient.invoice_detail.bottom `website/invoices` no parameters Appears at the very bottom of the invoice detail. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.invoice_detail.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the invoice list uiclient.invoice_list.top `website/invoices` no parameters Appears above the invoice list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.invoice_list.top', 10, function () { return '
    Acme
    '; }); ``` #### The invoice list toolbar uiclient.invoice_list.toolbar `website/invoices` no parameters Appears in the toolbar of the invoice list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.invoice_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The top of the bulk payment page uiclient.bulk_pay.top `website/invoices` no parameters Appears above the screen for paying several invoices together. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.bulk_pay.top', 10, function () { return '
    Acme
    '; }); ``` #### Above the transfer result page uiclient.license_transfer_result.before `website/account` no parameters Appears above the licence transfer result page. That page opens **through a link in an email**, so the visitor may not be signed in. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.license_transfer_result.before', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **The invoice page can open without a sign-in** > > The invoice detail also opens through a **share link**, where the visitor has no session. Output depending on one comes out empty there, and showing customer details opens them to somebody you cannot identify. > **Adding a field to the profile form does not save it** > > A field you put on the account form is **only visible**. For the value to be kept you must also hook into the profile update flow; otherwise the customer fills it in, saves, and it disappears. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Customer Panel Data Hooks](https://dev.wisecp.com/en/client-panel-data-hooks) - [Site Content Hooks](https://dev.wisecp.com/en/client-content-hooks) ## Customer Content Screen Hooks https://dev.wisecp.com/en/client-content-screen-hooks The twenty-seven placement points of the home page, content, the knowledge base and the sign-in forms. ### Overview The placement points on the pages open to visitors live here: the home page, content and the blog, contact, the knowledge base and the sign-in forms. Most of these pages are **open to everyone**. A script you place here is run by anyone not signed in, and anything depending on a session comes out empty. ### Reference #### After the home hero uiclient.home.hero.after `website/content` no parameters Appears after the opening block of the home page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.home.hero.after', 10, function () { return '
    Acme
    '; }); ``` #### The end of the home sections uiclient.home.sections.end `website/content` no parameters Appears at the end of the home page sections. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.home.sections.end', 10, function () { return '
    Acme
    '; }); ``` #### The top of a content page uiclient.content.top `website/content` no parameters Appears at the top of the content pages. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.content.top', 10, function () { return '
    Acme
    '; }); ``` #### After the page content uiclient.content_page.content.after `website/content` no parameters Appears after the content of a plain page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.content_page.content.after', 10, function () { return '
    Acme
    '; }); ``` #### After a blog article uiclient.blog_detail.content.after `website/content` no parameters Appears after the body of a blog article. It suits an author box or related posts. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.blog_detail.content.after', 10, function () { return '
    Acme
    '; }); ``` #### After the blog feed uiclient.blog_list.feed.after `website/content` no parameters Appears after the blog list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.blog_list.feed.after', 10, function () { return '
    Acme
    '; }); ``` #### After the news feed uiclient.news_list.feed.after `website/content` no parameters Appears after the news list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.news_list.feed.after', 10, function () { return '
    Acme
    '; }); ``` #### After the reference content uiclient.reference_detail.content.after `website/content` no parameters Appears after the content of a reference page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.reference_detail.content.after', 10, function () { return '
    Acme
    '; }); ``` #### The top of the contact form uiclient.contact.form.top `website/content` no parameters Appears above the contact form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.contact.form.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the contact form uiclient.contact.form.bottom `website/content` no parameters Appears below the contact form. If you add a field, handling the submitted value is your job. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.contact.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the contact sidebar uiclient.contact.aside.bottom `website/content` no parameters Appears at the bottom of the side column on the contact page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.contact.aside.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the domain search uiclient.domain.search.after `website/content` no parameters Appears after the domain search box. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.domain.search.after', 10, function () { return '
    Acme
    '; }); ``` #### The top of the knowledge base landing uiclient.kb_landing.content.top `website/knowledgebase` no parameters Appears at the top of the knowledge base landing page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_landing.content.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the knowledge base landing uiclient.kb_landing.content.bottom `website/knowledgebase` no parameters Appears at the bottom of the landing page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_landing.content.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the search box uiclient.kb_landing.search.after `website/knowledgebase` no parameters Appears after the knowledge base search box. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_landing.search.after', 10, function () { return '
    Acme
    '; }); ``` #### After the category header uiclient.kb_category.header.after `website/knowledgebase` no parameters Appears after the header of a category page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_category.header.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the category content uiclient.kb_category.content.bottom `website/knowledgebase` no parameters Appears at the bottom of the category page content. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_category.content.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the table of contents uiclient.kb_article.toc.after `website/knowledgebase` no parameters Appears after the table of contents of an article. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_article.toc.after', 10, function () { return '
    Acme
    '; }); ``` #### After the article content uiclient.kb_article.content.after `website/knowledgebase` no parameters Appears after the article body. It suits a feedback area or related articles. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_article.content.after', 10, function () { return '
    Acme
    '; }); ``` #### The article footer uiclient.kb_article.footer `website/knowledgebase` no parameters Appears in the footer of an article. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_article.footer', 10, function () { return '
    Acme
    '; }); ``` #### The top of the knowledge base sidebar uiclient.kb_sidebar.top `website/knowledgebase` no parameters Appears at the top of the knowledge base side column. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_sidebar.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the knowledge base sidebar uiclient.kb_sidebar.bottom `website/knowledgebase` no parameters Appears at the bottom of the side column. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.kb_sidebar.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the sign-in form uiclient.login.form.bottom `website/content` no parameters Appears below the sign-in form. That page is **open to everyone**: a script placed here runs for anyone not signed in. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.login.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the sign-up form uiclient.register.form.bottom `website/content` no parameters Appears below the sign-up form. If you add an extra consent, **enforce it yourself**. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.register.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the password recovery form uiclient.auth.forget.form.bottom `website/content` no parameters Appears below the password recovery form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.auth.forget.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the password reset form uiclient.auth.reset.form.bottom `website/content` no parameters Appears below the password reset form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.auth.reset.form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### After the invitation actions uiclient.auth.invite.actions.after `website/content` no parameters Appears after the actions on the sub-user invitation page. The invited person **may hold no account yet**. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:client.auth.invite.actions.after', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **These pages are open to everyone** > > Code you place on the sign-in, sign-up and content pages is run by anyone, **identified or not**. Keep internal details, keys and anything meant only for a customer out of them. > **On the invitation page the other party may have no account** > > The sub-user invitation page can open for somebody with **no record at all** in the system. Output depending on a session or an existing customer record comes out empty there. ### Related Articles - [Customer Site Hooks](https://dev.wisecp.com/en/hooks-on-the-customer-site) - [Customer Panel Data Hooks](https://dev.wisecp.com/en/client-panel-data-hooks) - [Site Content Hooks](https://dev.wisecp.com/en/client-content-hooks) # Hooks / Management Panel ## Hooks in the Management Panel https://dev.wisecp.com/en/hooks-in-the-management-panel The 87 points that add to a screen in the management panel. Plus the 7 hooks making a new capability known to it. ### Overview The panel takes two kinds of widening. **Screen points** add HTML to a page that already exists. **Registration hooks** make something new known to the panel: a menu entry, a dashboard box, a permission, a report. The two serve different jobs. Putting information in a corner of a screen is a screen point. Opening a page and a permission of your own is a registration hook. ### Structure Panel templates are plain PHP and a hook point is called inside the template itself. The naming follows the same pattern as the customer site: family, screen, spot. ```php 'Acme', 'status' => 'open', 'size' => 6]; }); // THE MENU: the return is ignored and the change goes through the global menu structure Hook::add('register:admin.menu', 10, function () { $GLOBALS['menus']['acme'] = ['name' => 'Acme', 'link' => 'acme/report']; }); ``` ### Pitfalls > **A menu entry opens no permission** > > The menu hook puts **the link** there and nothing else. The page itself still wants a permission, and a separate registration hook makes that permission known. Without both, an operator sees the entry and gets a **refused** answer on clicking it. > **Panel templates are plain PHP** > > On the customer site a hook is called with a template tag. In the panel it is a direct PHP call and the returns are echoed **by hand**. So how many values a point passes changes from screen to screen. Check the parameters against the hook's entry. > **Registration hooks share no contract** > > Some of the seven want you to return an array and some want you to add to a structure handed to you. Getting it the wrong way round is **silent**: you return your array, nothing appears, and no error is raised. > **Panel hooks are not open to the customer** > > These points run on signed-in staff pages alone, so a customer never sees them. Even so, filter what you show **by the staff member's permissions**: not every operator should see everything. ### Related Articles - [Management Panel Data and Gates](https://dev.wisecp.com/en/management-panel-data-and-gates) - [Hooks on the Customer Site](https://dev.wisecp.com/en/hooks-on-the-customer-site) - Module Lifecycle Hooks ## Management Panel Data and Gates https://dev.wisecp.com/en/management-panel-data-and-gates The management panel away from the screen. 23 filters change lists and forms, 6 gates sit on staff actions, and 19 hooks report an event. ### Overview The panel's strongest hooks are here. **List and form filters** touch nearly every screen, because lists and forms come from one structure. You write one filter and it runs on dozens of screens. That power cuts both ways. A filter that does not pick its target touches **the whole panel**. Each filter hands you the table or form name, and checking it is your job. ### Reference #### The ones that change data The main ones among twenty-three. Most hand the value over by reference and ignore the return. The operation filters carry a **mixed** contract acting on what you return. - **filter:admin.table.rows**: Every list row — the cells, the row classes, the raw record. The row changes by reference; the return is ignored. - **filter:admin.table.columns**: The list columns; you add, drop and reorder them. The array changes by reference; the return is ignored. - **filter:admin.form_builder**: The fields the form builder produces. - **filter:admin.dashboard.statistics**: The number cards on the dashboard. - **filter:admin.dashboard.widgets**: The list of dashboard boxes. - **filter:admin.menu_management**: The menu tree. - **filter:admin.notifications**: The notifications reaching staff. - **filter:admin.bubble_counts**: The counter bubbles on the menu. - **filter:admin.health_status**: The system health indicator. - **filter:admin.settings_save**: The settings about to be saved. - **filter:admin.operation.before**: Before an operation runs — you can stop it or hand back its output. - **filter:admin.operation.after**: After an operation ran, before the answer leaves. - **filter:api.response**: An API answer — the body going to consumers outside the panel. #### The ones that stop All six sit on staff and permission work. Returning a non-empty string stops the action, and the operator reads that text as the error. - **gate:admin.staff_create**: Opening a new staff account. Returning a non-empty string vetoes it; that text becomes the error. - **gate:admin.staff_delete**: Deleting a staff account. Returning a non-empty string blocks the deletion; that text becomes the error. - **gate:admin.privilege_save**: Saving a privilege group. - **gate:admin.department_delete**: Deleting a department. - **gate:admin.editor_upload**: Uploading a file from the editor. - **gate:admin.password_change**: Changing a password. #### The ones that report - **action:admin.staff_created**: A staff account was opened. - **action:admin.settings_saved**: Settings were saved. - **action:admin.activity_logged**: An activity was recorded in the panel. - **action:security.settings_updated**: Security settings changed. - **action:admin.two_factor_changed**: Two-step verification was switched on or off. #### A filter with a target ```php // The second parameter IS THE TABLE NAME. Without checking it the filter runs on every list. Hook::add('filter:admin.table.rows', 10, function (&$row, $table_name) { if ($table_name !== 'serviceList') return null; $id = (int) ($row['model']['id'] ?? 0); if (Acme::flagged($id)) $row['attributes']['class'] = 'table-warning'; }); ``` ### Pitfalls > **A filter that skips the table name hits every list** > > Row and column filters run on **every** list in the panel. Skip the name check in the second parameter and a rule meant for the service list lands on invoices, customers and tickets. Make your first line the **name check**. > **Adding a column does not fill the cell** > > The column filter opens **the heading** and nothing else. The cell value for that column comes from the row filter. Writing one without the other leaves an empty column, or a cell that shows up nowhere. > **The operation filter acts on what you return** > > The before-operation filter carries a **mixed** contract. An error array stops the operation. A variables array changes the inputs. An output array answers without running it at all. A wrong key name **silently** means "carry on". > **The row filter runs once per row** > > On a hundred-row list the row filter runs **a hundred times**. A query inside it turns the list into a hundred queries. Gather what you need **once** into a static variable and read only that inside the listener. ### Related Articles - [Hooks in the Management Panel](https://dev.wisecp.com/en/hooks-in-the-management-panel) - [Customer Site Data and Gates](https://dev.wisecp.com/en/customer-site-data-and-gates) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) ## Panel Staff and Privilege Hooks https://dev.wisecp.com/en/admin-staff-hooks The ten hooks over staff accounts, privilege groups, departments and panel settings. ### Overview Everything about who uses the panel lives here: staff accounts, privilege groups, support departments and an administrator’s own preferences. These hooks share one pattern: what you receive is not the whole record but the **fields that changed**. When nothing changed the array is empty. ### Reference #### Following a staff record being updated actionadmin.staff_updated `AdminStaff` only what changed Runs after an administrator account is updated. Parameters 3 $idintThe id of the administrator updated. $dataarrayThe fields that **changed** on the main record: status, address, name, language, privilege or password. It **arrives empty** when none changed: this answers "what changed", not "what the record holds". $infoarrayThe changed fields on the extra record: signature, notes, display preference and phone. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.staff_updated', 10, function ($id, $data, $info) { // The arrays hold what CHANGED, not the whole record. if (isset($data['privilege'])) Acme::auditPrivilegeChange($id, $data['privilege']); }); ``` #### Following a staff member being removed actionadmin.staff_deleted `AdminStaff` the id only Runs after an administrator account is deleted. Parameters 1 $idintThe id of the deleted administrator. No record is passed: if you need the name or address you must have kept it beforehand. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.staff_deleted', 10, function ($id) { // No record is passed: keep what you need beforehand. Acme::revokeAllTokens($id); }); ``` #### Following two-step verification being switched off actionadmin.staff_authentication_disabled `AdminStaff` security weakens Runs after two-step verification is switched off for an administrator. It is a **weakening of security** and is usually done by somebody else. Parameters 2 $idintThe administrator it was switched off for. $methodstringThe key of the method switched off. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.staff_authentication_disabled', 10, function ($id, $method) { // Security just weakened: do not stay quiet. Acme::alertSecurityTeam($id, $method); }); ``` #### Following a privilege group being saved actionadmin.privilege_saved `AdminStaff` id unreliable on create Runs when a privilege group is created or edited. Parameters 4 $idintThe id of the group. ? **On the create path it is not the new id** and may be zero. Tell a new record from the operation type, not from this. $typestringThe operation: `add` or `edit`. $namestringThe name of the group. $permsstringThe permission keys as saved, comma separated. It is not an array: split it to work with it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.privilege_saved', 10, function ($id, $type, $name, $perms) { // Tell a new record from the operation TYPE, not the id. if ($type === 'add') Acme::noteNewRole($name, explode(',', $perms)); }); ``` #### Following an administrator updating their own profile actionadmin.profile_updated `AdminStaff` their own profile Runs when an administrator updates their own profile. Parameters 3 $admin_idintThe administrator whose profile changed. $data_updatesarrayThe changed fields on the main record: name and address. Empty when nothing changed. $info_updatesarrayWhat changed on the extra record: the phone fields. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.profile_updated', 10, function ($admin_id, $data_updates, $info_updates) { if (isset($data_updates['email'])) Acme::alertEmailChange($admin_id); }); ``` #### Following panel preferences changing actionadmin.preferences_updated `AdminStaff` display preferences Runs when an administrator changes how the panel looks for them. Parameters 3 $admin_idintThe administrator whose preferences changed. $info_setsarrayThe display preferences that changed: light or dark, and the menu state. $data_setsarrayWhat changed on the main record: their language. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.preferences_updated', 10, function ($admin_id, $info_sets, $data_sets) { if (isset($data_sets['lang'])) Acme::syncLocale($admin_id, $data_sets['lang']); }); ``` #### Following a department being saved actionadmin.department_saved `AdminStaff` id correct on create Runs when a support department is created or edited. Parameters 3 $idintThe department id. Unlike the privilege hook, the id here is **correct on the create path too**. $typestringThe operation: `add` or `edit`. $set_dataarrayThe saved fields: rank, appointees and icon. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.department_saved', 10, function ($id, $type, $set_data) { // Here the id is correct on the create path as well. Acme::syncDepartment($id, $set_data); }); ``` #### Following a department being deleted actionadmin.department_deleted `AdminStaff` after deletion Runs after a support department is deleted. Parameters 2 $idintThe id of the deleted department. $detailarrayThe record of the deleted department, name included. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.department_deleted', 10, function ($id, $detail) { Acme::dropDepartmentRoute($id); }); ``` #### Following logs being cleared actionadmin.logs_cleared `AdminTools` cannot be undone Runs when an administrator clears a log type. What is deleted **does not come back**. Parameters 2 $typestringWhat was cleared: user actions, errors or module records. $datestringThe cut-off; records up to and **including** that date are gone. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.logs_cleared', 10, function ($type, $date) { // What is gone stays gone: note it in your own archive. Acme::noteRetention($type, $date); }); ``` #### Following notification settings being saved actionadmin.notification_settings_saved `AdminSettings` template engine Runs when the engine used for notification templates changes. That setting decides how **every** notification is produced. Parameters 2 $enginestringThe chosen engine: none, Smarty or Twig. $admin_idintThe administrator who changed it; zero when there is no session. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.notification_settings_saved', 10, function ($engine, $admin_id) { // Every notification is produced through this setting. Acme::notifyOps('notification engine: ' . $engine); }); ``` ### Pitfalls > **The id is unreliable when a privilege group is created** > > On the create path the id in the privilege hook is **not the id of the new record** and may be zero. Tell a new record from the operation type. The department hook does not share this flaw; there the id is right on both paths. > **The arrays carry what changed, not the whole record** > > The arrays in the staff, profile and preference hooks hold only the **fields that changed**. A field being absent does not mean "removed", it means "untouched". Read the record separately if you need its current state. ### Related Articles - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Panel Analytics Hooks https://dev.wisecp.com/en/admin-analytics-hooks The six hooks over report figures, date ranges, charts and live counters. ### Overview Every number on the report pages passes through these hooks: summary figures, time series, distributions and finance totals. Beside them sits the read-only event of the live counters. One rule runs through all of them: **keep the shape**. The chart library and the queries that follow expect particular keys; a missing or mismatched structure quietly yields an empty report. ### Reference #### Following a live metric poll actionadmin.analytics.realtime_polled `admin/analytics` read only Runs when the live counters on the board refresh. It repeats **at regular intervals** while the page is open. Parameters 1 $statsarrayThe live metrics: how many are online, visitors in the last five minutes, and map markers. A copy is passed: **changing it has no effect**. It is deliberately an event, not a filter. Return 1 voidThe return is ignored; the data cannot be changed. Listener PHP ```php Hook::add('action:admin.analytics.realtime_polled', 10, function ($stats) { // A copy is passed: changing it has no effect. Acme::pushMetric('online', (int) ($stats['online_count'] ?? 0)); }); ``` #### Changing the report date range filteradmin.analytics.date_range `admin/analytics` keys must survive Runs after the date range picked by the user is resolved. Every report query uses that range. Parameters 2 $resultarrayby linkThe resolved range: start, end, the previous period’s start and end, and the day count. ? **Every key must survive**: a missing one breaks the queries that follow and the report comes out empty. $rangestringThe raw range text from the user. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.analytics.date_range', 10, function (&$result, $range) { // Every key must stay: a missing one empties the report. $result['start'] = Acme::fiscalStart($result['start']); }); ``` #### Changing the summary figures filteradmin.analytics.kpis `admin/analytics` shape follows the section Runs once the summary figures at the top of a report page are worked out. Parameters 3 $kpisarrayby linkThe values worked out. ? **Its shape follows the section**: the keys in the client report are not the keys in the service report. Check the section before reaching for a key. $sectionstringThe report section: clients, services or support. $rangearrayThe active date range. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.analytics.kpis', 10, function (&$kpis, $section, $range) { // The shape follows the section: check it first. if ($section !== 'clients') return; $kpis['acme_churn'] = Acme::churnRate($range); }); ``` #### Changing the time series filteradmin.analytics.trend_series `admin/analytics` equal lengths required Runs once the chart data is worked out. Parameters 3 $trendarrayby linkLabels and one or more value series. ? **The labels and the series must be the same length**; the chart draws its axes from that. Keep the existing series keys and add a new series under its own key. $reportstringThe report key: income, expense, profit and loss, clients, services or support. $rangearrayThe active date range. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.analytics.trend_series', 10, function (&$trend, $report, $range) { // Labels and series must be the same length. if ($report !== 'income') return; $trend['acme_forecast'] = Acme::forecast(count($trend['labels'] ?? [])); }); ``` #### Changing the distribution data filteradmin.analytics.distribution `admin/analytics` fixed item shape Runs once the distribution chart data is worked out. Parameters 4 $dataarrayby linkThe distribution items. Each item must carry a **label and a value**; the chart library expects that shape and any other quietly yields an empty chart. $entitystringWhat is distributed: clients, services or tickets. $typestringThe kind: status, country, language, product, period, priority, department, staff or response time. $rangearrayThe active date range. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.analytics.distribution', 10, function (&$data, $entity, $type, $range) { // Every item must carry a label and a value. if ($entity === 'client' && $type === 'country') $data = Acme::mergeSmallCountries($data); }); ``` #### Changing the finance summary filteradmin.analytics.invoice_report_totals `admin/analytics` currency filtered Runs once the summary totals of a finance report are worked out. Parameters 4 $totalsarrayby linkThe summary totals: count, amount, day count, daily average and currency. $reportstringWhich report: income, expense, profit and loss, tax, cancelled, refunded or payment method. $rangearrayThe active date range. $currency_idintThe currency filtered on; a **zero** means the base currency. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.analytics.invoice_report_totals', 10, function (&$totals, $report, $range, $currency_id) { // A zero currency means the base currency. if ($report === 'income') $totals['acme_target'] = Acme::monthlyTarget($currency_id); }); ``` ### Pitfalls > **Dropping a date-range key empties the report** > > Every key in the range array is read by the queries that follow. Removing one or renaming it breaks the figure and chart queries; what appears on screen is not an error but an **empty report**, which makes it hard to spot. > **Labels and series must be the same length** > > If the label count and the value count part ways in a time series the chart axes slip. Build a new series to the length of the existing labels, and keep the series keys already there. ### Related Articles - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Panel Screen Data Hooks https://dev.wisecp.com/en/admin-screen-data-hooks The thirteen hooks over what the customer, service and tool screens show. ### Overview The hooks that change what panel screens show live here: the customer detail and list, the service management cards, add-on buttons, critical notifications and the theme version lookup. Two differ from the rest: the profile tabs and the security accordion hand you a **component object** rather than an array, and you add through a method call. ### Reference #### Changing the customer extra details filteradmin.client_detail.info `admin/users` passed by link Runs once the extra detail fields on a customer page are read. Parameters 2 $client_infoarrayby linkThe extra detail pairs. You may overwrite an existing key or add a new one. $user_idintThe customer id. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.client_detail.info', 10, function (&$client_info, $user_id) { $client_info['acme_tier'] = Acme::tierOf($user_id); }); ``` #### Adding a sub-tab to a customer profile filteradmin.client_detail.profile_subtabs `admin/users` a component object Runs while the vertical tabs of a customer profile are built. Add a tab of your own here. Parameters 3 $profileTabobjectby linkThe tab component. It is an **object**, not an array: you add a tab by calling its add method, not by appending to a list. $userarrayThe active customer record. $user_idintThe customer id. Return 1 voidThe return is ignored; the change happens through the object’s method. Listener PHP ```php Hook::add('filter:admin.client_detail.profile_subtabs', 10, function (&$profileTab, $user, $user_id) { // An object, not an array: call its method. $profileTab->add('acme', 'Acme', Acme::renderPanel($user_id)); }); ``` #### Adding a section to the security area filteradmin.client_detail.security_accordion `admin/users` a component object Runs while the security settings accordion of a customer is built. Parameters 3 $secAccordionobjectby linkThe accordion component; add a section through its add method. $client_infoarrayThe extra details of the customer. $user_idintThe customer id. Return 1 voidThe return is ignored; the change happens through the object. Listener PHP ```php Hook::add('filter:admin.client_detail.security_accordion', 10, function (&$secAccordion, $client_info, $user_id) { $secAccordion->add('acme-sessions', 'Acme sessions', Acme::sessions($user_id)); }); ``` #### Changing the customer statistics filteradmin.client_detail.statistics `admin/users` passed by link Runs once the counters on a customer page are worked out. Parameters 2 $user_statsarrayby linkThe statistics: active and inactive services, invoices and tickets. $user_idintThe customer id. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.client_detail.statistics', 10, function (&$user_stats, $user_id) { $user_stats['acme_open_cases'] = Acme::openCases($user_id); }); ``` #### Changing the customer list filters filteradmin.client_list.filters `admin/users` the model must support it Runs once the filter criteria of the customer list are built. Parameters 1 $dynamic_filterarrayby linkThe filter criteria: group, status, language, country, address and date. ? A key you add **must also be handled in the listing query**; otherwise the filter appears but narrows nothing. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.client_list.filters', 10, function (&$dynamic_filter) { // A new key must be handled in the listing query too. $dynamic_filter['acme_tier'] = Acme::tierFilterValue(); }); ``` #### Changing the service management cards filteradmin.service_detail.management_cards `admin/services` two lists together Runs once the management cards on a service detail are prepared. Add a card of your own, hide one, or change the order. Parameters 1 $contextarrayby linkTwo lists together: `cards` holds the card content and `layout` says which card sits in which column and in what order. ? **Update both**: a card missing from the layout never appears, and a layout key with no card is quietly skipped. Return 1 voidThe return is ignored; you write over the context. Listener PHP ```php Hook::add('filter:admin.service_detail.management_cards', 10, function (&$context) { // Update BOTH: the card and the layout. $context['cards']['acme'] = Acme::renderCard($context['service'] ?? []); $context['layout']['right'][] = 'acme'; }); ``` #### Changing the add-on settings buttons filteradmin.addon_configure.buttons `admin/tools` emptying removes them all Runs once the buttons on an add-on settings page are prepared. Parameters 1 $buttonsarrayby linkThe button map; each carries its text, class, icon and click action. Empty the array entirely and **no button appears**. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.addon_configure.buttons', 10, function (&$buttons) { // Emptying it entirely leaves no buttons at all. $buttons['acme-sync'] = [ 'text' => 'Sync Acme', 'class' => 'btn btn-outline-primary', 'icon' => 'bi bi-arrow-repeat', 'onclick' => 'acmeSync()', ]; }); ``` #### Changing the address of an uploaded image filteradmin.editor_upload_url `admin/tools` passed by link Runs after the address of an image uploaded into the editor is produced. This is how you move images onto a delivery network. Parameters 2 $urlstringby linkThe address produced. Leave it alone and the local one stays. $file_pathstringThe full path of the file on the server. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.editor_upload_url', 10, function (&$url, $file_path) { $url = Acme::pushToCdn($file_path) ?: $url; }); ``` #### Following an image upload actionadmin.editor_image_uploaded `admin/tools` address after the filter Runs after an image is uploaded into the editor. Parameters 2 $file_pathstringThe full path of the file on the server. $urlstringThe final address, **as it stands after the address filter** ran. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.editor_image_uploaded', 10, function ($file_path, $url) { // The address is the one AFTER the filter ran. Acme::noteAsset($file_path, $url); }); ``` #### Adding to the critical notification list filteradmin.notifications.critical `admin/inc` fixed item shape Runs once the critical notification list in the panel header is prepared. Parameters 1 $critical_transaction_notificationsarrayby linkThe notification items. Each must carry the fields the template expects: id, icon, type, message, date, read state and buttons. An item missing one breaks the display. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:admin.notifications.critical', 10, function (&$critical_transaction_notifications) { // Fill every field the template expects. foreach (Acme::criticalAlerts() as $a) $critical_transaction_notifications[] = $a; }); ``` #### Following a panel notification actionadmin.notified `Notification` zero means not written Runs when a system notification lands in the panel. Parameters 4 $namestringThe event name. $dataarrayThe event data: message placeholders and what is used to spot repeats. $levelstringThe level: error, warning, information or success. $event_idintThe id of the record created. **A zero means nothing was written**: either the same notice already existed or the write failed. Test before using the id. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:admin.notified', 10, function ($name, $data, $level, $event_id) { // Zero means nothing was written. if ($level === 'error' && $event_id) Acme::page($name, $data); }); ``` #### Taking over the theme version lookup filteradmin.theme_version_check `admin/settings` first filled wins Runs before a theme is asked about a new version. Return an answer and **no remote call is made**. Parameters 2 $keystringThe theme folder key. $manifestarrayThe theme manifest: its update address and installed version. Return 1 string|null**A filled text is taken as the raw response** and the call is skipped. The **first** filled return by priority wins and the rest never run. With an empty return the core makes its usual request. Listener PHP ```php Hook::add('filter:admin.theme_version_check', 10, function ($key, $manifest) { // A filled return means NO remote call is made. if (!str_starts_with($key, 'Acme')) return null; return Acme::versionJson($key); }); ``` #### Adjusting the theme version response filteradmin.theme_version_response `admin/settings` the raw response Runs after the version response arrives and before it is decoded. Its sibling picks the source; this hook **adjusts what came back**. Parameters 2 $keystringby linkThe theme key. It is passed by link but already **consumed**: changing it has no effect. $manifestarrayby linkThe theme manifest, also consumed. Return 1 voidThe return is ignored; you write over the raw response. The core then decodes it; with no version field the result counts as empty. Listener PHP ```php Hook::add('filter:admin.theme_version_response', 10, function (&$key, &$manifest) { // Both parameters are consumed: changing them has no effect. Acme::noteVersionCheck($key); }); ``` ### Pitfalls > **Adding a card takes two lists** > > In the service management cards the content sits in one list and the **layout in another**. Add only the content and the card never appears; write only a name into the layout and that name is quietly skipped. Update both. > **The tabs and the accordion are objects, not arrays** > > The profile sub-tabs and the security accordion hand you a **component object**. A listener treating it like an array fails; you add through the object’s own method. ### Related Articles - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Panel Customer Screen Hooks https://dev.wisecp.com/en/admin-client-screen-hooks The nineteen placement points of the customer screens: summary cards, tabs, the list toolbar and the creation form. ### Overview The placement points of the customer screens live here. They all work the same way. The HTML you return appears at that point. Return `null` when you have nothing to add. Two points fall outside that pattern. **Adding a tab** receives an object rather than HTML, and the addition happens through a method call. The **document field form** is collected once as the page opens and then fixed. It cannot vary with the field being edited. ### Reference #### The top of the summary tab uiadmin.client_detail.summary.top `admin/users/detail` above the cards Appears at the very top of the customer summary, above the statistic cards. The right place for a risk banner, a last-contact note or a key-account warning. Parameters 3 $userarrayThe full customer record. $user_idintThe customer id. $user_statsarrayThe counters worked out: active and inactive services, invoices, tickets. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.summary.top', 10, function ($user, $user_id, $user_stats) { $score = Acme::riskScore($user_id); if ($score < 70) return null; // eklenecek bir sey yoksa null return '
    Risk skoru: ' . (int) $score . '
    '; }); ``` #### Adding a row to the information card uiadmin.client_detail.summary.info_card `admin/users/detail` a row inside the card Appears after the last row of the information card. The place for a field such as an outside customer number, the referral source or an account manager. Parameters 3 $userarrayThe full customer record. $client_infoarrayThe extra detail pairs of the customer. $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.summary.info_card', 10, function ($user, $client_info, $user_id) { $crm = Acme::crmId($user_id); if ($crm === '') return null; // Cikti kacissiz basilir: dis degeri kendiniz temizleyin. return '
    CRM
    ' . htmlspecialchars($crm) . '
    '; }); ``` #### Adding an operation to the actions card uiadmin.client_detail.summary.action_cards `admin/users/detail` an operation box Appears after the last operation in the actions card. It puts an operation of your own beside the built-in ones. Parameters 2 $userarrayThe full customer record. $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.summary.action_cards', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the summary tab uiadmin.client_detail.summary.bottom `admin/users/detail` below the cards Appears once the cards of the summary tab end. The place for a card or an information panel of your own. Parameters 2 $userarrayThe full customer record. $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.summary.bottom', 10, function () { return '
    Acme
    '; }); ``` #### A badge or button in the title area uiadmin.client_detail.header_actions `admin/users/detail` beside the badges Appears at the end of the status badges in the page title. Put a badge of your own or a shortcut button here. Parameters 3 $userarrayThe full customer record. $user_idintThe customer id. $client_infoarrayThe resolved customer details: kind, tax status and protection flags. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.header_actions', 10, function () { return '
    Acme
    '; }); ``` #### A new tab on the customer detail uiadmin.client_detail.tabs `admin/users/detail` an object, not an array Runs once every core tab is built. Unlike the other placement points you **do not return HTML** here: you add the tab through the object’s method. Parameters 3 $tabobjectThe tab object; add a new tab through its method. $userarrayThe full customer record. $user_idintThe customer id. Return 1 voidThe return is **not used**. This point shows no HTML: the change happens by calling the object’s method. A listener returning HTML quietly does nothing here. Listener PHP ```php Hook::add('ui:admin.client_detail.tabs', 10, function ($tab, $user, $user_id) { // HTML DONDURMEYIN: sekme nesneye eklenir. $tab->add('acme', 'Acme', Acme::renderTab($user_id)); }); ``` #### The bottom of the profile tab uiadmin.client_detail.profile.bottom `admin/users/detail` below the profile Appears once the vertical tabs of the profile are on screen. It suits a verification summary or an extra preference section. Parameters 3 $userarrayThe full customer record. $client_infoarrayThe extra details of the customer. $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.profile.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the contacts tab uiadmin.client_detail.contacts.top `admin/users/detail` top of the tab Appears above the address and contact list. It suits an address verification state or a warning about a missing billing address. Parameters 3 $userarrayThe full customer record. $user_idintThe customer id. $user_addressesarrayThe address and contact records of the customer. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.contacts.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the sub-users tab uiadmin.client_detail.subusers.top `admin/users/detail` top of the tab Appears above the sub-user list. It suits a warning about unaccepted invitations or a note on permission policy. Parameters 2 $userarrayThe full customer record. $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.subusers.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the invoices tab uiadmin.client_detail.invoices.top `admin/users/detail` top of the tab Appears above the invoice table. It suits an outstanding balance warning or the state of automatic payment. Parameters 1 $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.invoices.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the services tab uiadmin.client_detail.orders.top `admin/users/detail` top of the tab Appears above the service and order list. It suits a renewal warning or a provisioning failure banner. Parameters 1 $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.orders.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the support tab uiadmin.client_detail.tickets.top `admin/users/detail` top of the tab Appears above the ticket table. It suits a count of open tickets or a satisfaction summary. Parameters 1 $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.tickets.top', 10, function () { return '
    Acme
    '; }); ``` #### The top of the notes tab uiadmin.client_detail.notes.top `admin/users/detail` top of the tab Appears above the note list. The place to show system notes coming from an outside source. Parameters 2 $user_idintThe customer id. $notesarrayThe notes of the customer. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.notes.top', 10, function () { return '
    Acme
    '; }); ``` #### The very bottom of the customer page uiadmin.client_detail.bottom `admin/users/detail` end of the page Appears at the very end of the page, immediately before the footer. It suits a panel that belongs to no tab. Parameters 2 $userarrayThe full customer record. $user_idintThe customer id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_detail.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The customer list toolbar uiadmin.client_list.toolbar `admin/users` no parameters Appears in the button group above the list. Add a button for an export or a bulk action of your own. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_list.toolbar', 10, function () { return '' . ' Acme'; }); ``` #### Above the table on the customer list uiadmin.client_list.before_table `admin/users` above the table Appears after the statistic card and the filter panel, immediately above the table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### Above the customer creation form uiadmin.client_create.before_content `admin/users` above the form Appears above the new customer form. It suits an information banner or an extra instruction. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_create.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the customer creation form uiadmin.client_create.bottom `admin/users` below the form Appears once the new customer form ends, at the bottom of the page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.client_create.bottom', 10, function () { return '
    Acme
    '; }); ``` #### An extra section on the document field form uiadmin.document_field.form `admin/users` collected once Puts an extra section into the add and edit window of the document verification fields. If you introduced a field type of your own, ask for its settings here. Parameters 0 —It takes no parameters. Return 1 string|nullThe HTML you return appears. ? Because the window is built in the browser, the output is collected **once as the page opens** and embedded as a constant: it **cannot vary** with the field being edited. If you need per-field behaviour, show all of it and choose in your own code. Listener PHP ```php Hook::add('ui:admin.document_field.form', 10, function () { // The output is collected once: it cannot vary per field. return '
    ' . '
    '; }); ``` ### Pitfalls > **The output enters the page unescaped** > > Whatever you return at these points enters the page as it stands. Embedding a value from outside (a customer name, a response from another system, form input) directly creates a **code execution hole in the panel**. Escape the value yourself. > **The tab point takes no HTML** > > The tab point looks like the others but **shows no HTML**: it hands you an object and the tab is added through its method. A listener returning HTML quietly does nothing here, and raises no error either, which makes it hard to spot. ### Related Articles - [Panel Screen Data Hooks](https://dev.wisecp.com/en/admin-screen-data-hooks) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) ## Panel Service Screen Hooks https://dev.wisecp.com/en/admin-service-screen-hooks The eighteen placement points of the service screens: the detail tabs, the title area and the toolbars of the side lists. ### Overview The placement points of the service screens live here. They cover every tab of the detail page, the title area and the toolbars of the side lists. Most detail points hand you the service record. Some add something that **can arrive empty**: the product record when the product was gone, or the module instance when the service has no server. Test before reaching into those. ### Reference #### A badge or button in the service title uiadmin.service_detail.header_actions `admin/services/detail` the title area Appears in the title row of the service detail. Put the state on the remote panel or a shortcut of your own here. Parameters 1 $servicearrayThe active service record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.header_actions', 10, function () { return '
    Acme
    '; }); ``` #### A new tab on the service detail uiadmin.service_detail.tabs `admin/services/detail` an object, not an array Runs once the core tabs are built. You do not return HTML: you add the tab through the object’s method. Two flags tell you which tab makes sense. Parameters 4 $tabobjectThe tab object. $servicearrayThe full service record. $is_domainboolWhether the service is a domain. Server tabs make no sense on a domain; decide from this. $has_moduleboolWhether the service sits on a server. A false here leaves a tab pulling remote data empty. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.service_detail.tabs', 10, function ($tab, $service, $is_domain, $has_module) { // Sunucusu olmayan hizmette uzak panel sekmesi bos cikar. if (!$has_module) return; $tab->add('acme', 'Acme', Acme::renderTab((int) ($service['id'] ?? 0))); }); ``` #### The bottom of the details tab uiadmin.service_detail.details.bottom `admin/services/detail` the product may be empty Appears below the service details. Parameters 2 $servicearrayThe active service record. $productarrayThe product behind the service. It **arrives empty** when the product was deleted or the service is not tied to one. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.details.bottom', 10, function ($service, $product) { // Urun BOS gelebilir. if (!$product) return null; return '
    ' . htmlspecialchars(Acme::planNote($product)) . '
    '; }); ``` #### The bottom of the management tab uiadmin.service_detail.management.bottom `admin/services/detail` the module may be empty Appears below the server management tools. Parameters 2 $servicearrayThe active service record. $moduleobjectThe loaded server module. ? It **can arrive empty** when the service has no server or the module failed to load. Test before calling a method. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.management.bottom', 10, function ($service, $module) { // Modul BOS gelebilir: metot cagirmadan once sinayin. if (!$module) return null; return Acme::renderRemotePanel($module, $service); }); ``` #### The top of the add-ons tab uiadmin.service_detail.addons.top `admin/services/detail` top of the tab Appears above the list of service add-ons. Parameters 1 $servicearrayThe active service record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.addons.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the requirements tab uiadmin.service_detail.requirements.bottom `admin/services/detail` the filled answers Appears below the details asked of the customer during the order. Parameters 2 $servicearrayThe active service record. $requirementsarrayThe requirements filled in for this service. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.requirements.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the history tab uiadmin.service_detail.history.bottom `admin/services/detail` below the history Appears below the service history. The place to put your own record beside the core one. Parameters 1 $servicearrayThe active service record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.history.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the package change tab uiadmin.service_detail.upgrade.top `admin/services/detail` the remainder in hand Appears above the package upgrade screen. The remaining days and amount are in your hands, so you can show your own pricing note here. Parameters 3 $servicearrayThe active service record. $productarrayThe current product record. $remainingarrayThe remainder: days used, days left and the amount left. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.upgrade.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the transfer tab uiadmin.service_detail.transfer.bottom `admin/services/detail` carries a secret Appears below the domain transfer screen. Parameters 2 $servicearrayThe active domain record. $service_optionsarrayThe service options: the transfer lock and the **authorisation code**. ? That code is a secret: keep it off the screen and out of your records. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.transfer.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the verification tab uiadmin.service_detail.verification.bottom `admin/services/detail` domain verification Appears below the domain contact verification screen. Parameters 2 $servicearrayThe active domain record. $service_idintThe service id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.verification.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The very bottom of the service page uiadmin.service_detail.bottom `admin/services/detail` end of the page Appears at the very end of the page. It suits a panel that belongs to no tab. Parameters 1 $servicearrayThe service record on screen. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_detail.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The service list toolbar uiadmin.service_list.toolbar `admin/services` no parameters Appears in the button group above the service list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the service list uiadmin.service_list.before_table `admin/services` no parameters Appears immediately above the service table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The add-on list toolbar uiadmin.service_addon_list.toolbar `admin/services` no parameters Appears in the toolbar of the service add-on list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_addon_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the add-on list uiadmin.service_addon_list.before_table `admin/services` no parameters Appears immediately above the add-on table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_addon_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The cancellation request toolbar uiadmin.service_cancellation_list.toolbar `admin/services` no parameters Appears in the toolbar of the cancellation request list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_cancellation_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The transfer list toolbar uiadmin.service_transfer_list.toolbar `admin/services` no parameters Appears in the toolbar of the service transfer list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_transfer_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The package change list toolbar uiadmin.service_updowngrade_list.toolbar `admin/services` no parameters Appears in the toolbar of the package upgrade and downgrade list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.service_updowngrade_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **The module instance can be empty** > > The module parameter on the management tab is **empty** when the service has no server or the module failed to load. A listener calling a method straight away raises a fatal error there and takes **the whole page** down: it is not your panel that breaks but the administrator’s service screen. > **The transfer point carries the authorisation code** > > The options array on the transfer tab holds the **authorisation code** of the domain. Whoever sees that code can move the domain to another registrar: keep it off the screen, out of your logs and away from outside services. ### Related Articles - [Panel Screen Data Hooks](https://dev.wisecp.com/en/admin-screen-data-hooks) - [Service Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) ## Panel Support Screen Hooks https://dev.wisecp.com/en/admin-support-screen-hooks The twenty-one placement points of the support screens: the ticket detail, the reply form, the list rows and the department forms. ### Overview The placement points of the support screens live here: every part of the ticket detail, the list rows and the department forms. A few points on the ticket detail hand you a **privilege flag**. It is not a gate: the core disables its own buttons with it, but it does not stop your output. If you show something that needs the privilege, do the check yourself. ### Reference #### A badge or button in the ticket title uiadmin.tickets_detail.header_actions `admin/tickets/detail` the title area Appears in the title row of the ticket detail. It suits a case number in another system or a shortcut of your own. Parameters 1 $ticketarrayThe active ticket record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_detail.header_actions', 10, function () { return '
    Acme
    '; }); ``` #### The top of the side column uiadmin.ticket_detail.sidebar_top `admin/tickets/detail` top of the side column Appears in the side column of the ticket detail, above the first card. Parameters 2 $ticketarrayThe active ticket record. $opPrivboolWhether the viewer holds the operation privilege. ? This is **information**, not a gate: when false the core disables its own buttons but does not stop your output. If you show something that needs the privilege, **check it yourself**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.sidebar_top', 10, function () { return '
    Acme
    '; }); ``` #### Adding an operation to the side column uiadmin.ticket_detail.sidebar_actions `admin/tickets/detail` an operation box Appears among the operation boxes in the side column. It puts a ticket operation of your own beside the built-in ones. Parameters 2 $ticketarrayThe active ticket record. $opPrivboolWhether the viewer holds the operation privilege. ? This is **information**, not a gate: when false the core disables its own buttons but does not stop your output. If you show something that needs the privilege, **check it yourself**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.sidebar_actions', 10, function ($ticket, $opPriv) { // Yetki bilgisi kapi DEGIL: kendiniz sinayin. if (!$opPriv) return null; return '
    '; }); ``` #### A row on the ticket details card uiadmin.ticket_detail.details_card.meta `admin/tickets/detail` the details card Appears among the fields of the ticket details card. Add metadata of your own beside the core fields. Parameters 1 $ticketarrayThe active ticket, with its mail source, department and reference in it. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.details_card.meta', 10, function () { return '
    Acme
    '; }); ``` #### Above the conversation uiadmin.ticket_detail.conversation.before `admin/tickets/detail` above the conversation Appears immediately above the reply thread. It suits context or a warning from another system. Parameters 1 $ticketarrayThe active ticket record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.conversation.before', 10, function () { return '
    Acme
    '; }); ``` #### The top of the reply form uiadmin.ticket_detail.reply_form.top `admin/tickets/detail` top of the reply form Appears above the box staff write their reply in. It suits a warning that should be seen before writing. Parameters 2 $ticketarrayThe active ticket record. $opPrivboolWhether the viewer holds the operation privilege. ? This is **information**, not a gate: when false the core disables its own buttons but does not stop your output. If you show something that needs the privilege, **check it yourself**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.reply_form.top', 10, function () { return '
    Acme
    '; }); ``` #### The reply form toolbar uiadmin.ticket_detail.reply_form.toolbar `admin/tickets/detail` the form toolbar Appears in the toolbar of the reply form. Put your own insert or template button here. Parameters 1 $ticketarrayThe active ticket record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.reply_form.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Below the reply editor uiadmin.ticket_detail.reply_editor.after `admin/tickets/detail` below the editor Appears immediately below the text editor. It suits an extra option or a confirmation box before sending. Parameters 1 $ticketarrayThe active ticket record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.reply_editor.after', 10, function () { return '
    Acme
    '; }); ``` #### Below the canned replies uiadmin.ticket_detail.canned_replies.after `admin/tickets/detail` the language is separate Appears below the canned reply options. The place to add a template source of your own. Parameters 2 $ticketarrayThe active ticket record. $client_langstringThe active interface language. Canned replies load in the customer’s language, and since the template does not hold that value the interface language is passed instead. The two **may differ**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.canned_replies.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the internal note form uiadmin.ticket_detail.note_form.bottom `admin/tickets/detail` the internal note form Appears below the internal note box. Notes are never shown to the customer. Parameters 1 $ticketarrayThe active ticket record. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.ticket_detail.note_form.bottom', 10, function () { return '
    Acme
    '; }); ``` #### A new tab on the ticket detail uiadmin.ticket_detail.tabs `admin/tickets/detail` an object, not an array Runs once the tabs of the ticket detail are built. You do not return HTML: you add the tab through the object’s method. Parameters 2 $tabobjectThe tab object. Being an object, it changes for good without a by-link mark. $ticketarrayThe full ticket record. Return 1 voidThe return is **not used**. This point shows no HTML. Listener PHP ```php Hook::add('ui:admin.ticket_detail.tabs', 10, function ($tab, $ticket) { $tab->add('acme', 'Acme', Acme::renderTab((int) ($ticket['id'] ?? 0))); }); ``` #### The very bottom of the ticket page uiadmin.tickets_detail.bottom `admin/tickets/detail` end of the page Appears at the very end of the page. Parameters 1 $ticketarrayThe ticket record on screen. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_detail.bottom', 10, function () { return '
    Acme
    '; }); ``` #### A badge on a list row uiadmin.tickets_list.row_badges `admin/tickets` per row Appears on **every row** of the ticket list. It puts a status badge of your own beside the core ones. Parameters 1 $modelarrayThe row data: id, status, read mark, department and title. ? The hook runs per row, so a listener querying the database here produces **hundreds of queries per page**. Collect what you need beforehand. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_list.row_badges', 10, function ($model) { // SATIR BASINA calisir: burada sorgu yapmayin, onceden toplayin. if (!Acme::isEscalated((int) ($model['id'] ?? 0))) return null; return 'Acme'; }); ``` #### The ticket list toolbar uiadmin.tickets_list.toolbar `admin/tickets` no parameters Appears in the button group above the ticket list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the ticket list uiadmin.tickets_list.before_table `admin/tickets` no parameters Appears immediately above the ticket table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The department list toolbar uiadmin.department_list.toolbar `admin/tickets/departments` no parameters Appears in the toolbar of the department list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.department_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the department list uiadmin.department_list.before_table `admin/tickets/departments` no parameters Appears immediately above the department table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.department_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### Above the ticket creation form uiadmin.tickets_edit.before_content `admin/tickets` the customer may be empty Appears above the form staff use to open a ticket for a customer. Parameters 1 $userarrayThe customer pre-selected for the form. It **arrives empty** when no customer is chosen yet. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the ticket creation form uiadmin.tickets_edit.bottom `admin/tickets` the customer may be empty Appears below the same form, at the bottom of the page. Parameters 1 $userarrayThe customer pre-selected for the form. It **arrives empty** when no customer is chosen yet. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tickets_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the department form uiadmin.department_edit.before_content `admin/tickets/departments` empty when creating Appears above the department add and edit form. Parameters 1 $detailarrayThe department being edited. It is **empty when creating**: test before reaching for an id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.department_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the department form uiadmin.department_edit.bottom `admin/tickets/departments` empty when creating Appears below the same form. Parameters 1 $detailarrayThe department being edited. It is **empty when creating**: test before reaching for an id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.department_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **The privilege flag is not a gate** > > The privilege parameter on the ticket detail **tells you whether the viewer holds it**; it does not block your output on its own. If you would rather not show an operation button to somebody without the privilege, put the condition in **your own listener**. The real check stays in the operation on the server: hiding here is only visibility. > **The row badge runs on every row** > > The list badge point is called **separately for every ticket** on the page. A listener doing a database query or an outside call inside it produces fifty queries on a fifty-row page and slows the list noticeably. Collect what you need once and read it from memory. ### Related Articles - Support Ticket Hooks - [Ticket View Hooks](https://dev.wisecp.com/en/ticket-view-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) ## Panel Product Screen Hooks https://dev.wisecp.com/en/admin-product-screen-hooks The twenty-two placement points of the product screens: the edit tabs, the list toolbars and the group, add-on and server forms. ### Overview The placement points of the product screens live here: every tab of the product edit page, the list toolbars and the group, add-on, requirement and server forms. On most form points the record you hold is **empty on a new record**. The list points hand you the product type: the same list serves every type from hosting to certificates. ### Reference #### Above the product edit form uiadmin.product_edit.before_content `admin/products/edit` above the form Appears at the very top of the product edit page, above the tabs. Parameters 1 $productarrayThe product being edited. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### A new tab on the product edit page uiadmin.product_edit.tabs `admin/products/edit` an object, not an array Runs once the product tabs are built. You do not return HTML: you add the tab through the object’s method. Parameters 2 $tabobjectThe tab object. $productarrayThe product being edited. Return 1 voidThe return is **not used**. This point shows no HTML. Listener PHP ```php Hook::add('ui:admin.product_edit.tabs', 10, function ($tab, $product) { $tab->add('acme', ['title' => 'Acme', 'content' => Acme::renderTab($product)]); }); ``` #### The top of the details tab uiadmin.product_edit.details.top `admin/products/edit` top of the tab Appears above the basic detail fields of the product. Parameters 1 $detailarrayThe active product record: type, options, language data and module data. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.details.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the details tab uiadmin.product_edit.details.bottom `admin/products/edit` bottom of the tab Appears below the basic detail fields. The place to put a product setting of your own inside the form. Parameters 1 $detailarrayThe active product record: type, options, language data and module data. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.details.bottom', 10, function ($detail) { // Alan formun icinde: kaydetmeyi filter:product.save_data ile yakalayin. $v = htmlspecialchars((string) ($detail['options']['acme_sku'] ?? '')); return '
    ' . '
    '; }); ``` #### The bottom of the pricing tab uiadmin.product_edit.pricing.bottom `admin/products/edit` the pricing tab Appears below the price table. The place for a pricing rule or note of your own. Parameters 1 $detailarrayThe active product record: type, options, language data and module data. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.pricing.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the add-ons tab uiadmin.product_edit.addons.bottom `admin/products/edit` the add-on list is text Appears below the add-on selection of the product. Parameters 1 $detailarrayThe active product record. The linked add-ons field is **comma-separated text**, not an array: split it to work with it. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.addons.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the automation tab uiadmin.product_edit.automation.bottom `admin/products/edit` module settings Appears below the server and module settings. Parameters 1 $detailarrayThe active product record: type, options, language data and module data. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.automation.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the other tab uiadmin.product_edit.other.bottom `admin/products/edit` the other tab Appears below the remaining product settings. Parameters 1 $detailarrayThe active product record: type, options, language data and module data. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.other.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The very bottom of the product edit page uiadmin.product_edit.bottom `admin/products/edit` end of the page Appears at the very end of the page, outside the tabs. Parameters 1 $productarrayThe product being edited. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The product list toolbar uiadmin.product_list.toolbar `admin/products` the type arrives Appears in the button group above the product list. Parameters 1 $typestringThe product type being listed: hosting, server, software, text message, certificate or special. The same list serves every type: check this if what you add should not appear on all of them. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_list.toolbar', 10, function ($type) { // Ayni liste her tip icin kullanilir. if ($type !== 'hosting') return null; return 'Acme'; }); ``` #### Above the table on the product list uiadmin.product_list.before_table `admin/products` the type arrives Appears immediately above the product table. Parameters 1 $typestringThe product type being listed: hosting, server, software, text message, certificate or special. The same list serves every type: check this if what you add should not appear on all of them. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_list.before_table', 10, function ($type) { // Ayni liste her tip icin kullanilir. if ($type !== 'hosting') return null; return 'Acme'; }); ``` #### Above the product creation form uiadmin.product_add.before_content `admin/products` no parameters Appears above the new product form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_add.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the product creation form uiadmin.product_add.bottom `admin/products` no parameters Appears below the new product form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_add.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The add-on list toolbar uiadmin.product_addon_list.toolbar `admin/products` no parameters Appears in the toolbar of the product add-on list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_addon_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The group list toolbar uiadmin.product_group_list.toolbar `admin/products` no parameters Appears in the toolbar of the product group list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_group_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The server list toolbar uiadmin.product_server_list.toolbar `admin/products/servers` no parameters Appears in the toolbar of the server list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_server_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the add-on form uiadmin.product_addon_edit.bottom `admin/products` empty when creating Appears below the product add-on add and edit form. Parameters 1 $detailarrayThe add-on being edited. It is **empty on a new record**: test before reaching for an id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_addon_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the requirement form uiadmin.product_requirement_edit.bottom `admin/products` empty when creating Appears below the product requirement form. Parameters 1 $detailarrayThe requirement being edited. It is **empty on a new record**: test before reaching for an id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_requirement_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the server form uiadmin.product_server_edit.bottom `admin/products/servers` empty when creating Appears below the server add and edit form. Parameters 1 $detailarrayThe server being edited. It is **empty on a new record**: test before reaching for an id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_server_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the server group form uiadmin.product_server_group_edit.bottom `admin/products/servers` empty when creating Appears below the server group form. Parameters 1 $detailarrayThe server group being edited. It is **empty on a new record**: test before reaching for an id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_server_group_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the group form uiadmin.product_group_edit.before_content `admin/products` group or category Appears above the product group and category form. Parameters 2 $detailarrayThe group or category being edited. It is **empty on a new record**: test before reaching for an id. $categorystringThe category context. **Empty means a top group**, filled means a category kind: the same form does two jobs. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_group_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the group form uiadmin.product_group_edit.bottom `admin/products` group or category Appears below the same form. Parameters 2 $detailarrayThe group or category being edited. It is **empty on a new record**: test before reaching for an id. $categorystringThe category context. **Empty means a top group**, filled means a category kind: the same form does two jobs. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.product_group_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **Adding a form field does not save it** > > A field you put at these points is **only visible**. For the value to be kept you must catch it in the save filter; otherwise an administrator fills it in, saves, and the value quietly disappears. > **The group form does two jobs** > > On the group edit points an **empty category context means a top group** while a filled one means a category kind is being edited. A listener that does not tell them apart shows a category-only field on the group form. ### Related Articles - [Product Data and Catalogue Hooks](https://dev.wisecp.com/en/product-data-hooks) - [Server Record Hooks](https://dev.wisecp.com/en/server-record-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) ## Panel Invoice Screen Hooks https://dev.wisecp.com/en/admin-invoice-screen-hooks The twenty-three placement points of the invoice, cash book and coupon screens: the invoice detail, summary cards, list toolbars and forms. ### Overview The placement points of the invoice, cash book and coupon screens live here: the sections of the invoice detail, the list toolbars and the coupon and currency forms. The invoice summary point hands you the arithmetic already done: the **balance left** and the **total paid**. In the cash summary each value arrives in two forms; use the raw one when you calculate. ### Reference #### A badge or button in the invoice title uiadmin.invoices_detail.header_actions `admin/invoices/detail` the title area Appears in the title row of the invoice detail. It suits its counterpart in your accounting system or a shortcut of your own. Parameters 2 $invoicearrayThe active invoice record. $invoice_idintThe invoice id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_detail.header_actions', 10, function () { return '
    Acme
    '; }); ``` #### The top of the invoice summary card uiadmin.invoice_detail.summary_card_top `admin/invoices/detail` the balance in hand Appears at the very top of the summary card. The balance left and the total paid are in your hands. Parameters 4 $invoicearrayThe active invoice record. $invoice_idintThe invoice id. $balancefloatThe balance left. Above zero means the invoice is still unpaid; it is filled on a partial payment too. $total_paidfloatThe total paid so far. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoice_detail.summary_card_top', 10, function ($invoice, $invoice_id, $balance, $total_paid) { // Kismi odemede de bakiye dolu gelir. if ($balance <= 0 || $total_paid <= 0) return null; return '
    Kismi odeme
    '; }); ``` #### The bottom of the invoice summary uiadmin.invoices_detail.summary.bottom `admin/invoices/detail` below the summary Appears after the lines of the summary card. Parameters 2 $invoicearrayThe active invoice record. $invoice_idintThe invoice id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_detail.summary.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the payments section uiadmin.invoices_detail.payments.top `admin/invoices/detail` the payment list Appears above the list of payments recorded against the invoice. Parameters 3 $invoicearrayThe active invoice record. $invoice_idintThe invoice id. $paymentsarrayThe payment records of the invoice. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_detail.payments.top', 10, function () { return '
    Acme
    '; }); ``` #### A new tab on the invoice detail uiadmin.invoice_detail.tabs `admin/invoices/detail` an object, not an array Runs once the invoice tabs are built. You do not return HTML: you add the tab through the object’s method. Parameters 2 $tabobjectThe tab object. $invoicearrayThe full invoice record. Return 1 voidThe return is **not used**. This point shows no HTML. Listener PHP ```php Hook::add('ui:admin.invoice_detail.tabs', 10, function ($tab, $invoice) { $tab->add('acme', 'Acme', Acme::renderTab((int) ($invoice['id'] ?? 0))); }); ``` #### The very bottom of the invoice page uiadmin.invoices_detail.bottom `admin/invoices/detail` end of the page Appears at the very end of the page. Parameters 2 $invoicearrayThe active invoice record. $invoice_idintThe invoice id. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_detail.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Beside the invoice list summary cards uiadmin.invoices_list.stats `admin/invoices` data after the filter Appears beside the summary cards above the list. It puts a card of your own next to the core ones. Parameters 1 $initial_statsarrayThe summary data worked out: unpaid, paid and overdue. This is the data **after the summary filter ran**: a change you made there shows up here. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_list.stats', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the invoice list uiadmin.invoices_list.before_table `admin/invoices` no parameters Appears immediately above the invoice table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The invoice list toolbar uiadmin.invoice_list.toolbar `admin/invoices` no parameters Appears in the toolbar of the invoice list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoice_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The cash book toolbar uiadmin.invoices_cash.toolbar `admin/money` no parameters Appears in the toolbar of the income and expense book. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_cash.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the cash book uiadmin.invoices_cash.before_table `admin/money` no parameters Appears immediately above the cash book table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_cash.before_table', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the recurring expense list uiadmin.invoices_periodic_expenses.before_table `admin/money` no parameters Appears immediately above the recurring expense table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_periodic_expenses.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The recurring expense toolbar uiadmin.invoices_periodic_expenses.toolbar `admin/money` no parameters Appears in the toolbar of the recurring expense list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_periodic_expenses.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The coupon list toolbar uiadmin.coupon_list.toolbar `admin/money` no parameters Appears in the toolbar of the coupon list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.coupon_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the coupon list uiadmin.coupon_list.before_table `admin/money` no parameters Appears immediately above the coupon table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.coupon_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The currency list toolbar uiadmin.currency_list.toolbar `admin/money` no parameters Appears in the toolbar of the currency list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.currency_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the currency list uiadmin.currency_list.before_table `admin/money` no parameters Appears immediately above the currency table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.currency_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### Below the cash book summary uiadmin.invoices_cash.summary.after `admin/money` raw and formatted together Appears below the income and expense summary. Parameters 1 $summaryarrayThe cash summary: income, expense and balance. Each value arrives in **two forms**: a raw number for arithmetic and formatted text for the screen. Use the raw one when comparing. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_cash.summary.after', 10, function ($summary) { // Karsilastirmada HAM degeri kullanin, bicimli metni degil. if ((float) ($summary['balance'] ?? 0) >= 0) return null; return '
    Kasa eksi bakiyede
    '; }); ``` #### Above the invoice creation form uiadmin.invoices_edit.before_content `admin/invoices` the customer may be zero Appears above the manual invoice form. Parameters 1 $user_idintThe customer the invoice is for. It is **zero** when no customer is chosen yet. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the invoice creation form uiadmin.invoices_edit.bottom `admin/invoices` the customer may be zero Appears below the same form. Parameters 1 $user_idintThe customer the invoice is for. It is **zero** when no customer is chosen yet. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.invoices_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the coupon form uiadmin.coupon_edit.before_content `admin/money` two modes Appears above the coupon add and edit form. Parameters 2 $coupon_idintThe coupon id; **zero when creating**. $isEditboolWhether this is the edit mode. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.coupon_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the coupon form uiadmin.coupon_edit.bottom `admin/money` two modes Appears below the same form. Parameters 2 $coupon_idintThe coupon id; **zero when creating**. $isEditboolWhether this is the edit mode. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.coupon_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The coupon summary column uiadmin.coupon_edit.summary_sidebar `admin/money` the side column Appears in the summary column beside the coupon form. Parameters 1 $coupon_idintThe coupon id; zero when creating. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.coupon_edit.summary_sidebar', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **Do not calculate with the formatted value** > > In the cash summary every amount exists in two forms: a raw number and the **formatted text** meant for the screen. That text carries a thousands separator and a currency symbol; a listener converting it back to a number gets the wrong answer on every amount large enough to have a separator. > **A filled balance does not mean nothing was paid** > > The balance left in the invoice summary is **also filled on a partial payment**. A listener assuming "a balance means no payment" treats a half-paid invoice as unpaid. Look at the total paid as well. ### Related Articles - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [Invoice Amount Hooks](https://dev.wisecp.com/en/invoice-amount-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) ## Panel Shell and Order Hooks https://dev.wisecp.com/en/admin-shell-and-order-hooks The twenty placement points of the panel shell, the dashboard and the order screens: the head, the body, the top menus and the order detail. ### Overview Two different scales meet here. The **shell points** run on every page of the panel: the head, the body and the top menus. The **dashboard and order points** belong to a single screen. The difference is cost. A heavy file placed at a shell point slows the whole panel; a point tied to one screen concerns only that screen. ### Reference #### Adding a style to the panel uiadmin.head.css `admin/inc` on every page Appears in the head section of the panel, on every page. Link a stylesheet of your own here. Parameters 0 —It takes no parameters. Return 1 string|nullThe HTML you return enters the head section. ? It runs on **every page of the panel**: a heavy stylesheet here slows the whole panel down. If you need it on one screen, use a point on that screen instead. Listener PHP ```php Hook::add('ui:admin.head.css', 10, function () { // It runs on EVERY page: keep it light. return ''; }); ``` #### Adding a script to the panel uiadmin.head.js `admin/inc` on every page Appears in the panel footer, on every page. Despite its name the output sits at the **end of the page**, where scripts load. Parameters 0 —It takes no parameters. Return 1 string|nullThe HTML you return appears at the end of the page. ? It runs on every page of the panel. If your script throws, **the panel’s own scripts can stop too**: wrap your code in your own error handling. Listener PHP ```php Hook::add('ui:admin.head.js', 10, function () { // A failure here can stop the panel's own scripts too. return ''; }); ``` #### The start of the body uiadmin.body.begin `admin/inc` on every page Appears at the very start of the page body, on every page. It suits an announcement banner across the whole panel. Parameters 0 —It takes no parameters. Return 1 string|nullThe HTML you return appears at the start of the body. It runs on every page; use your own container so the layout holds. Listener PHP ```php Hook::add('ui:admin.body.begin', 10, function () { return '
    Acme
    '; }); ``` #### The end of the body uiadmin.body.end `admin/inc` on every page Appears at the very end of the page body. It is the right place for modals and hidden containers. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.body.end', 10, function () { return '
    Acme
    '; }); ``` #### An item in the create menu uiadmin.header.create_menu `admin/inc` the top menu Appears in the create menu on the top bar. Put a quick-create link of your own here. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.header.create_menu', 10, function () { return '
  • Acme record
  • '; }); ``` #### An item in the help menu uiadmin.header.help_menu `admin/inc` the top menu Appears in the help menu on the top bar. It suits a link to your own documentation or support. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.header.help_menu', 10, function () { return '
    Acme
    '; }); ``` #### The top of the dashboard uiadmin.dashboard.top `admin/index` top of the board Appears at the very top of the dashboard, above the statistic cards. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.dashboard.top', 10, function () { return '
    Acme
    '; }); ``` #### After the statistic cards uiadmin.dashboard.statistics.after `admin/index` after the cards Appears immediately after the statistic cards on the dashboard. The place to add a counter card of your own to the row. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.dashboard.statistics.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the dashboard uiadmin.dashboard.bottom `admin/index` end of the board Appears at the very end of the dashboard. The place to add a panel of your own. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.dashboard.bottom', 10, function () { return '
    Acme
    '; }); ``` #### A badge or button in the order title uiadmin.order_detail.header_actions `admin/orders/detail` the title area Appears in the title row of the order detail. Parameters 3 $orderarrayThe order record on screen. $order_idintThe order id. $order_statusstringThe order status: waiting, in process, active or cancelled. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_detail.header_actions', 10, function () { return '
    Acme
    '; }); ``` #### The top of the order detail uiadmin.order_detail.top `admin/orders/detail` above the content Appears above the order content. The place to show a warning that depends on the status. Parameters 3 $orderarrayThe order record on screen. $order_idintThe order id. $order_statusstringThe order status: waiting, in process, active or cancelled. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_detail.top', 10, function ($order, $order_id, $order_status) { if ($order_status !== 'waiting') return null; return '
    Acme onayi bekleniyor
    '; }); ``` #### Adding an operation to the order items uiadmin.order_detail.item_actions `admin/orders/detail` two flags arrive Appears in the operation area of the order items. Two flags beside you say which operation makes sense. Parameters 3 $orderarrayThe full order record. $has_pending_itemsboolWhether any service or add-on is still waiting. A false means nothing is left to provision. $has_moduleboolWhether the order holds a service on a module. A false leaves a button acting on a remote server with nothing to do. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_detail.item_actions', 10, function ($order, $has_pending_items, $has_module) { // Modulsuz siparişte uzak islem butonu bosa calisir. if (!$has_pending_items || !$has_module) return null; return ''; }); ``` #### The very bottom of the order page uiadmin.order_detail.bottom `admin/orders/detail` end of the page Appears at the very end of the page. Parameters 3 $orderarrayThe order record on screen. $order_idintThe order id. $order_statusstringThe order status: waiting, in process, active or cancelled. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_detail.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The order list toolbar uiadmin.order_list.toolbar `admin/orders` no parameters Appears in the toolbar of the order list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the order list uiadmin.order_list.before_table `admin/orders` no parameters Appears immediately above the order table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### Above the order creation form uiadmin.order_create.before_content `admin/orders` no parameters Appears above the manual order form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_create.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the order creation form uiadmin.order_create.bottom `admin/orders` no parameters Appears below the same form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.order_create.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The top of the domain pricing screen uiadmin.domain_pricing.top `admin/domains` no parameters Appears above the extension price table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.domain_pricing.top', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the domain pricing screen uiadmin.domain_pricing.bottom `admin/domains` no parameters Appears below the extension price table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.domain_pricing.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The document list toolbar uiadmin.domain_doc_list.toolbar `admin/domains` no parameters Appears in the toolbar of the extension document requirement list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.domain_doc_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **Shell points run on every page of the panel** > > The style and script points are not limited to one screen: whatever page an administrator opens, your output goes into it. A heavy file, or a script calling an outside service on every load, slows **the whole panel**. If what you add belongs to one screen, use that screen’s own point. > **A failure in your script can stop the panel’s scripts** > > Code you add at the end of the page runs beside the panel’s own. An uncaught error halts script execution in the browser: what goes unresponsive is not your panel but **the screen the administrator is using**. Wrap your code in your own error handling. ### Related Articles - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) - Order Hooks - [Panel Analytics Hooks](https://dev.wisecp.com/en/admin-analytics-hooks) ## Panel Content Screen Hooks https://dev.wisecp.com/en/admin-content-screen-hooks The twenty-two placement points of the content, message, notification and language screens: forms, lists and toolbars. ### Overview The placement points of the site content, contact messages, notification templates and languages live here. One thing runs through the content screens: **the same screen serves several types**. Pages, contracts, news, articles and references share one form; the message list serves five folders. Check the type if what you add should not appear on all of them. ### Reference #### After the page title uiadmin.content.page_title.after `admin/manage-website` on many pages Appears after the title on the content management pages. The controller name tells you which page you are on. Parameters 1 $controllerstringThe name of the active controller. The hook runs on several pages: without a check your output appears on all of them. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.content.page_title.after', 10, function ($controller) { // Kanca birden cok sayfada calisir. if ($controller !== 'pages') return null; return 'Acme'; }); ``` #### Above the content form uiadmin.content_edit.before_content `admin/manage-website` five content types Appears above the content edit form. Parameters 2 $page_typestringThe content type: page, contract, news, article or reference. The same screen serves five types: check this if what you add should not appear on all of them. $detailarrayThe content record being edited. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.content_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the content form uiadmin.content_edit.bottom `admin/manage-website` five content types Appears below the same form. The place to add a metadata field of your own. Parameters 2 $page_typestringThe content type: page, contract, news, article or reference. The same screen serves five types: check this if what you add should not appear on all of them. $detailarrayThe content record being edited. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.content_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The content list toolbar uiadmin.content_list.toolbar `admin/manage-website` the screen links Appears in the toolbar of the content list. The links of the screen itself are in your hands too. Parameters 2 $page_typestringThe content type: page, contract, news, article or reference. The same screen serves five types: check this if what you add should not appear on all of them. $linksarrayThe links of the screen: controller, add, categories and contracts. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.content_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the content list uiadmin.content_list.before_table `admin/manage-website` above the table Appears immediately above the content table. Parameters 1 $page_typestringThe content type: page, contract, news, article or reference. The same screen serves five types: check this if what you add should not appear on all of them. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.content_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the content category form uiadmin.content_category_edit.bottom `admin/manage-website` two category types Appears below the form of the blog and reference categories. Parameters 2 $category_typestringThe category type: article or reference. $detailarrayThe category being edited. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.content_category_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the slide form uiadmin.slide_edit.bottom `admin/manage-website` empty when creating Appears below the home page slide form. Parameters 1 $detailarrayThe slide being edited. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.slide_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The message action area uiadmin.messages_detail.actions `admin/manage-website/messages` no parameters Appears in the action area of a contact message. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.messages_detail.actions', 10, function () { return '
    Acme
    '; }); ``` #### The message toolbar uiadmin.messages_detail.message.toolbar `admin/manage-website/messages` no parameters Appears in the toolbar of the message itself. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.messages_detail.message.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The message reply form toolbar uiadmin.messages_detail.reply_form.toolbar `admin/manage-website/messages` no parameters Appears in the toolbar of the reply form. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.messages_detail.reply_form.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Below the message editor uiadmin.messages_detail.reply_editor.after `admin/manage-website/messages` no parameters Appears immediately below the reply editor. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.messages_detail.reply_editor.after', 10, function () { return '
    Acme
    '; }); ``` #### The message list toolbar uiadmin.messages_list.toolbar `admin/manage-website/messages` the folder arrives Appears in the toolbar of the contact message list. Parameters 1 $folderstringThe open folder: unread, read, replied, spam or trash. The same toolbar serves all five folders. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.messages_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The notification template list toolbar uiadmin.notification_list.toolbar `admin/notifications` grouped template list Appears in the toolbar of the notification template list. Parameters 1 $templatesarrayEvery template group, each carrying its name and items. It is not a flat list but a **two-level** structure: walk the groups and look inside. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.notification_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the template list uiadmin.notification_list.before_table `admin/notifications` grouped template list Appears immediately above the template table. Parameters 1 $templatesarrayEvery template group, each carrying its name and items. It is not a flat list but a **two-level** structure: walk the groups and look inside. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.notification_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the template list uiadmin.notification_list.bottom `admin/notifications` grouped template list Appears at the very bottom of the template list. Parameters 1 $templatesarrayEvery template group, each carrying its name and items. It is not a flat list but a **two-level** structure: walk the groups and look inside. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.notification_list.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the template form uiadmin.notification_edit.before_content `admin/notifications` group and key Appears above the notification template edit form. Parameters 3 $nTemplatearrayThe template being edited: its status, language contents and recipient settings. $nGroupstringThe group of the template. $nKeystringThe key of the template. Test the group and the key together to recognise your own template. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.notification_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the template form uiadmin.notification_edit.bottom `admin/notifications` group and key Appears below the same form. The place to show a list of your own variables. Parameters 3 $nTemplatearrayThe template being edited: its status, language contents and recipient settings. $nGroupstringThe group of the template. $nKeystringThe key of the template. Test the group and the key together to recognise your own template. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.notification_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the notification settings form uiadmin.notification_settings.before_form `admin/notifications` the shared shell code Appears above the notification shell settings. Parameters 1 $settingsarrayThe current settings: per-language header, body and footer code, and the logo paths. That code is the shell of **every** notification: a change here reaches every message sent. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.notification_settings.before_form', 10, function () { return '
    Acme
    '; }); ``` #### The language list toolbar uiadmin.language_list.toolbar `admin/languages` no parameters Appears in the toolbar of the language list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.language_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the language list uiadmin.language_list.before_table `admin/languages` no parameters Appears immediately above the language table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.language_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### Above the language form uiadmin.language_edit.before_content `admin/languages` empty when creating Appears above the language add and edit form. Parameters 1 $detailarrayThe language being edited: its name, status, whether it is local, and its writing direction. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.language_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the language form uiadmin.language_edit.bottom `admin/languages` empty when creating Appears below the same form. Parameters 1 $detailarrayThe language being edited: its name, status, whether it is local, and its writing direction. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.language_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` ### Pitfalls > **One screen serves many types** > > The content form serves five separate types, the message list five folders, and the page title point many controllers. A listener that outputs without checking the type shows a **blog field** to an administrator editing a contract. > **The template list has two levels** > > The list at the notification template points is not flat: first come the **groups**, then the items of each group. A listener walking it as one level never sees the items at all. ### Related Articles - [Notification Hooks](https://dev.wisecp.com/en/notification-hooks) - [Language and Translation Hooks](https://dev.wisecp.com/en/language-and-translation-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) ## Panel Settings and Tool Hooks https://dev.wisecp.com/en/admin-settings-and-tool-hooks The thirty-seven placement points of the settings, staff, module, automation and tool screens: tab points, forms and toolbars. ### Overview The placement points of the settings, staff, module, automation and tool screens live here. What marks this group out is **how many tab points it holds**: the general, security, tax, account, automation and staff settings each carry their own. All of them hand you an object rather than HTML, and the tab is added through a method call. ### Reference #### A new tab in the general settings uiadmin.settings.tabs `admin/settings` an object, not an array Runs once the general settings tabs are built. Parameters 1 $tabobjectThe tab object; add a tab through its method. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.settings.tabs', 10, function ($tab) { $tab->add('acme', 'Acme', Acme::renderSettings()); }); ``` #### A new tab in the security settings uiadmin.security_settings.tabs `admin/settings` an object, not an array Runs once the security settings tabs are built. Parameters 1 $tabobjectThe tab object; add a tab through its method. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.security_settings.tabs', 10, function ($tab) { $tab->add('acme-sec', 'Acme', Acme::renderSecurity()); }); ``` #### A new tab in the tax settings uiadmin.taxation_settings.tabs `admin/settings` an object, not an array Runs once the tax settings tabs are built. Parameters 1 $tabobjectThe tab object; add a tab through its method. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.taxation_settings.tabs', 10, function ($tab) { $tab->add('acme-tax', 'Acme', Acme::renderTax()); }); ``` #### A new tab in the account settings uiadmin.account_settings.tabs `admin/settings` an object, not an array Runs once the tabs of an administrator’s own account settings are built. Parameters 2 $tabobjectThe tab object; add a tab through its method. $udataarrayThe signed-in administrator’s data. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.account_settings.tabs', 10, function ($tab, $udata) { $tab->add('acme-me', 'Acme', Acme::renderMyPrefs((int) ($udata['id'] ?? 0))); }); ``` #### A new tab in the automation settings uiadmin.automation_settings.tabs `admin/automation` an object, not an array Runs once the automation settings tabs are built. Parameters 1 $tabobjectThe tab object; add a tab through its method. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.automation_settings.tabs', 10, function ($tab) { $tab->add('acme-cron', 'Acme', Acme::renderCronSettings()); }); ``` #### A new tab on the staff form uiadmin.staff_edit.tabs `admin/staff` an object, not an array Runs once the staff edit tabs are built. Parameters 2 $tabobjectThe tab object; add a tab through its method. $detailarrayThe staff record. ? **There is no id when creating**: test before adding a tab that depends on one. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.staff_edit.tabs', 10, function ($tab, $detail) { // Ekleme modunda kimlik YOK. if (!($detail['id'] ?? 0)) return; $tab->add('acme', 'Acme', Acme::renderStaffTab((int) $detail['id'])); }); ``` #### A new tab in the action logs uiadmin.tools_actions_list.tabs `admin/tools` a different method Runs once the action log tabs are built. Parameters 1 $tabobjectThe tab object. ? On this screen a tab is added through **a different method**; calling the one used at the sibling points does nothing here. Return 1 voidThe return is **not used**. This point shows no HTML: the tab is added through the object’s method. Listener PHP ```php Hook::add('ui:admin.tools_actions_list.tabs', 10, function ($tab) { // Bu ekranda metot farkli. $tab->set('acme', 'Acme', Acme::renderActions()); }); ``` #### The bottom of the general settings uiadmin.settings.bottom `admin/settings` no parameters Appears at the very bottom of the general settings page. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.settings.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The staff list toolbar uiadmin.staff_list.toolbar `admin/staff` no parameters Appears in the toolbar of the staff list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.staff_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the staff list uiadmin.staff_list.before_table `admin/staff` no parameters Appears immediately above the staff table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.staff_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The privilege list toolbar uiadmin.privilege_list.toolbar `admin/staff` no parameters Appears in the toolbar of the privilege group list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.privilege_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the table on the privilege list uiadmin.privilege_list.before_table `admin/staff` no parameters Appears immediately above the privilege table. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.privilege_list.before_table', 10, function () { return '
    Acme
    '; }); ``` #### The add-on list toolbar uiadmin.tools_addons_list.toolbar `admin/tools` no parameters Appears in the toolbar of the add-on list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tools_addons_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The task list toolbar uiadmin.tools_tasks_list.toolbar `admin/tools` no parameters Appears in the toolbar of the panel task list. Parameters 0 —It takes no parameters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tools_tasks_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the staff form uiadmin.staff_edit.before_content `admin/staff` empty when creating Appears above the staff add and edit form. Parameters 1 $detailarrayThe staff record being edited. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.staff_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the staff form uiadmin.staff_edit.bottom `admin/staff` empty when creating Appears below the same form. Parameters 1 $detailarrayThe staff record being edited. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.staff_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the privilege form uiadmin.privilege_edit.before_content `admin/staff` empty when creating Appears above the privilege group form. Parameters 1 $detailarrayThe privilege record: its name and permissions. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.privilege_edit.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the privilege form uiadmin.privilege_edit.bottom `admin/staff` empty when creating Appears below the same form. Parameters 1 $detailarrayThe privilege record: its name and permissions. It is **empty when creating**. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.privilege_edit.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Above the add-on settings uiadmin.addon_configure.before `admin/modules` on every add-on Appears on the add-on settings page, above the module’s own content. Parameters 2 $moduleobjectThe module instance; you may query its methods. $module_dataarrayThe record and configuration of the module: name, version and settings. The settings can hold an API key: keep it off the screen. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.addon_configure.before', 10, function ($module, $module_data) { // Kanca HER eklentinin ayar sayfasinda calisir: kendinizinkini secin. if (($module_data['name'] ?? '') !== 'Acme') return null; return '
    Acme
    '; }); ``` #### Below the add-on settings uiadmin.addon_configure.after `admin/modules` on every add-on Appears on the same page, below the module’s content. Parameters 2 $moduleobjectThe module instance; you may query its methods. $module_dataarrayThe record and configuration of the module: name, version and settings. The settings can hold an API key: keep it off the screen. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.addon_configure.after', 10, function ($module, $module_data) { // Kanca HER eklentinin ayar sayfasinda calisir: kendinizinkini secin. if (($module_data['name'] ?? '') !== 'Acme') return null; return '
    Acme
    '; }); ``` #### Above the module settings uiadmin.module_settings.before `admin/modules` on every module Appears above the content on the module configuration page. Parameters 2 $moduleobjectThe module being configured. $module_dataarrayThe data and configuration of the module. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.module_settings.before', 10, function () { return '
    Acme
    '; }); ``` #### Below the module settings uiadmin.module_settings.after `admin/modules` on every module Appears below the content on the same page. Parameters 2 $moduleobjectThe module being configured. $module_dataarrayThe data and configuration of the module. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.module_settings.after', 10, function () { return '
    Acme
    '; }); ``` #### Above the module page uiadmin.module_page.before `admin/modules` the instance may be empty Appears above the content on a module’s own management page. Parameters 2 $module_keystringThe key of the active module. $moduleobjectThe admin area instance of the module. ? On some paths it **arrives empty**: test before calling a method. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.module_page.before', 10, function ($module_key, $module) { // Ornek BOS gelebilir. if (!$module) return null; return Acme::renderModuleBanner($module_key); }); ``` #### Below the module page uiadmin.module_page.after `admin/modules` the instance may be empty Appears below the content on the same page. Parameters 2 $module_keystringThe key of the active module. $moduleobjectThe admin area instance of the module. ? On some paths it **arrives empty**: test before calling a method. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.module_page.after', 10, function ($module_key, $module) { // Ornek BOS gelebilir. if (!$module) return null; return Acme::renderModuleBanner($module_key); }); ``` #### The module list toolbar uiadmin.module_list.toolbar `admin/modules` the group arrives Appears in the toolbar of the module list. Parameters 1 $group_namestringThe active module group: mail, text message, payment, registrar, product or fraud check. The same list serves every group. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.module_list.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### Above the grid on the module list uiadmin.module_list.before_grid `admin/modules` above the grid Appears above the module cards. Parameters 2 $modulesarrayThe data of the modules being listed. $group_namestringThe active module group. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.module_list.before_grid', 10, function () { return '
    Acme
    '; }); ``` #### Above the theme settings uiadmin.theme_settings.before `admin/settings` the theme instance Appears above the content on the theme configuration page. Parameters 1 $themeobjectThe theme being configured. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.theme_settings.before', 10, function () { return '
    Acme
    '; }); ``` #### Below the theme settings uiadmin.theme_settings.after `admin/settings` the theme instance Appears below the content on the same page. Parameters 1 $themeobjectThe theme being configured. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.theme_settings.after', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the automation board uiadmin.automation_dashboard.bottom `admin/automation` the queue counters Appears at the very bottom of the scheduled task board. Parameters 1 $statsarrayThe board data: system state and queue counters. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.automation_dashboard.bottom', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of the automation system tab uiadmin.automation_settings.system_tab.bottom `admin/automation` carries a secret key Appears below the scheduled task system settings. Parameters 1 $configarrayThe whole task configuration. ? It holds the **secret key** that guards the cron address. Whoever sees it can trigger the tasks from outside: keep it off the screen and out of your logs. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.automation_settings.system_tab.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Adding an operation to a task card uiadmin.automation_task_card.actions `admin/automation` per task Appears in the operation area of the scheduled task cards, **separately for every task**. Parameters 1 $taskarrayThe data of one task: its name, frequency, pending and failed counts, and whether it is switched off. The hook runs per task: keep heavy work out of it. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.automation_task_card.actions', 10, function () { return '
    Acme
    '; }); ``` #### The analytics overview toolbar uiadmin.analytics.overview.toolbar `admin/analytics` section and range Appears in the toolbar of the report overview page. Parameters 2 $sectionstringThe active section: clients, services or support. $rangearrayThe active date range. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.analytics.overview.toolbar', 10, function () { return '
    Acme
    '; }); ``` #### The bottom of an analytics report uiadmin.analytics.report.bottom `admin/analytics` the range may be empty Appears at the very bottom of a report page. Parameters 2 $reportstringThe active report key. $rangearrayThe active date range. It **arrives empty** on reports that use no date range. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.analytics.report.bottom', 10, function () { return '
    Acme
    '; }); ``` #### Adding an operation to the distribution card uiadmin.analytics.distribution_card.actions `admin/analytics` the distribution card Appears in the operation area of the distribution chart. Parameters 1 $entitystringWhat is distributed: clients, services or tickets. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.analytics.distribution_card.actions', 10, function () { return '
    Acme
    '; }); ``` #### Above the import wizard uiadmin.tools_imports_wizard.before_content `admin/tools` the platform list Appears above the import wizard. Parameters 1 $platformsarrayThe import platforms installed. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.tools_imports_wizard.before_content', 10, function () { return '
    Acme
    '; }); ``` #### Below the licence details uiadmin.help_license.after `admin/tools` licence details Appears below the licence information screen. Parameters 1 $license_infoarrayThe licence details: owner, product, the address it is locked to and its dates. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.help_license.after', 10, function () { return '
    Acme
    '; }); ``` #### Above the updates screen uiadmin.help_updates.before_content `admin/tools` may be false Appears above the version update screen. Parameters 1 $new_versionmixedDetails of the new version found. With no new version it arrives as **false** rather than an array: test before reading a field. Return 1 string|nullThe **HTML you return appears**. With several listeners the outputs are appended one after another in priority order. Empty text, `null` and `false` are skipped, so return `null` when you have nothing to add. The output enters the page unescaped — clean anything coming from outside yourself. Listener PHP ```php Hook::add('ui:admin.help_updates.before_content', 10, function ($new_version) { // Yeni surum yoksa dizi degil YANLIS gelir. if (!is_array($new_version)) return null; return '
    Acme uyumluluk notu
    '; }); ``` ### Pitfalls > **The action log screen uses a different tab method** > > Every tab point looks alike, but the **action log screen uses another method**. Code copied from a sibling point quietly does nothing here: no error appears, and neither does the tab. > **The automation configuration carries a secret key** > > The configuration array on the system tab holds the **secret key** that guards the scheduled task address. Printing it to the screen or copying it into your own store makes the tasks triggerable from outside. ### Related Articles - [Panel Staff and Privilege Hooks](https://dev.wisecp.com/en/admin-staff-hooks) - [Module Installation Hooks](https://dev.wisecp.com/en/module-installation-hooks) - [Management Panel Hooks](https://dev.wisecp.com/en/hooks-in-the-management-panel) # Hooks / Customer Account ## Customer Account Hooks https://dev.wisecp.com/en/customer-account-hooks The 103 hooks about the customer account itself: signing up, signing in, verifying, blocking, sub-accounts and affiliates. ### Overview This domain carries the account **itself**: its opening, its sign-in, its verification, its blocking, its deletion. The screens a customer sees are a domain of their own. Some of these hooks fire **in the management panel**, because an operator can change an account too. So when writing a listener, the answer to "who did this" is not always the customer. ### Reference #### The account's life - **action:user.created**: An account was opened. The return is ignored. - **action:user.updated**: Account details changed. The return is ignored. - **action:user.status_changed**: The account status changed. The return is ignored. - **action:user.account_blocked**: The account was blocked, or unblocked. The return is ignored. - **action:user.deleted**: The account was deleted. The return is ignored. - **action:user.deleted_dormant**: A long-dormant account was deleted on its own. The return is ignored. #### Sign-in and security - **action:user.logged_in**: A sign-in happened. The return is ignored. - **action:user.login_failed**: A sign-in attempt failed. The return is ignored. - **action:user.logged_in_as**: A staff member entered a customer account. The return is ignored. - **action:user.password_changed**: The password changed. The return is ignored. - **action:user.two_factor_changed**: Two-step verification was switched on or off. The return is ignored; by here the change is already saved. - **action:user.session_revoked**: A session was ended. The return is ignored. - **action:user.account_switched**: The customer switched to another account. The return is ignored; the session is already written. #### Gates Eighteen gates stand in front of every weighty account action. They share one contract: a non-empty string stops the action. An empty return — `null` or `''` — lets it through. - **gate:user.create**: Opening an account. A filled return stops it, and that text becomes the error. - **gate:user.login**: Signing in — after the password checked out. A filled return stops it; a string and `['message' => '…']` are both taken. - **gate:user.login_as**: A staff member entering a customer account. A filled return stops it. - **gate:user.delete**: Deleting an account. A filled return stops it, and the delete answers `false`. - **gate:user.block**: Blocking an account. A filled return stops it. - **gate:user.add_funds**: Adding funds — before the invoice is issued. A filled return stops it, and the text reaches the customer. - **gate:user.email_change**: Changing the e-mail address. A filled return stops it. - **gate:user.two_factor_disable**: Switching two-step verification off. A filled return stops it; a string and `['message' => '…']` are both taken. - **gate:user.affiliate_enroll**: Joining the affiliate programme. A filled return stops it, and no record is opened. - **gate:user.affiliate_withdrawal**: An affiliate payout request. A filled return stops it. - **gate:user.gdpr_process**: Processing a data request. A filled return stops it. - **gate:user.subuser_invite**: Inviting a sub-account. A filled return stops it, and the text reaches the customer. #### Filters These eight do **not** share one contract. Three shapes stand side by side, so read the return of the one you are writing for. - **filter:user.required_fields**: The required fields on signing up and on the profile. Return an array; every listener's return is merged in. - **filter:user.login_resolve**: Which account the entered identity resolves to. The value changes by reference; the return is not used. - **filter:user.password_verify**: The password check. Returning `true` counts the password as good; every other return is ignored. - **filter:user.login_redirect**: Where a sign-in lands. The value changes by reference; the return is not used. - **filter:user.add_funds_amount**: The amount being added. The value changes by reference; the return is not used. - **filter:custom_field.save_value**: The value a custom field saves. The value changes by reference; the return is not used. - **filter:custom_field.load_value**: The value a custom field loads. The value changes by reference; the return is not used. - **filter:user.verification_fields**: The fields on a verification document. The fields change by reference; the return is ignored. #### Example ```php // A GATE: no account from a blocked country. Hook::add('gate:user.create', 10, function ($data) { $cc = strtoupper((string) ($data['country'] ?? '')); if (Acme::blockedCountry($cc)) return 'We take no sign-ups from there.'; return null; }); // AN EVENT: put the new account on the marketing list. Hook::add('action:user.created', 10, function ($user_id, $data) { Crm::subscribe((int) $user_id, (string) ($data['email'] ?? '')); }); ``` ### Pitfalls > **Both the customer and an operator change an account** > > Most hooks here fire from **two places**: the customer's own screen and the management panel. Writing a gate to say "the customer cannot do this" stops **the operator** as well. Where you need to tell them apart, read the actor the hook hands you. > **The sign-in gate does not check the password** > > The sign-in gate runs **after** the credentials check. The work there is not checking a password but stopping for another reason. To touch the password check itself, a filter of its own exists. > **Sub-accounts and switching blur the identity** > > A customer can enter another account as a **sub-user**. The person signed in and the account being worked on are then **different**. Use the account id the hook hands you; an id read from the session points at the wrong person after a switch. > **The delete hook is a point of no return** > > Deleting an account takes its records with it. By the time the delete **event** fires the work is done; gather what you need at **the gate** instead. The gate is earlier, and it can stop the action where that is wanted. ### Related Articles - [Customer Site Data and Gates](https://dev.wisecp.com/en/customer-site-data-and-gates) - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Partner Programme Hooks https://dev.wisecp.com/en/affiliate-hooks The four hooks over enrolment, commissions, referral clicks and payout requests. ### Overview The four moments of the partner programme: a customer **joins**, their link is **clicked**, a sale raises a **commission**, and they **ask to be paid**. One distinction to hold from the start: the id of the partner record and the id of the customer are **separate numbers**, and the hooks carry both. ### Reference #### Following an enrolment actionuser.affiliate_activated `ClientAffiliate` at enrolment Runs after a customer joins the partner programme. Parameters 2 $user_idintThe id of the account that joined. $aff_idintThe id of the partner record created. This is a **different** number from the customer id; the sibling hooks carry both separately. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.affiliate_activated', 10, function ($user_id, $aff_id) { // Two different ids: the partner record is not the customer number. Acme::openPartnerDashboard($user_id, $aff_id); }); ``` #### Following a commission being made actionuser.affiliate_commission_created `ClientAffiliate` sale and renewal Runs after a commission is credited to a partner. Both first sales and renewals land here. Parameters 4 $affiliate_idintThe partner record receiving it. $servicearrayThe service behind it: amount, currency and owner. $transaction_typestring`sale` for a first sale, `renewal` for a renewal. On renewals the commission recurs every period: mind the difference if you hand out a one-off reward. $commissionfloatThe commission worked out. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.affiliate_commission_created', 10, function ($affiliate_id, $service, $transaction_type, $commission) { // On renewals the commission recurs every period. if ($transaction_type === 'sale') Acme::rewardFirstSale($affiliate_id); }); ``` #### Following a referral click actionuser.affiliate_referral_clicked `ClientAffiliate` on every click Runs when a partner link is clicked. It fires on **every** click, with or without a sale, with or without a logged-in visitor. Parameters 2 $ownerIdintThe customer id of the partner; this is the attribution key. $contextarrayThe click context: the partner record, where the visitor came from and their address. The referring address **can be empty**, and it may count as personal data, so mind how long you keep it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.affiliate_referral_clicked', 10, function ($ownerId, $context) { // It runs on every click: keep heavy work out of here. Acme::countClick($ownerId); }); ``` #### Following a payout request actionuser.affiliate_withdrawal_requested `ClientAffiliate` nothing paid yet Runs when a partner asks for their earnings. **Nothing has been paid**: the record opens in a waiting state. Parameters 6 $uidintThe customer asking. $affIdintThe partner record, **not** the customer id. $amountfloatThe amount asked for; it matches what was written exactly. $cidintThe currency of the partner. $gatewaystringThe payout channel chosen. $widintThe request record created; always above zero. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.affiliate_withdrawal_requested', 10, function ($uid, $affId, $amount, $cid, $gateway, $wid) { // Nothing was PAID: the request opened in a waiting state. Acme::queuePayout($wid, $affId, $amount, $gateway); }); ``` ### Pitfalls > **The partner record and the customer are separate numbers** > > The payout hook carries both the customer id and the partner record, and **they are not the same number**. Taking the partner record for a customer id books the earnings against the wrong account. > **On renewals the commission recurs every period** > > The commission hook fires on the first sale and on every renewal. If you hand out a one-off reward, check the transaction type; otherwise the same customer earns it again each month. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Balance and Payment Preference Hooks https://dev.wisecp.com/en/balance-hooks The four hooks over the wallet, balance adjustments and the auto-payment chain. ### Overview Money enters or leaves a customer wallet four ways: a top-up request, credit from an invoice, a manual adjustment by an administrator, and a change in the auto-payment chain. Currency is the trap here: a balance is kept in its own currency while an invoice may be in another. Check which one an amount is in before carrying it anywhere. ### Reference #### Following the auto-payment chain actionuser.autopay_changed `AccountCards` head of the chain Runs when the auto-payment chain of an account changes. Parameters 2 $uidintThe owner of the account. $autopayCtxarrayThe current head of the chain. When the chain empties this **arrives empty**: the account has no card left to pay with and renewals will fail. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.autopay_changed', 10, function ($uid, $autopayCtx) { // An empty chain means renewals will fail. if (!($autopayCtx['backup_card'] ?? null)) Acme::warnNoCard($uid); }); ``` #### Following credit reaching the balance actionuser.balance_credited `Invoices` after conversion Runs when credit from an invoice reaches a customer balance. Parameters 4 $user_idintThe customer whose balance grew. $amountfloatThe amount credited, **in the balance currency** and after conversion. It need not match the invoice total: the invoice may be in another currency. $currencyintThe balance currency. $invoicearrayThe source invoice. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.balance_credited', 10, function ($user_id, $amount, $currency, $invoice) { // The amount is in the balance currency: do not mix it with the invoice one. Acme::ledgerCredit($user_id, $amount, $currency); }); ``` #### Following a balance adjustment actionuser.credit_adjusted `AdminUsers` two directions Runs when an administrator adjusts a balance by hand. Parameters 5 $user_idintThe customer whose balance moved. $typestringWhich way: `up` credit, `down` debit. $amountfloatThe adjustment amount. $new_balancefloatThe balance after the change. $cidintThe balance currency. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.credit_adjusted', 10, function ($user_id, $type, $amount, $new_balance, $cid) { Acme::ledgerAdjust($user_id, $type, $amount, $new_balance); }); ``` #### Following a top-up request actionuser.funds_added `AccountBalance` the net amount Runs when a customer asks to top up their wallet. Parameters 3 $uidintThe owner of the wallet. $amountfloatThe **net** amount asked for. Tax and gateway commission are **not included**: what the customer pays is higher. Do not book this number as the amount paid. $fundsCtxarrayContext: which surface the request came from and the minimum. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.funds_added', 10, function ($uid, $amount, $fundsCtx) { // The NET amount: tax and commission are not in it. Acme::noteTopupIntent($uid, $amount); }); ``` ### Pitfalls > **The balance amount is not the invoice amount** > > What reaches a balance arrives **in the balance currency** and after conversion. It need not equal the invoice total; treating them as one number produces a gap in your books. > **The top-up amount is the net amount** > > The amount in the top-up hook is **net**: tax and gateway commission are not in it. What the customer pays is higher, so do not record this number as the sum collected. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Account Security Hooks https://dev.wisecp.com/en/account-security-hooks The nine hooks over verification, password resets, sign-outs and access. ### Overview Every step that guards the identity of an account lives here: email and phone verification, the security question, password resets, login codes and sign-outs. Two gates stand out. The password gate hands you the **raw password**; only check it. The access gate works the other way round from the rest: a filled return **skips** the built-in checks. ### Reference #### Following an email change actionuser.email_changed `AccountUsers` a verified address Runs after the email address of an account changes. The new address is **verified**. Parameters 3 $uidintThe account owner. $oldEmailstringThe previous address. $newEmailstringThe new, verified address. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.email_changed', 10, function ($uid, $oldEmail, $newEmail) { // Tell the old address too: if the account was taken, that is the only warning path. Acme::alertOldAddress($oldEmail, $newEmail); }); ``` #### Following an email verification actionuser.email_verified `AccountUsers` at verification Runs after a customer verifies their email address. Parameters 1 $user_idintThe customer whose address was verified. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.email_verified', 10, function ($user_id) { Acme::unlockOnboarding($user_id); }); ``` #### Following a phone verification actionuser.phone_verified `AccountUsers` international form Runs after a customer verifies their phone number. Parameters 2 $uidintThe customer whose number was verified. $phonestringThe verified number as stored, in **international form**. The country code leads it; do not expect a local format. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.phone_verified', 10, function ($uid, $phone) { // The number is in international form. Acme::enableSmsAlerts($uid, $phone); }); ``` #### Following a security question change actionuser.security_question_changed `AccountUsers` always the login id Runs when the security question of an account is set or removed. Parameters 2 $user_idintThe member whose question changed. This is **always the person signed in**: a sub-user cannot change somebody else’s question. $has_questionboolTrue when a question is now set; false when the question **and its answer** were removed together. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.security_question_changed', 10, function ($user_id, $has_question) { // Removal comes through this hook too. if (!$has_question) Acme::warnWeakerRecovery($user_id); }); ``` #### Following a password reset request actionuser.password_reset_requested `Auth` two account types Runs when a password reset is requested. Both customer and administrator accounts pass here. Parameters 3 $userIdintThe account the request opened for. $typestringThe account type: `member` or `admin`. Administrator resets matter far more; keep the two apart. $emailstringThe address it was requested from, confirmed to match the account. Return 1 voidThe return is ignored. What your listener returns **cannot change** the reset response. Listener PHP ```php Hook::add('action:user.password_reset_requested', 10, function ($userId, $type, $email) { // An administrator reset matters far more. if ($type === 'admin') Acme::alertSecurityTeam($userId, $email); }); ``` #### Following a sign-out actionuser.logged_out `Auth` session captured first Runs when a session is closed. Parameters 2 $dataarrayThe session of the person leaving, **captured before the sign-out**. With no session it **arrives empty**: look before reaching for an id. $typestringThe sign-out type: `admin` or `member`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.logged_out', 10, function ($data, $type) { // With no session the array arrives EMPTY. if (!($data['id'] ?? 0)) return; Acme::closeSession((int) $data['id'], $type); }); ``` #### Following a login code being sent actionuser.login_code_issued `Auth` the code is not passed Runs when a one-time login code goes out. Parameters 3 $userIdintThe member the code went to. $channelstringThe delivery channel. $expiryintWhen the code stops working. The code **itself is not passed**, deliberately: handing it to a listener would spread it to a second place. Return 1 voidThe return is ignored; it cannot change the response. Listener PHP ```php Hook::add('action:user.login_code_issued', 10, function ($userId, $channel, $expiry) { // The code itself is deliberately withheld. Acme::noteLoginAttempt($userId, $channel); }); ``` #### Following a device being trusted actionuser.device_trusted `Auth` the trust token is not passed Runs when an account ticks "trust this device" on the two-factor screen and the browser is remembered. Parameters 4 $userIdintThe account the device now belongs to. $typestringThe account type: `member` or `admin`. $windowintHow many days the trust lasts. $uastringThe browser's raw User-Agent string. The trust token **is not passed**: handing it to a listener would spread it to a second place. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.device_trusted', 10, function ($userId, $type, $window, $ua) { Acme::securityLog('device_trusted', $userId, ['type' => $type, 'days' => $window]); }); ``` #### Following device trust being revoked actionuser.device_trust_revoked `Auth` rows are already gone Runs when an account's trusted devices are removed: a password reset, two-factor being switched on or off, a recovery-code sign-in, "sign out everywhere", or a removal from the account's own list. Parameters 3 $userIdintThe account whose trust was revoked. $typestringThe account type: `member` or `admin`. On a bulk clear with no type given it **arrives empty** — every type went. $countintHow many devices were removed; a single removal always passes `1`. Return 1 voidThe return is ignored; the rows are already deleted when it runs. Listener PHP ```php Hook::add('action:user.device_trust_revoked', 10, function ($userId, $type, $count) { Acme::securityLog('trust_revoked', $userId, ['count' => $count]); }); ``` #### Stopping a password reset gateuser.password_reset `AdminUsers` the raw password Runs before an administrator password is reset. Enforce your own password policy here. Parameters 3 $user_idintThe account being reset. $passwordstringThe new password **in the clear**. ? For a policy check only: do not log it, store it or send it anywhere. $bystringWhere the reset came from. Return 1 mixed**A filled return blocks it**: either a text or an array carrying a message. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:user.password_reset', 10, function ($user_id, $password, $by) { // The raw password: check it only, write it NOWHERE. if (strlen($password) < 16) return 'An administrator password needs at least 16 characters.'; return null; }); ``` #### Sending a customer to a screen of your own gateuser.full_access `website` returns a redirect Runs when a customer enters the panel, **ahead of** the built-in access checks: missing fields, data consent and billing details all come after it. Parameters 1 $idintThe customer signing in. Return 1 string|null? It works the other way round from other gates: **returning a filled address** sends the customer there and **skips every built-in check**. Returning an address unconditionally switches off the missing-field and consent checks for good. Keep your condition narrow and return empty otherwise. Listener PHP ```php Hook::add('gate:user.full_access', 10, function ($id) { // A filled return SKIPS THE BUILT-IN CHECKS: keep the condition narrow. if (Acme::mustAcceptTerms($id)) return Utility::AppAdress() . '/acme/terms'; return null; }); ``` ### Pitfalls > **The access gate works the other way round** > > In other gates a filled return **blocks**; in the access gate a filled return **redirects and skips every built-in check**. Returning an address unconditionally switches off the missing-field and consent checks for good. > **The password gate receives the raw password** > > The reset gate hands you the new password **in the clear**, which a policy check needs. Logging it, storing it or sending it anywhere leaks exactly what you set out to protect. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Sub-user Hooks https://dev.wisecp.com/en/subuser-hooks The seven hooks over inviting, accepting, updating and removing sub-users. ### Overview Somebody else reaching an account starts with an invitation and ends with an acceptance. Every step between has its own hook: inviting, resending, accepting, updating, suspending and removing. Three separate numbers must be kept apart: the **account**, the **person** reaching it and the sub-user **record**. Mixing them grants rights on the wrong account. ### Reference #### Following a sub-user invitation actionuser.subuser_invited `AccountUsers` invitation waiting Runs when a sub-user is invited to an account. **There is no access yet**: the invitation waits until it is accepted. Parameters 4 $owner_idintThe account owner sending it. $subuser_idintThe waiting invitation record created. $emailstringThe invited address. That address **need not** already have an account. $permsarrayThe permissions to be granted. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_invited', 10, function ($owner_id, $subuser_id, $email, $perms) { // There is NO access yet: the invitation is waiting. Acme::noteInvite($owner_id, $email); }); ``` #### Following an invitation being accepted actionuser.subuser_accepted `AccountUsers` access starts here Runs when a sub-user accepts an invitation. **Access begins at this point**. Parameters 3 $owner_idintThe account access was granted on. $user_idintThe **real** user id of the sub-user; this is who signs in. $subuser_idintThe id of the sub-user record. Three separate numbers: the account, the person and the record. Mixing them grants rights on the wrong account. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_accepted', 10, function ($owner_id, $user_id, $subuser_id) { // Three separate numbers: the account, the person, the record. Acme::grantAccess($owner_id, $user_id); }); ``` #### Following a sub-user being added actionuser.subuser_added `AccountUsers` added by an admin Runs when a sub-user is added to an account. Parameters 2 $user_idintThe account owner. $subuserarrayThe record created: address, label and permissions. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_added', 10, function ($user_id, $subuser) { Acme::syncTeam($user_id, $subuser); }); ``` #### Following a sub-user update actionuser.subuser_updated `AccountUsers` permissions included Runs when the details or permissions of a sub-user change. Parameters 2 $owner_idintThe account owner. $subuserarrayThe updated data: address, label, status and permissions. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_updated', 10, function ($owner_id, $subuser) { Acme::syncPermissions($owner_id, $subuser); }); ``` #### Following a sub-user status change actionuser.subuser_status_changed `AccountUsers` suspension Runs when a sub-user is suspended or brought back. Parameters 3 $owner_idintThe account owner. $subuser_idintThe id of the record that changed. $statusstringThe new state: `active` or `inactive`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_status_changed', 10, function ($owner_id, $subuser_id, $status) { if ($status === 'inactive') Acme::revokeTokens($subuser_id); }); ``` #### Following an invitation being sent again actionuser.subuser_invite_resent `AccountUsers` sent again Runs when a waiting invitation is sent again. Parameters 2 $owner_idintThe account owner sending it. $subuser_idintThe record of the invitation resent. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_invite_resent', 10, function ($owner_id, $subuser_id) { Acme::noteResend($owner_id, $subuser_id); }); ``` #### Following a sub-user being removed actionuser.subuser_deleted `AccountUsers` after deletion Runs after a sub-user is removed from an account. Parameters 2 $owner_idintThe account owner. $subuserarrayThe full record before deletion: address, status and permissions. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.subuser_deleted', 10, function ($owner_id, $subuser) { Acme::revokeAllAccess($owner_id, $subuser['email'] ?? ''); }); ``` ### Pitfalls > **There are three separate numbers** > > The acceptance hook carries three ids: the **account** access was granted on, the **person** signing in and the sub-user **record**. Using the wrong one writes rights against another account. > **An invitation is not access** > > When the invitation hook runs the other party can **reach nothing yet**. Access begins at the acceptance hook. A listener that opens rights on invitation turns an unaccepted invitation into real access. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Personal Data Hooks https://dev.wisecp.com/en/personal-data-hooks The nine hooks over consent, exports, data requests and identity documents. ### Overview The four moments of personal data: a customer **gives consent**, **exports** their data, **asks for it to go**, and **submits identity documents**. Two hooks deliberately withhold content: the document on export, the identity paper on submission. Listeners get only a summary, because spreading that data to a second place works against the point. ### Reference #### Following a consent change actionuser.gdpr_consent_changed `AccountPrivacy` proof stamp Runs when a customer gives or withdraws consent for data processing. It fires only on a **real change**: saving the same value again does not raise it. Parameters 3 $uidintThe customer whose consent changed. $statusintThe new state: one for given, zero for withdrawn. Since the hook fires only on a change, the previous state is always the opposite. $given_atstringThe stamp written to the record. This is the time part of the consent proof: use this value rather than reading it back. Return 1 voidThe return is ignored. The consent is already written; this cannot block it. Listener PHP ```php Hook::add('action:user.gdpr_consent_changed', 10, function ($uid, $status, $given_at) { // Take the proof stamp from here rather than reading it back. Acme::recordConsent($uid, (bool) $status, $given_at); }); ``` #### Following a data export actionuser.gdpr_exported `AccountPrivacy` summary only Runs when a customer exports their own data. Parameters 2 $uidintThe customer exporting. $metaarrayA **summary** of the export: its format and size. The document content is withheld on purpose: passing it would hand every listener the customer’s personal data. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.gdpr_exported', 10, function ($uid, $meta) { // The document content is deliberately withheld. Acme::auditExport($uid, (int) ($meta['size'] ?? 0)); }); ``` #### Following a data request being opened actionuser.gdpr_request_created `AccountPrivacy` two kinds Runs when a customer opens a deletion or anonymisation request. Parameters 2 $uidintThe customer opening it. $typestringThe kind: `remove` for deletion, `anonymize` for anonymisation. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.gdpr_request_created', 10, function ($uid, $type) { Acme::openComplianceCase($uid, $type); }); ``` #### Following a data request being cancelled actionuser.gdpr_request_cancelled `AccountPrivacy` withdrawn Runs when a customer withdraws a request they opened. Parameters 2 $uidintThe owner of the request. $request_idintThe id of the cancelled request. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.gdpr_request_cancelled', 10, function ($uid, $request_id) { Acme::closeComplianceCase($request_id); }); ``` #### Following a data request being carried out actionuser.gdpr_processed `AdminUsers` cannot be undone Runs after a data request is carried out. **Deletion and anonymisation cannot be undone**: by this point the data is already gone. Parameters 3 $request_idintThe request id. $user_idintThe customer concerned. $actionstringWhat was carried out: removal, anonymisation, destruction or cancellation. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.gdpr_processed', 10, function ($request_id, $user_id, $action) { // The data is gone: clear your own copy as well. if ($action !== 'cancelled') Acme::purgeMirror($user_id); }); ``` #### Following documents being submitted actionuser.documents_submitted `AccountDocuments` reference only Runs when a customer submits identity documents. Parameters 2 $uidintThe customer submitting. This flow is for the person themselves: a sub-user cannot submit on somebody else’s behalf. $doc_metaarrayA summary of the submission. ? **No document content** is here, only references and metadata. It is deliberate, so identity documents are not handed to listeners. Return 1 voidThe return is ignored. The records are already written; this cannot block the submission. Listener PHP ```php Hook::add('action:user.documents_submitted', 10, function ($uid, $doc_meta) { // No document content, only the reference. Acme::queueReview($uid); }); ``` #### Widening the document field types filteruser.document_field_types `AdminUsers` passed by link Runs while the list of types offered when defining a document field is built. Add a field type of your own here. Parameters 1 $typesarrayby linkType key against the visible label. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:user.document_field_types', 10, function (&$types) { $types['acme-iban'] = 'Acme IBAN check'; }); ``` #### Changing a document field definition filteruser.document_field_input `AdminUsers` passed by link Runs before a document field is saved. Fill in the settings of a type you added here. Parameters 2 $valuesarrayby linkThe row to be written: status, type, labels, options, accepted extensions and size limit. $typestringby linkThe field type. It is there so you can tell whether this is your type: check it first. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:user.document_field_input', 10, function (&$values, &$type) { // Check whether it is your type first. if ($type !== 'acme-iban') return; $values['max_size'] = 512; }); ``` #### Changing the document records view filteruser.document_records_view `AdminUsers` personal data Runs when an administrator looks at a customer’s documents, before the records reach the screen. Parameters 2 $recordsarrayby linkThe document records: field name, value, status. They carry identity details: this is where masking belongs. $user_idintby linkThe customer being looked at; for reading only. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:user.document_records_view', 10, function (&$records, &$user_id) { // Hide the identity number from eyes without clearance. if (Acme::canSeeFullId()) return; foreach ($records as $i => $r) $records[$i]['field_value'] = Acme::mask($r['field_value'] ?? ''); }); ``` ### Pitfalls > **Content is withheld on purpose** > > The export hook does **not** carry the document, and the submission hook does not carry the identity paper; both give a summary only. That is design, not omission: handing the data to listeners works against the protection. If you need the content, read it through your own permitted path. > **A processed request cannot be undone** > > By the time the removal or anonymisation hook runs the data is **already gone**. You must clear your own copy as well, or the customer's data lives on with you. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Account and Contact Hooks https://dev.wisecp.com/en/account-contact-hooks The eight hooks over the profile picture, addresses, linked providers and preferences. ### Overview Everything a customer uses to describe themselves lives here: the profile picture, billing addresses, linked social accounts and notification preferences. The address side carries four hooks: the save gate and its event, the default changing, and deletion. The default address is the one printed on invoices. ### Reference #### Following a profile picture change actionuser.avatar_changed `AccountUsers` two branches Runs when a customer uploads or removes a profile picture. Parameters 3 $uidintThe account owner. $actionstringWhat happened: `set` uploaded, `removed` taken away. $picturestringThe path of the new picture. On the removal branch it is **always empty**: check the action before using the path. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.avatar_changed', 10, function ($uid, $action, $picture) { // On the removal branch the path arrives EMPTY. if ($action === 'removed') { Acme::dropAvatar($uid); return; } Acme::mirrorAvatar($uid, $picture); }); ``` #### Stopping a contact being saved gateuser.contact_save `AccountUsers` zero means new Runs before a customer saves a contact address. Parameters 3 $uidintThe account the address will belong to. $dataarrayThe address data: name, email, phone, country and tax details. $idintThe id being edited; a **zero means a new one** is being added. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:user.contact_save', 10, function ($uid, $data, $id) { // A zero id means a new record. if (!Acme::taxNumberValid($data)) return 'The tax details did not verify.'; return null; }); ``` #### Following a contact being saved actionuser.contact_saved `AccountUsers` create and edit Runs after a contact address is saved. Parameters 4 $uidintThe owner of the address. $saved_idintThe id of the saved address. $dataarrayThe address data. $is_newboolTrue when it was newly added. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.contact_saved', 10, function ($uid, $saved_id, $data, $is_new) { Acme::syncContact($uid, $saved_id, $data); }); ``` #### Following the default contact changing actionuser.contact_default_changed `AccountUsers` default changed Runs when an address becomes the default. The default is the address printed on invoices. Parameters 2 $uidintThe owner of the address. $idintThe address made default. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.contact_default_changed', 10, function ($uid, $id) { // The default address is the one printed on invoices. Acme::syncBillingAddress($uid, $id); }); ``` #### Following a contact being deleted actionuser.contact_deleted `AccountUsers` after deletion Runs after a contact address is deleted. Parameters 3 $uidintThe owner of the address. $idintThe id deleted; the row is gone. $existingarrayThe full address as read immediately before deletion. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.contact_deleted', 10, function ($uid, $id, $existing) { Acme::dropContact($uid, $id); }); ``` #### Following a social account being linked actionuser.connected_provider `SocialAuth` account linking Runs when an account is linked to an outside provider. Parameters 2 $userobjectThe matched user record. $mod_namestringThe name of the provider linked. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.connected_provider', 10, function ($user, $mod_name) { Acme::noteLink((int) ($user->id ?? 0), $mod_name); }); ``` #### Following notification preferences changing actionuser.notification_preferences_changed `AccountUsers` per channel Runs when a customer changes which notifications they receive. Parameters 2 $uidintThe account whose preferences changed. $prefsarrayWhat was written, **per channel**. The keys are deliberately the database column names, so which layer changed is never in doubt. Return 1 voidThe return is ignored. The preferences are already written. Listener PHP ```php Hook::add('action:user.notification_preferences_changed', 10, function ($uid, $prefs) { Acme::syncPreferences($uid, $prefs); }); ``` #### Producing the text of an activity record filteruser.action_text `User::addAction` only when empty Runs while the visible text of an account activity is resolved. Produce the text for your own activity keys here. Parameters 2 $textstringThe text the core found. The hook runs **only when no text was found**, so this is always empty. It is passed for signature consistency. $ctxarrayContext: the activity key, the language asked for and the placeholder values. Return 1 string|nullReturn the text you produced. Return nothing for keys that are not yours. Listener PHP ```php Hook::add('filter:user.action_text', 10, function ($text, $ctx) { // The hook runs only when no text was found. if (!str_starts_with($ctx['key'] ?? '', 'acme.')) return null; return Acme::actionText($ctx['key'], $ctx['lang'] ?? '', $ctx['variables'] ?? []); }); ``` ### Pitfalls > **The picture path is empty on the removal branch** > > The profile picture hook fires for uploads and removals alike, and on removal the path field **always arrives empty**. A listener using the path directly then works with nothing on that branch. > **The default address is the one on the invoice** > > Changing the default is more than a preference: the invoices that follow carry that address. If you keep an accounting side in step, listen on this hook too. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Account Restriction Hooks https://dev.wisecp.com/en/account-restriction-hooks The six hooks over blacklisting, bulk suspension, dormant accounts and bulk messages. ### Overview The operations that narrow what an account may do live here: blacklisting, suspending or cancelling every service, and deleting an account left unused. All of them reach wide and are hard to undo. That is what makes the gates valuable: they are the only place a wrong click can still be stopped. ### Reference #### Stopping a blacklisting gateuser.blacklist_add `AdminUsers` before the write Runs before a customer is put on the blacklist. Parameters 3 $user_idintThe customer to be blacklisted. $reasonstringThe reason code: payment fraud, chargeback, abuse, spam, terms breach, false information or other. $restrictionsarrayThe restrictions to apply: new orders, renewals, tickets and suspending services. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:user.blacklist_add', 10, function ($user_id, $reason, $restrictions) { // Ask for a second approval on an enterprise account. if (Acme::isEnterprise($user_id)) return 'An enterprise account needs manager approval.'; return null; }); ``` #### Following a blacklist change actionuser.blacklist_changed `AdminUsers` three states Runs after the blacklist state of a customer changes. Parameters 4 $user_idintThe customer affected. $statusstringWhat happened: `add`, `add-2` or `remove`. There are two separate adding values; a listener watching only one misses the other. $reasonstringThe reason. $admin_idintThe administrator who did it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.blacklist_changed', 10, function ($user_id, $status, $reason, $admin_id) { // There are two separate adding values. if ($status !== 'remove') Acme::flagRisk($user_id, $reason); }); ``` #### Stopping a bulk service cancellation gateuser.services_bulk_cancel `AdminUsers` all at once Runs before **every** service of a customer is cancelled. It is one click, and hard to undo. Parameters 1 $user_idintThe customer whose services would be cancelled. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:user.services_bulk_cancel', 10, function ($user_id) { // One click takes every service. if (Acme::hasActiveContract($user_id)) return 'An account under contract cannot be cancelled in bulk.'; return null; }); ``` #### Following a bulk suspension actionuser.services_bulk_suspended `AdminUsers` all at once Runs after every service of a customer is suspended. Parameters 1 $user_idintThe customer whose services were suspended. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:user.services_bulk_suspended', 10, function ($user_id) { Acme::notifyAccountFrozen($user_id); }); ``` #### Stopping a dormant account being deleted gateuser.delete_dormant `cronjobs` per account Runs before an account left unused for a long time is deleted. The hook fires **per candidate**: one round can call it hundreds of times. Parameters 2 $userIdintThe account up for deletion. $uarrayThe candidate row: name, address and how long it has been dormant. Return 1 string|null**A non-empty text skips this account** and the reason reaches the result report. Unlike other gates nothing is thrown: the round carries on with the remaining candidates. Listener PHP ```php Hook::add('gate:user.delete_dormant', 10, function ($userId, $u) { // It runs per account: keep it light. if (Acme::hasRetentionHold($userId)) return 'under a retention obligation'; return null; }); ``` #### Changing the bulk message recipients filteruser.bulk_recipients `AdminUsers` count and list together Runs once the recipient pool for a bulk email or text message is built. Parameters 4 $resultarrayby linkThe recipient pool: a count and the contacts. ? **Keep the two in step**: dropping a contact without adjusting the count shows a wrong recipient total on screen. $dbTypestringWho it goes to: customers or staff. $notifTypestringWhich channel: email or text message. $filtersarrayThe filters applied. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:user.bulk_recipients', 10, function (&$result, $dbType, $notifType, $filters) { // The COUNT and the LIST must stay in step. $result['contacts'] = Acme::dropOptedOut($result['contacts'] ?? []); $result['count'] = count($result['contacts']); }); ``` ### Pitfalls > **Blacklisting has two separate adding values** > > The status field carries three values and **two of them mean adding**. A listener watching only one misses a customer blacklisted through the other path. Testing for removal and treating the rest as adding is safer. > **Update the recipient list and the count together** > > The bulk recipient filter carries both the contacts and the count. Dropping a contact and leaving the count alone shows the administrator a **wrong recipient total** and skews the decision to send. ### Related Articles - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) # Hooks / Services ## Service Lifecycle Hooks https://dev.wisecp.com/en/service-lifecycle-hooks The 97 hooks opened across a sold service's life: setting up, suspending, cancelling, renewing, changing plan, and talking to the provider module. ### Overview A service is the thing a customer bought: a hosting package, a server, a software licence. The hooks here follow its **life**. Most come in pairs: a gate in front of an action and an event behind it. Another part is about the **module** actually providing the service. The code opening an account on a server, suspending it or changing a password lives there. These hooks let you touch that call's input and output. ### Reference #### Life events - **action:service.created**: A service was opened. The return value is ignored. - **action:service.status_changed**: Its status changed. The return value is ignored. - **action:service.suspended**: It was suspended. The return value is ignored. - **action:service.terminated**: It was terminated. The return value is ignored. - **action:service.cancelled**: It was cancelled. The return value is ignored. - **action:service.deleted**: Its record was deleted. The return value is ignored. - **action:service.renewed**: It was renewed. The return value is ignored. - **action:service.updowngrade.applied**: A plan change was applied. The return value is ignored. #### Gates Eighteen gates. Two of them split one job in two. A module action called from the panel passes one gate, and the same action from the customer panel passes another. Where your rule holds for both, bind to **both**. - **gate:service.suspend**: Suspending. - **gate:service.cancel**: Cancelling. - **gate:service.delete**: Deleting. - **gate:service.upgrade**: Upgrading the plan. - **gate:service.manual_renew**: Renewing by hand. - **gate:service.status_change**: Changing the status. - **gate:service.module_action**: A module action from the panel. - **gate:service.client_tool**: A module action from the customer panel. - **gate:service.transfer_request**: A transfer request. - **gate:service.autorenew_toggle**: Switching auto-renewal on and off. #### The provider module - **action:service.module_ran**: An action ran on the provider module. - **filter:service.module_result**: What the module returned. - **filter:service.build_options**: The options going to the module while a service is built. The array changes by reference and the changed array is what gets written; the return value is not used. - **filter:service.config_fields**: The service's configuration fields. - **filter:service.module_username**: The username used on the module. - **action:service.password_changed**: The service password changed. #### The detail screen's data - **filter:service.detail_data**: The data behind a service detail. - **filter:service.dashboard.cards**: The cards on a service dashboard. - **filter:service.dashboard.tools**: The tools on a service dashboard. - **filter:service.detail_tabs_capabilities**: Which capabilities the detail tabs show. - **filter:service.upgrade_products**: The products offered for an upgrade. #### A gate and an event together ```php // A GATE: do not suspend a service under review (ask us first). Hook::add('gate:service.suspend', 10, function ($service) { if (Acme::onHold((int) ($service['id'] ?? 0))) return 'This service is under review; the suspend was stopped.'; return null; }); // AN EVENT: tell the outside once it was suspended. Hook::add('action:service.suspended', 10, function ($id, $service) { Crm::suspended((int) $id); }); ``` ### Pitfalls > **Automatic actions pass these hooks too** > > Suspending, terminating and renewing also run from **scheduled tasks**. A gate saying "the operator should not do this" also lands on the job running overnight, and the system cannot do its own work. Write a gate thinking about who it stops. > **The event does not prove the work landed on the server** > > The status hook runs when **our record** changes. Whether the provider module truly closed the account on the server is a separate question, and the record can change even where the module call failed. Where the server side matters, read the filter carrying the module result. > **The panel and customer paths are separate gates** > > Module actions pass two separate gates: one for the operator in the panel, one for the customer in theirs. Binding to only one means your rule **can be walked around** by the other path. The customer path is also narrower: only the methods a module openly allows can be called. > **A renewal can show up twice** > > A renewal can come from an invoice being paid or from a manual action, and a subscription collection takes the same path. Writing work that follows a renewal, be ready to run **twice for one period** and weed out the repeat on your side. ### Related Articles - Order Flow Hooks - Invoice and Payment Hooks - Module Lifecycle Hooks ## Service Status Hooks https://dev.wisecp.com/en/service-status-hooks Nine hooks from a sold service opening to its record going: setting up, status changes, suspending, terminating and deleting. ### Overview A service lives in **two places at once**: as a row in our database and as a real account at the end of a provider module. Most hooks here speak about the first. Knowing that split matters: when a status changes, **our record** changed. Whether the account on the server truly closed is a separate question, and usually the outcome of a job that was **queued**. ### Reference #### Changing the service about to be created filterservice.save_data `Hook::runRefs` by reference Runs before the service row is written to the database. Parameters 1 $dataarrayrefThe row about to be written: `user_id`, `product_id`, `options`, `status`, `duedate`. What you change here goes straight to the database. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.save_data', 10, function (&$data) { // Put your own tracking key inside options; do not add a column. $data['options']['acme_batch'] = Acme::currentBatch(); }); ``` #### Learning that a service opened actionservice.created `Services::create()` the row landed Runs after the service row was written. The account at the provider may **not exist yet** at this point. Parameters 2 $idintThe new service id. $dataarrayThe data that was written. It has passed the filter, so your changes show here. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.created', 10, function ($id, $data) { // The row exists, the account may not: do not wait on provisioning here. Crm::opened((int) $id, (int) ($data['user_id'] ?? 0)); }); ``` #### Stopping a status change gateservice.status_change `Services::change_status()` both states Runs before the service status changes, handing you both the old state and the target. Parameters 3 $servicearrayThe service record with its **current** status. $statusstringThe target: `active`, `suspended`, `cancelled`, `inprocess`. $oldStatusstringThe current status. Read both together to target one particular transition. Return 1 stringA non-empty string **stops** the transition; the text is thrown as the error. Listener PHP ```php Hook::add('gate:service.status_change', 10, function ($service, $status, $oldStatus) { // Check only the reopening of a cancelled service. if ($oldStatus === 'cancelled' && $status === 'active' && !Acme::reactivationAllowed($service)) return 'A cancelled service reopens with an operator\'s approval.'; return null; }); ``` #### Following a status change actionservice.status_changed `Services::change_status()` the module may have overridden Runs after the status changed. The value you get is the **final** one, after any override by the module. Parameters 4 $serviceIdintThe service id. $statusstringThe new status. It can **differ** from the one asked for where the module stepped in. $oldStatusstringThe previous status. $servicearrayA snapshot of the service **before** the change. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.status_changed', 10, function ($serviceId, $status, $oldStatus, $service) { // Do no work again where it landed back on the same status. if ($status === $oldStatus) return; Crm::statusMoved($serviceId, $oldStatus, $status); }); ``` #### Stopping a suspend gateservice.suspend `cronjobs/ServiceSuspend` a veto turns into a cancel Runs before a service or add-on is suspended. **Stopping it has a cost**: the job turns into a cancel signal. Parameters 4 $target_typestring`service` or `addon`. One hook carries both. $target_idintThe id of the record being suspended. $servicearrayThe live row: `status`, `duedate`, `module`, `owner_id`. $user_idintThe service owner. Return 1 stringA non-empty string **vetoes** the suspend — and it does not end there: the signal turns into a **cancel** and your reason is recorded. So "do not suspend" is not the same as "do nothing". Listener PHP ```php Hook::add('gate:service.suspend', 10, function ($target_type, $target_id, $service, $user_id) { if ($target_type !== 'service') return null; // leave add-ons alone // MIND: a veto stops the suspend but turns the job into a CANCEL signal. if (Acme::vipAccount($user_id)) return 'VIP account: review by hand.'; return null; }); ``` #### Following a suspend actionservice.suspended `cronjobs/ServiceSuspend` the module job is queued Runs after the service was suspended. The actual closing on the server was **queued**. Parameters 5 $target_typestring`service` or `addon`. $target_idintThe id of the suspended record. $user_idintThe service owner. $reasonstringThe reason, **translated into the customer's language**. $module_queue_idintThe queue id of the module job. `0` means it was **never queued**: on a service without a module nothing happens on the server side. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.suspended', 10, function ($target_type, $target_id, $user_id, $reason, $module_queue_id) { // A queue id of 0 means nothing was done on the server. if ($module_queue_id === 0) Ops::note('suspend-no-module', $target_id); }); ``` #### Following a termination actionservice.terminated `cronjobs/ServiceTerminate` the server side Runs after the service was terminated. This hook carries the **server side**: which module, which server. Parameters 5 $target_idintThe id of the terminated service. $service_typestring`hosting` or `server`. $modulestringThe server module's name. $server_idintThe server the service sat on. $module_queue_idintThe queue id of the termination job. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.terminated', 10, function ($target_id, $service_type, $module, $server_id, $module_queue_id) { // Capacity came free on that server: update your own counter. Capacity::released((int) $server_id, $service_type); }); ``` #### Stopping a service delete gateservice.delete `Services::delete()` the record goes Runs before the service record is deleted. Deleting is **not the same** as closing the account on the server: it removes our record alone. Parameters 1 $servicearrayThe service record about to be deleted. Return 1 stringA non-empty string **stops** the delete; the text is thrown as the error. Listener PHP ```php Hook::add('gate:service.delete', 10, function ($service) { // Deleting a live service leaves an orphaned account on the server. if (($service['status'] ?? '') === 'active') return 'A live service cannot be deleted; terminate it first.'; return null; }); ``` #### Following a delete actionservice.deleted `Services::delete()` a last snapshot Runs after the service record was deleted. Parameters 2 $idintThe id of the deleted service. $servicearrayA snapshot from **before** the delete. Take what you need from here; the record is gone. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.deleted', 10, function ($id, $service) { Acme::forgetService((int) $id, $service['name'] ?? ''); }); ``` ### Pitfalls > **A suspend veto becomes a cancel** > > Returning a non-empty text at the suspend gate does not stop the work, it **turns it into a cancel**. A rule written as "do not suspend this one" can end with the service **cancelled outright**. Write here knowing what follows. > **A queue id of zero means nothing happened on the server** > > The suspend and terminate hooks hand you a **queue id**. Zero means the module job was **never created**: the service has no module, or the server is unknown. Our record changed and nothing happened on the server. > **The final status may not be the one asked for** > > The status event hands you the value **after any override by the module**. The target you saw at the gate and the value in the event can **differ**. Reacting to a transition, read the value from the event rather than the intent at the gate. > **Deleting is not closing** > > Deleting a service removes **our record** and nothing else. The account on the server stays where it is, now visible from nowhere. Where the server side should close too, terminate first and delete after. ### Related Articles - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - Order Flow Hooks - Scheduled Task Hooks ## Service Module Hooks https://dev.wisecp.com/en/service-module-hooks The eight hooks talking to the module that truly provides a service: the gates before a call, the parameters going out and the result coming back. ### Overview The code opening an account on a server, suspending it or changing a password lives **in the module**. The core says "do this" and waits for the outcome. This path has **two separate gates** and mixing them up is common: one for calls from the panel, one for calls from the customer's own panel. The customer path is narrower and cannot reach past the methods a module openly allows. ### Reference #### Changing the options a service is built with filterservice.build_options `Orders::buildServices()` from order to service Runs while a service is built from an order item, before the record is written. Parameters 3 $service_dataarrayrefThe whole payload of the service to be built: `type`, `product_id`, `amount`, `status`, `module`, `options`, `metrics`. $itemarrayThe source order item. Read-only context; what the customer picked at order time is here. $productarrayThe resolved product row. Read-only context. Return 1 voidThe values change **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.build_options', 10, function (&$service_data, $item, $product) { // Fill in module options from what the order says. $service_data['options']['acme_region'] = Acme::region($item); }); ``` #### Changing what the module is built with filterservice.module_context `Hook::runRefs` before the module object exists Runs in `Services::run_module()`, immediately before the module object is built. The module reads `$service['options']` at construction time, so this is the last point where a change still reaches the provider — the gate below already runs too late for that. Parameters 3 $servicearrayrefThe service record. Its `options` is what the module object is built from. $actionstringrefThe action about to run. Alias resolution (`terminate` → `cancel`) happens after this hook, so a new value is resolved too. $paramsarrayrefThe arguments the module method will receive. Return 1 voidThe values change **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.module_context', 10, function (&$service, &$action, &$params) { if ($action !== 'create') return; // Written into the copy the module is about to be built from; persist it // separately if it also has to survive in the record. $options = is_array($service['options'] ?? null) ? $service['options'] : []; $options['ip'] = Acme::reserve((int) $service['id']); $service['options'] = $options; }); ``` > **Every create path passes here, and only here** > > The up/downgrade rebuild hands `run_module()` a **prepared array** and never re-reads the row. A listener that only follows `action:service.created` misses that path, and the service is provisioned without whatever the listener meant to add. #### Stopping a module action gateservice.module_action `Services::run_module()` the panel path Runs before the module method is called. This gate stands on calls from **the panel**. Parameters 3 $servicearrayThe service record. $actionstringThe action about to run: `create`, `suspend`, `cancel` and the like. $paramsarrayThe parameters going to the module method. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the module is never called. Listener PHP ```php Hook::add('gate:service.module_action', 10, function ($service, $action, $params) { // Send no write action to a server inside its maintenance window. if (in_array($action, ['create', 'suspend', 'cancel'], true) && Acme::maintenance((int) ($service['server_id'] ?? 0))) return 'The server is under maintenance; this cannot run now.'; return null; }); ``` #### Following a module call actionservice.module_ran `Services::run_module()` one array parameter Runs after the module method ran — **whether it worked or not**. Parameters 1 $payloadarrayIt carries a single array: `service`, `instance` (the module object), `action`, `result`, `error`. Unlike the other hooks the parameters do **not arrive separately**; they all sit inside this one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.module_ran', 10, function ($payload) { // ONE parameter arrives; the error field inside it reports the failure. if (!empty($payload['error'])) Ops::alert('module-failed', $payload['action'] ?? '', $payload['error']); }); ``` #### Changing the module result filterservice.module_result `Hook::runRefs` all five by reference Runs before the module's return is handled by the core. **All five arguments** arrive by reference. Parameters 5 $servicearrayrefThe service record. $instanceobjectrefThe module object. $actionstringrefThe action that ran. $resultmixedrefWhat the module returned: a config array, login details, `false`, or something else. This is the one you usually change. $errormixedrefThe error message or `null`. Writing here tells the core "this failed". Return 1 voidThe values change **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.module_result', 10, // Defaults are REQUIRED: $error is null when the module succeeds, and a null argument is // dropped, so a listener with five required parameters never runs on a successful call. function (&$service = null, &$instance = null, &$action = null, &$result = null, &$error = null) { // Turn a provider's temporary error into something worth retrying. if ($error && Acme::transient((string) $error)) { $error = null; $result = false; // the core reads a failure, raises no alarm } }); ``` #### Stopping a call from the customer panel gateservice.client_tool `ClientServices` the customer path Runs where a customer calls a module action from their own panel. This gate is **separate** from the panel one. Parameters 3 $servicearrayThe customer's own service. $methodNamestringThe real module method about to run: a directly callable name (`sso_panel_login`) or the `handle_` form. $moduleobjectThe module object. Built in customer mode, with panel-only rows already dropped. Return 1 stringA non-empty string **stops** the call; it reaches the customer as the error. Listener PHP ```php Hook::add('gate:service.client_tool', 10, function ($service, $methodName, $module) { // One-click panel entry is sensitive: close it to unverified accounts. if ($methodName === 'sso_panel_login' && !Acme::verified($service)) return 'Verify your account before entering the panel.'; return null; }); ``` #### Following a customer call actionservice.client_tool_ran `ClientServices` two method names Runs after the module method the customer called has run. Parameters 4 $servicearrayThe service record. $methodstringThe name **at request level**: `tool_action`, `tool_table`, `sso_panel_login`, or a key the module opened. $methodNamestringThe module method that **actually ran**. The two often differ, so mind which one you read. $moduleResultmixedWhat the module returned. Output the module printed directly is **not** in here. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.client_tool_ran', 10, function ($service, $method, $methodName, $moduleResult) { // The request name and the real method differ: record BOTH. Audit::clientTool((int) ($service['id'] ?? 0), $method, $methodName); }); ``` #### Changing the configuration fields filterservice.config_fields `Hook::runRefs` fields and actions Runs after the configuration screen the module offers was built. Parameters 2 $configarrayrefIt carries two keys: `actions` and `fields`. You can add fields, drop them or make them read-only. $moduleServerModuleThe service's module object. Return 1 voidThe values change **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.config_fields', 10, function (&$config, $module) { // Show a dangerous action to nobody but an operator. unset($config['actions']['rebuild']); }); ``` #### Following a service password change actionservice.password_changed `ClientServices` the password is not carried Runs after the service's panel password changed. Parameters 2 $servicearrayThe service whose password changed. $uidintThe account id of the service owner. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.password_changed', 10, function ($service, $uid) { // The new password NEVER reaches the hook; report only that it changed. Notify::securityEvent($uid, 'service-password', (int) ($service['id'] ?? 0)); }); ``` #### Following a server change actionservice.server_changed `AdminServices` after the move Runs after a service is assigned to a different server. The record moved, but **nobody moved the data**: this hook is where you start that. Parameters 4 $idintThe id of the service. $currentServerIdintThe previous server; a **zero** means it had none. $new_server_idintThe new server; a **zero** means it was taken off one. $newServerTypestringThe module type now set on the service. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.server_changed', 10, function ($id, $currentServerId, $new_server_id, $newServerType) { // The record moved, the data did not: start the migration yourself. if ($currentServerId && $new_server_id) Acme::queueMigration($id, $currentServerId, $new_server_id); }); ``` ### Pitfalls > **There are two gates and neither covers the other** > > Calls from the panel pass one gate and calls from the customer panel pass **another**. A rule bound to only one **can be walked around** by the other path. Where your rule holds for both sides, bind to both. > **The module event carries one array** > > Where other hooks hand you separate parameters, the module-ran hook hands you **one array**. A listener expecting four parameters fails not because the hook throws two, but because it throws **one**. Reading the error field inside it is also the only way to tell success from failure. > **The result filter can write the error too** > > On the result filter **all five arguments** are by reference: you can change the result *and* the error. Clearing the error tells the core "this did not fail"; used carelessly it **hides a real failure** and nobody notices. > **The customer path is narrow already** > > From the customer panel only the methods a module **openly allows** can be called; creating, terminating and suspending never reach there. Check that before adding a rule at the gate: what you mean to block may be closed **already**. ### Related Articles - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - Module Lifecycle Hooks - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) ## Service Cancellation Hooks https://dev.wisecp.com/en/service-cancellation-hooks The seven hooks running where a customer says "I no longer want this": the request, the approval, taking it back, and the cancellation itself. ### Overview Cancelling has **two layers**, and mixing them up puts your code in the wrong place. The upper layer is **the request**: a customer asks, an operator approves, or the customer takes it back. The lower layer is **the work**: the service is truly closed. The request layer keeps a record and talks to the customer. The work layer runs from a scheduled task and reaches the provider. A service can also be cancelled **with no request at all**: an unpaid invoice does that on its own. ### Reference #### Stopping a cancellation request gateservice.cancellation_request `ClientServices` the customer asks Runs before the customer's cancellation request is created. Stopping it means **no request is recorded**. Parameters 3 $servicearrayThe customer's own service. Domains do not pass this flow. $urgencystringThe timing: `now`, or `period-ending` at the end of the term. Two very different things: one closes the service today, the other waits out a paid term. $reasonKeystringThe reason key: `not-needed`, `too-expensive`, `switching`, `missing-features`, `other`. Return 1 stringA non-empty string **stops** the request; it reaches the customer as the error. Listener PHP ```php Hook::add('gate:service.cancellation_request', 10, function ($service, $urgency, $reasonKey) { // A service under contract does not close before its term ends. if ($urgency === 'now' && Acme::underContract($service)) return 'No immediate cancellation while the contract runs.'; return null; }); ``` #### Following a cancellation request actionservice.cancellation_requested `ClientServices` it hands you a record id Runs after the request was recorded. The service is **still running** at this point. Parameters 5 $servicearrayThe service record. $urgencystringThe timing: `now`, or `period-ending` at the end of the term. Two very different things: one closes the service today, the other waits out a paid term. $reasonKeystringThe reason key: `not-needed`, `too-expensive`, `switching`, `missing-features`, `other`. $reasonstringThe stored reason text: the label in the customer's language plus any free note. On `other` only the free text arrives. $eventIdintThe id of the created request record. The approve and revoke hooks come back to the same one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.cancellation_requested', 10, function ($service, $urgency, $reasonKey, $reason, $eventId) { // The service is STILL running: this is an intent, not a closing. Retention::opened($eventId, $reasonKey, $urgency); }); ``` #### Stopping a cancellation approval gateservice.cancellation_accept `AdminServices` the operator approves Runs before the operator approves a cancellation request. Parameters 2 $service_idintThe id of the service to be cancelled. An **id** arrives, not the service record. $requestarrayThe request record: `id`, `owner_id` and the decoded `data` (timing, reason). Return 1 stringA non-empty string **stops** the approval; it reaches the operator as the error. Listener PHP ```php Hook::add('gate:service.cancellation_accept', 10, function ($service_id, $request) { // Cancelling with an unpaid invoice open loses the money owed. if (Acme::hasUnpaid($service_id)) return 'Settle the unpaid invoice first.'; return null; }); ``` #### Following a cancellation approval actionservice.cancellation.accepted `AdminServices` approved Runs after the operator approved the request. Parameters 3 $serviceIdintThe service id. $cancellationTypestringThe kind: now, or at the end of the term. Where the term end was chosen the service **does not close today**. $requestDataarrayThe request data: the reason and who approved it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.cancellation.accepted', 10, function ($serviceId, $cancellationType, $requestData) { // On a term-end cancellation nothing closes today; hold your counter. if ($cancellationType === 'now') Capacity::freed($serviceId); }); ``` #### Following a request taken back actionservice.cancellation_revoked `ClientServices` the customer changed their mind Runs where the customer took their cancellation request back. Parameters 3 $servicearrayThe service record. $eventarrayThe request record **as it was before** deletion, holding the timing, the reason key, the reason text and any note. The record is gone, so read it here. $uidintThe account id of the service owner. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.cancellation_revoked', 10, function ($service, $event, $uid) { // Taking it back is a retention win: close the record. Retention::closed((int) ($event['id'] ?? 0), 'revoked'); }); ``` #### Stopping the cancellation work gateservice.cancel `cronjobs/ServiceCancel` a veto still cancels Runs before a service or add-on is truly cancelled. This gate sits **below** the request layer. Parameters 4 $target_typestring`service` or `addon`. $target_idintThe id of what is being cancelled. $servicearrayThe live row: `status`, `type`, `module`, `duedate`. $user_idintThe service owner. Return 1 stringA non-empty string **vetoes** the cancel — and the job still turns into a **cancel signal**, with your reason recorded. Same behaviour as the suspend gate: a veto is not "nothing happens". Listener PHP ```php Hook::add('gate:service.cancel', 10, function ($target_type, $target_id, $service, $user_id) { // With data still moving you may want the cancel held. if (Acme::migrationRunning($target_id)) return 'A migration is running.'; return null; }); ``` #### Following an add-on cancellation request actionservice.addon.cancellation_requested `ClientServices` at period end Runs when a customer asks for an add-on to end at the close of its period. The request is recorded but **the add-on is still live** and keeps working until the due date. Parameters 5 $servicearrayThe service the add-on belongs to. $addonarrayThe add-on being cancelled. At this moment the row is still active; the cancellation lands on the due date. $reasonstringWhat the customer wrote. The field is optional, so it **can arrive empty**. It is cut at 300 characters. $eventIdintThe id of the cancellation request record. $uidintThe account that owns the add-on. A sub-user may have done the clicking; that is a different id. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.addon.cancellation_requested', 10, function ($service, $addon, $reason, $eventId, $uid) { // Start the win-back offer: the add-on still runs, there is time. Acme::offerRetention($uid, $addon['addon_name'] ?? '', $reason); }); ``` #### Following a cancellation being taken back actionservice.addon.cancellation_revoked `ClientServices` changed their mind Runs when a customer takes back a cancellation request. This is where you stop the win-back flow you started. Parameters 4 $servicearrayThe service the add-on belongs to. $addonarrayThe add-on whose cancellation was taken back. $eventarrayThe **deleted** request record, reason and approval included. By the time the hook runs that row is gone from the database; this is the last copy of it. $uidintThe account that owns the service. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.addon.cancellation_revoked', 10, function ($service, $addon, $event, $uid) { Acme::stopRetention($uid, $addon['addon_name'] ?? ''); }); ``` #### Following a scheduled downgrade being cancelled actionservice.scheduled_downgrade_cancelled `ClientServices` changed their mind Runs when a downgrade set for the due date is called off. The service carries on with the package it has. Parameters 3 $servicearrayThe service record. The package **did not change**: the downgrade was planned, never applied. $scheduledarrayThe cancelled plan, **as it was before the update**. Its status field still holds the old value. $uidintThe account that owns the service. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.scheduled_downgrade_cancelled', 10, function ($service, $scheduled, $uid) { // Take the downgrade back out of your capacity forecast. Acme::releaseForecast((int) ($service['id'] ?? 0)); }); ``` ### Pitfalls > **A request and a cancellation are not the same** > > The request hooks run where a customer **asks**; the service is still up. The real closing happens on the cancel hooks below. Freeing capacity, deleting data or raising a final invoice from the request hook leaves damage you cannot undo **where the customer takes it back**. > **A term-end cancellation closes nothing today** > > The kind on the approval hook is either **now** or **the end of the term**. On the second the service runs to the end of the paid period. A listener assuming "cancelled" without reading it counts a service the customer is still using as closed. > **A cancel veto does not prevent the cancel** > > Returning text at the cancel gate does not stop the work; the signal **still turns into a cancel** and only your reason is recorded. The same trap as the suspend gate. To truly prevent it, act on **the request layer**. > **Not every cancellation comes from a request** > > An unpaid invoice takes a service to cancellation **with no request**. A system listening only to the request hooks never sees those. To see every cancellation, listen to the status hook on the layer below. ### Related Articles - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - Invoice and Payment Hooks - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) ## Service Renewal Hooks https://dev.wisecp.com/en/service-renewal-hooks The eight hooks extending a service and changing its plan: renewals, auto-renewal, upgrades and downgrades. ### Overview A renewal arrives by **three separate paths**: the customer starts one by hand, auto-renewal charges a stored card, or a paid invoice triggers it. All three meet at the same event hook. A plan change is not one step. The request is taken, a **flow** is picked by the payment situation (an invoice was raised, it was scheduled for the term end, or it went straight to the queue), and the change lands only at the end. ### Reference #### Stopping a manual renewal gateservice.manual_renew `ClientServices` the manual path only Runs where a customer starts a renewal by hand, before the renewal engine is **called at all**. Parameters 2 $servicearrayThe service being renewed. $renew_optsarrayThe options going to the engine: `source` (here `manual`), plus the add-on discovery, metric and notification flags. This gate sees the manual path alone; auto-renewal and the invoice path do not pass here. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and shown to the customer. Listener PHP ```php Hook::add('gate:service.manual_renew', 10, function ($service, $renew_opts) { // Renewing a suspended service spends money for nothing; open it first. if (($service['status'] ?? '') === 'suspended') return 'A suspended service wants activating first.'; return null; }); ``` #### Learning that a renewal landed actionservice.renewed `Services::process_renewal()` the record holds the OLD date Runs after the date was extended. **All three renewal paths** arrive here. Parameters 4 $serviceIdintThe id of the renewed service. $servicearrayA snapshot from **before** the extension. The date, term and amount are still the **old** ones; do not read the new date from here. $newDuedatestringThe new date that was written. It never moves backwards: the **later** of the candidate and the current one is kept. $oldDuedatestringThe date before the extension. The same as the one on the record, passed separately for an easy comparison. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.renewed', 10, function ($serviceId, $service, $newDuedate, $oldDuedate) { // Did the date REALLY move? The same one can be written again. if ($newDuedate === $oldDuedate) return; Crm::renewed($serviceId, $oldDuedate, $newDuedate); }); ``` #### Stopping the auto-renewal switch gateservice.autorenew_toggle `ClientServices` the state being asked for Runs while a customer switches auto-renewal on or off, with the value **not written yet**. Parameters 3 $servicearrayThe service record. Its auto-renewal field still holds the **old** value. $enableboolThe **new** state being asked for. $uidintThe service **owner**. A sub-user may be the one acting; that id does not arrive here. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and shown to the customer. Listener PHP ```php Hook::add('gate:service.autorenew_toggle', 10, function ($service, $enable, $uid) { // Switching it off lets the service lapse quietly: hold it while money is owed. if (!$enable && Acme::hasDebt($uid)) return 'Auto-renewal stays on while a balance is owed.'; return null; }); ``` #### Following the auto-renewal switch actionservice.autorenew_changed `ClientServices` the record holds the OLD value Runs after the value was written. Parameters 2 $servicearrayThe service record, read **before** the change. Its auto-renewal field still holds the old value; the new state is the second parameter. $enableboolThe **new** state that was written. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.autorenew_changed', 10, function ($service, $enable) { // The new state is the SECOND parameter; the field on the record is old. if (!$enable) Retention::flag((int) ($service['id'] ?? 0), 'autorenew-off'); }); ``` #### Stopping a plan change gateservice.upgrade `Services` up and down Runs before a plan change starts. **Both** upgrades and downgrades pass here. Parameters 5 $servicearrayThe current service row. $old_pidintThe id of the current product. $product_idintThe id of the target product. $price_dataarrayThe resolved target price row. $isUpbool`true` for an upgrade, `false` for a downgrade. Read the direction here rather than working it out from the product ids. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and shown to the customer. Listener PHP ```php Hook::add('gate:service.upgrade', 10, function ($service, $old_pid, $product_id, $price_data, $isUp) { // A downgrade can lose data: stop an account already over the target. if (!$isUp && Acme::usageAbovePlan($service, $product_id)) return 'Your current usage does not fit the target plan.'; return null; }); ``` #### Following a plan change request actionservice.plan_change_requested `ClientServices` three separate flows Runs after the request was taken. The flow value says **when the change lands**. Parameters 6 $servicearrayThe service record, still on the **old** plan. $old_pidintThe id of the old product. $new_pidintThe id of the wanted product. $flowstringThe resulting flow: `invoice_unpaid` (an invoice was raised and is **unpaid**), `scheduled` (set for the term end), `queued` (free of charge, straight to the queue). On all three the change has **not landed yet**. $updown_idintThe id of the created plan-change record. $invoice_idintThe id of the raised invoice, or `0` where there is none. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.plan_change_requested', 10, function ($service, $old_pid, $new_pid, $flow, $updown_id, $invoice_id) { // The change has NOT landed; the flow says when it will. if ($flow === 'invoice_unpaid') Crm::awaitPayment($invoice_id, $updown_id); }); ``` #### Learning that a plan change landed actionservice.updowngrade.applied `Services` one array parameter Runs after the plan change was truly applied. Parameters 1 $payloadarrayA single array: `service_id`, `old_service`, `new_product`, `type` (`upgrade` or `downgrade`), `needs_recreate`, `params`. A true `needs_recreate` means the service will be **rebuilt** on the server. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.updowngrade.applied', 10, function ($payload) { // A rebuild means downtime for a while: tell the customer. if (!empty($payload['needs_recreate'])) Notify::planRebuild((int) ($payload['service_id'] ?? 0)); }); ``` #### Changing the plans on offer filterservice.upgrade_products `Hook::runRefs` by reference Runs after the upgrade options shown to the customer were built. Parameters 1 $productsarrayrefThe products on offer. Dropping one hides it on the screen; using this rather than the gate keeps the customer from seeing **an option they cannot take**. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.upgrade_products', 10, function (&$products) { // Never show a plan you would refuse at the gate. $products = array_values(array_filter($products, fn ($p) => Acme::sellable((int) ($p['id'] ?? 0)))); }); ``` #### Following an add-on renewal actionservice.addon.renewed `handle_extend_addon_paid` after payment Runs after an add-on gets a longer term. Payment has landed and the new due date is written. Parameters 5 $addonIdintThe id of the add-on record. $addonarrayThe add-on row **as it was before the extension**. Its due date and amount hold the old values; take the new ones from the separate parameters. $servicearrayThe parent service record. $newDuedatestringThe new due date that was written. $oldDuedatestringThe due date before the extension. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.addon.renewed', 10, function ($addonId, $addon, $service, $newDuedate, $oldDuedate) { // Move the entitlement on the outside licence to the new due date. Acme::extendEntitlement($addonId, $newDuedate); }); ``` #### Following the repeat count running out actionservice.recurring_completed `generate_renewal` final cycle Runs when a product reaches its set number of repeats and the **last** renewal invoice is made. Nothing renews automatically after this. Parameters 4 $service_idintThe id of the service that ran out. $servicearrayThe service record. $countintHow many renewals were made for this service. $limitintThe repeat limit set on the product. Return 1 voidThe return is ignored. The invoice already exists and cannot be stopped here. Listener PHP ```php Hook::add('action:service.recurring_completed', 10, function ($service_id, $service, $count, $limit) { // Catch the final cycle: send the customer an offer to carry on. Acme::offerContinuation((int) ($service['owner_id'] ?? 0), $service_id); }); ``` #### Changing the auto-payment answer filterservice.auto_pay_source_available `ClientAutoRenewGuard` passed by link Runs before the question "can the renewal job charge this account without the customer present?" is answered. Declare a source the core does not know about here. Parameters 2 $availableboolby linkWhat the core decided. You may write over it. $ctxarrayContext: `owner_id`, the account being asked about. Return 1 voidThe return is ignored; you change the value in place. Saying yes grants permission to **try**. If your source cannot actually charge, the renewal fails and the service is suspended. Listener PHP ```php Hook::add('filter:service.auto_pay_source_available', 10, function (&$available, $ctx) { if ($available) return; // the core already found a source // Declare the mandate you keep yourself. $available = Acme::hasMandate((int) ($ctx['owner_id'] ?? 0)); }); ``` ### Pitfalls > **On the renewal event the record holds the OLD date** > > The service in the second parameter is a snapshot from **before** the extension: its date, term and amount are the old ones. The new date is **the third parameter**. Reading the record and concluding "not renewed" comes from here. The date also never moves backwards, so old and new can be **the same**. > **A plan change request is not the change** > > On the request hook the change has **not landed**. The flow can say one of three things: an invoice was raised and is unpaid, it was set for the term end, or it went straight to the queue. To act on the new plan, wait for the **applied** hook. > **On the switch hooks the record shows the old value** > > Auto-renewal hands you the service record from **before the change** at the gate and at the event alike. The new state is **a separate parameter** in both. A listener reading the field on the record mistakes a switch-on for a switch-off. > **Drop a plan you would refuse** > > The upgrade gate refuses an option **after the customer picked it**. Writing the same rule into the list filter keeps it off the screen, so nobody tries in vain. Keep the gate for safety and use the filter for **courtesy**. ### Related Articles - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - Invoice and Payment Hooks - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) ## Service Transfer Hooks https://dev.wisecp.com/en/service-transfer-hooks The seven hooks moving a service to a new owner: the request, the recipient's approval, a refusal, and calling it off. ### Overview A transfer wants **consent from both sides**: the current owner starts it and the recipient approves. A pending record sits between them and either side can call it off. These hooks **do not tell types apart**: hosting, servers and domains all pass the same flow. Writing a rule meant for domains, check the service type yourself. ### Reference #### Stopping a transfer request gateservice.transfer_request `ClientServices` domains pass here too Runs where the current owner starts the transfer, before the pending record is written. Parameters 3 $servicearrayThe service about to move. No type filtering happens: the `type` field can be `domain` too. $from_idintThe current owner. The account starting the transfer. $to_idintThe target owner. Resolved from an e-mail address and **confirmed** to be a registered customer. Return 1 stringA non-empty string **stops** the transfer; the text is thrown as the error and shown to the customer. Listener PHP ```php Hook::add('gate:service.transfer_request', 10, function ($service, $from_id, $to_id) { // A free developer licence does not move: put your own rule here. if (Acme::nonTransferable($service)) return 'This service cannot move to another account.'; return null; }); ``` #### Following a transfer request actionservice.transfer.requested `ClientServices` the payload, not the record Runs after the pending transfer record was created. The service is still with **the old owner**. Parameters 3 $servicearrayThe service about to move, still with its current owner. $dataarrayThe transfer **payload**: the service name, the sending and receiving sides, the verification token. The parameter in the same slot on the sibling hooks is **the record row itself**; this one is the data inside it. Mixing them up reads empty fields. $uidintThe id of the owner starting the transfer. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.transfer.requested', 10, function ($service, $data, $uid) { // $data IS THE PAYLOAD (not the event row): read its fields directly. Audit::transferOpened((int) ($service['id'] ?? 0), (int) ($data['from_id'] ?? 0), (int) ($data['to_id'] ?? 0)); }); ``` #### Stopping a transfer approval gateservice.transfer_approve `ClientServices` the recipient approves Runs where the recipient accepts, before ownership moves. Parameters 3 $servicearrayThe service record about to move. $from_idintThe current owner. $to_idintThe target owner. Return 1 stringA non-empty string **stops** the transfer; the text is thrown as the error and shown to the customer. Listener PHP ```php Hook::add('gate:service.transfer_approve', 10, function ($service, $from_id, $to_id) { // Check the recipient too: no service moves to an unverified account. if (!Acme::verifiedAccount($to_id)) return 'The receiving account is not verified.'; return null; }); ``` #### Learning that a transfer completed actionservice.transfer.approved `ClientServices` ownership moved Runs after ownership moved to the new account. Parameters 4 $serviceIdintThe id of the moved service. $fromIdintThe previous owner. $toIdintThe new owner. $newOwnerarrayThe new owner's record: `id`, `full_name`, `email`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.transfer.approved', 10, function ($serviceId, $fromId, $toId, $newOwner) { // Cut the old owner off: the service is no longer theirs. Acme::revokeAccess($fromId, $serviceId); Acme::grantAccess($toId, $serviceId); }); ``` #### Following a refused transfer actionservice.transfer.rejected `ClientServices` no service record arrives Runs where the recipient refused the transfer. Parameters 2 $meintThe account id of the recipient who refused. $dataarrayThe transfer data: `service_id`, `service_name`, the sending and receiving sides. This hook hands you **no service record**; take the id from here where you need it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.transfer.rejected', 10, function ($me, $data) { // No service record arrives: take the id from the payload. Crm::transferRefused((int) ($data['service_id'] ?? 0), (int) ($data['from_id'] ?? 0)); }); ``` #### Following a transfer called off actionservice.transfer.cancelled `ClientServices` the sender called it off Runs where the current owner called off a pending transfer. Parameters 2 $servicearrayThe service record, still with its current owner. $pendingarrayThe cancelled pending **record row**, holding the recipient details and the verification token. The parameter in the same slot on the request hook was **the payload**; this one is the row, with the data inside it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.transfer.cancelled', 10, function ($service, $pending) { // Here $pending IS THE RECORD ROW: the payload sits one level in. $payload = $pending['data'] ?? []; Audit::transferClosed((int) ($service['id'] ?? 0), (int) ($payload['to_id'] ?? 0)); }); ``` #### Following an invitation sent again actionservice.transfer.resent `ClientServices` the same record Runs where the owner sent the transfer invitation again. The pending record **does not change**; only the message is repeated. Parameters 2 $servicearrayThe service record. $pendingarrayThe pending transfer record. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.transfer.resent', 10, function ($service, $pending) { // It repeats: hold back the resends on your own side. Acme::countResend((int) ($service['id'] ?? 0)); }); ``` ### Pitfalls > **The same slot carries two different things** > > On the transfer request the second parameter is **the payload**; on the called-off and resent hooks the same slot carries **the record row**, with the payload one level in. Taking one for the other reads **empty fields**: no error, no value. > **Domains pass this flow too** > > The transfer hooks **do not look at** the service type. A rule you wrote for hosting also lands on domain transfers. Targeting one type, check the type field on the service record **in your first line**. > **The refusal hook hands you no service record** > > The refusal hook hands you the refusing account and the transfer data alone; there is **no service record**. A listener used to the other transfer hooks looks for the service name and finds nothing. Take the id from **the payload** and read the record yourself. > **Two gates guard two different sides** > > The request gate guards the **sending** side and the approval gate the **receiving** one. "This service cannot move" belongs at the request gate; "this account cannot receive" belongs at the approval gate. Writing both in one place leaves the other end open. ### Related Articles - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) ## Licence Transfer Hooks https://dev.wisecp.com/en/licence-transfer-hooks The seven hooks moving a software licence to another account and resetting an installation. ### Overview A licence transfer is a **separate flow** from a service transfer. It keeps its own record, charges its own fee, and moves along on **e-mail confirmation from both sides**. Reissuing has nothing to do with transfers: the installation details a licence is tied to are cleared, so the customer can set the software up on **another server**. ### Reference #### Stopping a licence transfer gateservice.license_transfer `LicenseTransfer` the product config arrives Runs before a licence transfer starts. Parameters 2 $servicearrayThe service record about to move, with its options decoded. $lt_configarrayThe product's licence transfer settings: the fee type, which side is invoiced, the limits. Read this when writing your own rule; the operator may have set each product differently. Return 1 stringA non-empty string **stops** the transfer; the text is thrown as the error. Listener PHP ```php Hook::add('gate:service.license_transfer', 10, function ($service, $lt_config) { // A developer licence given free of charge does not move. if (!empty($service['options']['developer'])) return 'A developer licence cannot move to another account.'; return null; }); ``` #### Changing the transfer fee filterservice.license_transfer_fee `Hook::runRefs` by reference Runs after the transfer fee was worked out, before the invoice is raised. Parameters 3 $feearrayrefThe fee as worked out: `type`, `amount`, `currency_id`, `basis`, `base_amount`, `base_currency_id`. For the percentage type the base is the product's current one-time price (`basis` = `product_price`); it falls back to the service amount only when the product has no active one-time price. Zeroing the amount makes the transfer free. $servicearrayThe service moving, with its amount and currency. $lt_configarrayThe product's fee settings. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.license_transfer_fee', 10, function (&$fee, $service, $lt_config) { // Charge nothing where both sides are the same company. if (Acme::sameCompany($service)) $fee['amount'] = 0.0; }); ``` #### Learning that a transfer started actionservice.license_transfer_started `LicenseTransfer` confirmation is pending Runs after the transfer record was created. The licence has **not moved yet**. Parameters 4 $transfer_idintThe id of the created transfer record. $service_idintThe id of the service moving. $transferor_idintThe current owner. $transferee_idintThe target owner. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_transfer_started', 10, function ($transfer_id, $service_id, $transferor_id, $transferee_id) { // The licence has NOT moved: it waits on both sides confirming. Audit::licenseTransfer('started', $transfer_id, $service_id); }); ``` #### Following one side confirming actionlicense.transfer.verified `LicenseTransfer` it runs twice Runs where one side confirmed by e-mail. It runs **twice per transfer**. Parameters 2 $transferarrayThe transfer record as it stands after the confirmation. $partystringThe side that confirmed: `transferor` (the sender) or `transferee` (the recipient). Read which side from here, since the hook runs twice. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:license.transfer.verified', 10, function ($transfer, $party) { // The hook runs TWICE: react to the recipient's confirmation alone. if ($party === 'transferee') Crm::licenseAccepted($transfer['id'] ?? 0); }); ``` #### Learning that a transfer completed actionservice.license_transfer.completed `LicenseTransfer` a freshly read record Runs after the licence moved to its new owner. Parameters 2 $transfer_idintThe id of the transfer record. $transferarrayThe **freshly read** transfer row: its status is complete and the completion time is filled in. The cache was bypassed, so the values are current. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_transfer.completed', 10, function ($transfer_id, $transfer) { // The record was read afresh: status and time are current. Acme::licenseMoved((int) ($transfer['owner_id'] ?? 0), $transfer); }); ``` #### Following a licence reissue actionservice.license_reissued `ClientServices` the installation was cleared Runs after the licence's installation binding was cleared, so the customer can set the software up on another server. Parameters 2 $servicearrayThe service record from **before** the reset. Its installation and address fields still hold the **old** values, which is how you learn the previous server. $uidint**Always the service owner**. Not the person who acted: a sub-user or a staff member may have triggered it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_reissued', 10, function ($service, $uid) { // The old installation is STILL on the record: you see the previous one. Acme::forgetInstall($service['options']['install'] ?? ''); }); ``` #### Following a licence update actionservice.license_updated `AdminServices` the licence fields were saved Runs after an operator saved the service's Management tab and something about the licence changed. Anything holding that licence elsewhere is now stale. Bring your own record up to date here. Watched fields: the key, the domain, the IP, the version and the licence parameters. Nothing fires when none of them moved. The save already happened, so nothing you return is read. Parameters 3 $serviceIdintThe service that was saved. $changesarrayOnly what moved, as `field => ['old' => …, 'new' => …]`. Keys: `key`, `domain`, `ip`, `version`, `parameters`. $servicearrayThe service record from **before** the save. Read it afresh if you need the stored row. Reading `$changes['key']` 3 old emptycreationThe key was issued for the first time. Create your record rather than moving one. both filledmoveFind your record by the old key and put it on the new one. new emptyclearedThe operator emptied the field. The key was already handed out, so deleting the remote record is usually wrong. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_updated', 10, function ($serviceId, $changes, $service) { // The key did not move: something else did, so just republish. if (!isset($changes['key'])) return Acme::republish((int) $serviceId); $old = (string) $changes['key']['old']; $new = (string) $changes['key']['new']; if ($new === '') return; // cleared, not deleted $old === '' ? Acme::register((int) $serviceId, $new) // issued for the first time : Acme::rekey($old, $new); // moved onto a new key }); ``` #### Following a licence report actionservice.license_report_submitted `ClientServices` a customer report Runs where a customer submitted a report about their licence. Parameters 2 $servicearrayThe service the report is about. $uidintThe service owner's id. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_report_submitted', 10, function ($service, $uid) { Ops::queue('license-report', (int) ($service['id'] ?? 0)); }); ``` #### Following a transfer ending actionservice.license_transfer_cancelled `LicenseTransfer` three endings Runs when a licence transfer comes to an end. Three separate endings land here: cancelled, rejected and timed out. Parameters 3 $transfer_idintThe id of the transfer that ended. $statusstringWhich ending: `cancelled`, `rejected` or `expired`. $reasonstringThe reason code, such as an admin cancelling or verification running out of time. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_transfer_cancelled', 10, function ($transfer_id, $status, $reason) { // Lift the transfer lock on the licence server. Acme::unlockTransfer($transfer_id); }); ``` #### Following verification being sent again actionservice.license_transfer.verification_resent `ClientLicenseTransfer` to waiting parties Runs when the transfer verification emails go out again. They reach only the parties who have **not verified yet**; nobody who confirmed is written to twice. Parameters 3 $servicearrayThe record of the service being transferred. The transfer is not done: the service still sits with its current owner. $activearrayThe live transfer record, with its id and status. $pendingarrayThe list of parties the mail went to again. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.license_transfer.verification_resent', 10, function ($service, $active, $pending) { // Nudge over a second channel, only the parties still waiting. foreach ($pending as $party) Acme::remind($party, (int) ($active['id'] ?? 0)); }); ``` #### Stopping a software domain change gateservice.software_domain_change `ClientServices` before the write Runs while a customer changes the domain their licence is bound to, after the core checks pass and before anything is written. Parameters 4 $servicearrayThe service record **as it was before the change**. $oldstringThe domain currently bound; empty when none was ever set. $domainstringThe requested domain, **already normalised**: lower case, leading www dropped. This is not the raw form value; write your check against this. $uidintThe account that owns the service. Return 1 string|null**A non-empty text blocks the change** and is shown to the customer. When blocked, neither the domain is written nor the change hook fires. Listener PHP ```php Hook::add('gate:service.software_domain_change', 10, function ($service, $old, $domain, $uid) { // Apply your own block list. if (Acme::blockedDomain($domain)) return 'This domain cannot hold a licence.'; return null; }); ``` #### Following a software domain change actionservice.software_domain_changed `ClientServices` after the write Runs once the bound domain of a licence is saved. This is where you tell the licence server. Parameters 4 $servicearrayThe service record. $oldstringThe previous domain; empty on a first binding. $domainstringThe newly bound domain. $uidintThe owning account. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.software_domain_changed', 10, function ($service, $old, $domain, $uid) { Acme::rebindLicence((int) ($service['id'] ?? 0), $domain); }); ``` ### Pitfalls > **The confirmation hook runs twice per transfer** > > The sender and the recipient confirm separately, and the hook runs **on both**. A listener reacting without reading the side does its work twice. Check the party value **in your first line**. > **On the reissue hook the installation is the OLD one** > > The hook hands you the service record from **before** the reset: the installation and address fields point at the old server. That is not a fault but **your only chance**: do whatever you need with the old installation here, because the value is about to go. > **The owner and the person acting are not the same** > > The id on the reissue hook is **always the service owner**. A sub-user or a staff member may have triggered it, and that id **never reaches** this hook. "Who did this" cannot be answered from here. > **A licence transfer is not a service transfer** > > The two flows look alike and use **separate hooks**: a licence transfer has a record of its own, a fee of its own and e-mail confirmation from both sides. A system listening to the service transfer hooks **never sees** a licence one. ### Related Articles - [Service Transfer Hooks](https://dev.wisecp.com/en/service-transfer-hooks) - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - Invoice and Payment Hooks ## Service Detail Hooks https://dev.wisecp.com/en/service-detail-hooks The eight hooks behind what a customer sees on a service detail: the page data, the cards, the tools, add-ons and metrics. ### Overview The service detail is the screen a customer looks at most, and much of it comes from **the module**: the cards, the tools, the capabilities. The filters here catch that output before it reaches the screen. The second group is add-ons. An add-on has a **status flow of its own** and moves separately from the parent service: bought on its own, invoiced on its own, suspended on its own. ### Reference #### Changing the detail page data filterservice.detail_data `ClientServices` the whole page Runs after the detail page's whole template data was built. Parameters 2 $dataarrayref**All** the data going to the page. The widest place to step in: add your own key without breaking the ones there. $servicearrayThe service in view. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.detail_data', 10, function (&$data, $service) { // Add your own data without disturbing the keys already there. $data['acme_health'] = Acme::health((int) ($service['id'] ?? 0)); }); ``` #### Changing the dashboard cards filterservice.dashboard.cards `ServerModule` module output Runs after the module's card definitions were built, before they reach the screen. Parameters 2 $cardsarrayrefThe card definitions. You can add, drop or reorder them. $moduleServerModuleThe service's module object. Use it to tell modules apart; a rule may not suit them all. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.dashboard.cards', 10, function (&$cards, $module) { // Put your card where it belongs rather than at the end. array_splice($cards, 1, 0, [Acme::usageCard()]); }); ``` #### Changing the tool list filterservice.dashboard.tools `ServerModule` a grouped list Runs after the module's tool list was built. Parameters 2 $toolsarrayrefThe grouped tool list: each group carries its own details and tools. It is not a flat list; you reach into the group. $moduleServerModuleThe service's module object. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.dashboard.tools', 10, function (&$tools, $module) { // The list IS GROUPED: reach the tools inside a group. foreach ($tools as &$group) $group['tools'] = array_filter($group['tools'] ?? [], fn ($t) => ($t['key'] ?? '') !== 'rebuild'); }); ``` #### Changing the domain capabilities filterservice.detail_tabs_capabilities `ClientDomains` it opens the tabs Runs after it was decided which capabilities the domain detail tabs show. Parameters 3 $capabilitiesarrayrefThe capability marks: DNS records, mail forwarding and the like. Every mark you write here is **carried back** to the screen's matching key: switching one off hides that tab. $servicearrayThe domain service in view. $moduleobject|nullThe provider module — it **can be empty**. On a domain with no module it arrives as `null`, so check before calling anything on it. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:service.detail_tabs_capabilities', 10, function (&$capabilities, $service, $module) { if ($module === null) return; // a domain with no module // Switching a mark off hides that tab on the screen. $capabilities['has_email_forwarding'] = Acme::mailAllowed($service); }); ``` #### Learning that an add-on opened actionservice.addon.created `Services` a separate invoice Runs after an add-on was added to a service. Parameters 5 $addonRecordIdintThe id of the created add-on record. $serviceIdintThe id of the parent service. $addonIdintThe id of the add-on product. `0` means this is a **domain add-on**, which has no entry in the product catalogue. $invoiceIdintThe id of the raised invoice, or `0` where there is none. $totalAmountfloatThe total charged for the add-on. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.addon.created', 10, function ($addonRecordId, $serviceId, $addonId, $invoiceId, $totalAmount) { // An addonId of 0 marks a DOMAIN add-on, not a product. if ($addonId === 0) return; Crm::addonSold($serviceId, $addonId, (float) $totalAmount); }); ``` #### Following an add-on status actionservice.addon.status_changed `Services` one array parameter Runs after an add-on's status changed. Parameters 1 $payloadarrayA single array: `service`, `addon`, `addon_id`, `old_status`, `new_status`, `user_id`. An add-on's status is **independent** of the parent: one can be suspended while the other runs. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.addon.status_changed', 10, function ($payload) { // ONE array arrives; the old and new status sit inside it. if (($payload['new_status'] ?? '') === 'suspended') Acme::addonOff((int) ($payload['addon_id'] ?? 0)); }); ``` #### Stopping a metric switch gateservice.metric_toggle `ClientServices` nothing is written Runs while a customer switches a usage metric on or off. Stopping it means nothing reaches **the database or the module**. Parameters 4 $servicearrayThe service record. $metricKeystringThe metric key. Defined in the product's metric settings. $enableboolThe state being asked for. $labelstringThe metric's display label. Handed to you ready for your error message. Return 1 stringA non-empty string **stops** the switch; the text is thrown as the error. Listener PHP ```php Hook::add('gate:service.metric_toggle', 10, function ($service, $metricKey, $enable, $label) { // The label arrives ready: write the message with the name they see. if (!$enable && Acme::metricRequired($metricKey)) return $label . ' cannot be switched off.'; return null; }); ``` #### Following a metric switch actionservice.metric_toggled `ClientServices` it landed Runs after the metric state was written. Parameters 3 $servicearrayThe service record. $metricKeystringThe metric key. $enableboolThe new state that was written. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.metric_toggled', 10, function ($service, $metricKey, $enable) { // A metric switched off can still be billed: set your own counter. Acme::metricState((int) ($service['id'] ?? 0), $metricKey, (bool) $enable); }); ``` #### Following a customer opening the detail page actionservice.detail.viewed `website/services` on every opening Runs when a customer opens the management page of their own service. Ownership and access checks are already behind you. Parameters 2 $servicearrayThe loaded service record. $serviceIdintThe id of the service. Return 1 voidThe return is ignored. To change what the page shows, use the detail data filter rather than this hook. Listener PHP ```php Hook::add('action:service.detail.viewed', 10, function ($service, $serviceId) { // Keep it light: the customer is waiting for the page. Acme::touchLastSeen($serviceId); }); ``` #### Following an admin opening the detail page actionservice.viewed `admin/services` on every opening Runs when an administrator opens a service detail. It carries the **same data** as its customer-side twin; only the person looking differs. Parameters 2 $servicearrayThe loaded service record. $service_idintThe id of the service. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.viewed', 10, function ($service, $service_id) { // Pull the live status from the remote panel ahead of time. Acme::prefetchStatus($service_id); }); ``` #### Stopping a file download gateservice.file_download `website/services` three flows Runs before a customer downloads a service file. All three flows pass through here: requirement attachment, software package and delivery file. Parameters 2 $kindstringWhich flow: `requirement`, `package` or `delivery`. $ctxarrayContext. Shared: `user_id`, `service_id`. The flow adds file name, version and record id. Return 1 mixed**A filled return blocks the download**: the answer becomes a 403 at once. The trap: this endpoint returns a raw file, so your return **never reaches the customer**. Unlike other gates no reason appears on screen; record it yourself. An empty return lets the download go on. Listener PHP ```php Hook::add('gate:service.file_download', 10, function ($kind, $ctx) { if ($kind !== 'package') return null; // Block when the quota is used up; no reason reaches the screen, so log it. if (Acme::quotaExceeded((int) ($ctx['user_id'] ?? 0))) { Acme::log('download quota used up', $ctx); return true; } return null; }); ``` #### Changing which file is served filterservice.file_download.source `website/services` passed by link Runs before the file is handed to the stream. You can build a package with install-specific keys inside it, or send the download to an address of your own. Parameters 2 $sourcearrayby linkWhat will be served: `path` (full path on disk), `name` (the saved name), `link` (outside address), `cleanup`. $ctxarrayby linkContext: `kind`, `user_id`, `service_id`, the service record and product id. Return 1 voidThe return is ignored; you write over the source. A filled path is sent; an empty path with an outside address redirects there; neither gives a 404. Set `cleanup` and **only the file** is removed afterwards, not the folder above it. Listener PHP ```php Hook::add('filter:service.file_download.source', 10, function (&$source, &$ctx) { if (($ctx['kind'] ?? '') !== 'package') return; // Build a package with the install key inside, then have it removed. $source['path'] = Acme::buildPackage((int) ($ctx['service_id'] ?? 0)); $source['name'] = 'acme-setup.zip'; $source['cleanup'] = true; }); ``` #### Stopping a billing profile assignment gateservice.billing_profile_assign `ClientServiceBilling` before the write Runs while a customer assigns a billing profile to a service, before anything is written. Domains pass through this gate too. Parameters 3 $servicearrayThe service or domain record. $profileIdintThe id of the profile being assigned. A **zero** means the override is being dropped and the account default restored. $uidintThe account that owns both the profile and the service. Return 1 string|null**A non-empty text blocks the assignment** and the text is shown to the customer as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:service.billing_profile_assign', 10, function ($service, $profileId, $uid) { // Do not allow profile changes while an account is frozen. if (Acme::frozen($uid)) return 'Your account is under review, so this cannot change.'; return null; }); ``` #### Following a billing profile assignment actionservice.billing_profile_assigned `ClientServiceBilling` after the write Runs once the assignment is saved. The gate is behind you and the value is written. Parameters 4 $servicearrayThe service or domain record. $profileIdintThe assigned profile; a zero means the default came back. $profileNamestringThe visible name: a company or person, or "Default" when the override was dropped. $uidintThe owning account. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:service.billing_profile_assigned', 10, function ($service, $profileId, $profileName, $uid) { Acme::syncAccounting((int) ($service['id'] ?? 0), $profileId); }); ``` ### Pitfalls > **The tool list arrives grouped** > > The tool filter hands you **a list of groups**, not a flat list, with each group holding its own tools. A listener walking it directly finds no tools, because every element it holds is a **group**. > **The module can be empty on the capability filter** > > On the domain capability filter the third parameter **can be empty**: some domains have no module. Calling a method without checking takes the page down. Every mark you write here is also **carried back** to the screen, so switching one off makes that tab vanish. > **An add-on is independent of its parent** > > An add-on carries its own status, its own invoice and its own suspend flow. The parent can be running while the add-on is suspended, or the other way round. Reading the parent's status and assuming the add-on is open leaves **an unpaid capability** switched on. > **A zero add-on id means a domain** > > Where the product id on the add-on event is `0`, this is a **domain add-on** (DNS management, privacy, forwarding) with no entry in the product catalogue. A listener reaching for a product finds **nothing** here. ### Related Articles - [Service Module Hooks](https://dev.wisecp.com/en/service-module-hooks) - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - [Hooks on the Customer Site](https://dev.wisecp.com/en/hooks-on-the-customer-site) # Hooks / Domains ## Domain Acquisition Hooks https://dev.wisecp.com/en/domain-acquisition-hooks The eight hooks on the path to the domain provider: the gates before a register, transfer or renew call, and the events behind them. ### Overview Getting a domain is a **paid and irreversible** call. Once the provider opened the registration there is no taking it back, so every step of this path has a gate in front of it and an event behind it. Gates run **before** the call and can stop it. Events run **after**, separately for success and for failure. Below is the full contract of all eight: which values arrive, in what order, and what you have to return. ### Reference #### Stopping a register or transfer request gatedomain.create `RegistrarModule::create()` before a paid call Runs immediately before the register or transfer request reaches the provider; the method was checked and the arguments are not built yet. Parameters 3 $servicearrayThe service record being provisioned. Owner, product and term live here. $optionsarrayThe parameters going to the provider: `sld`, `tld`, `year`, `dns`, `whois`, `tcode`. $methodstringThe method about to run: `register` or `transfer`. With a transfer code present it is a transfer. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.create', 10, function ($service, $options, $method) { if ($method === 'transfer') return null; // leave transfers alone $years = (int) ($options['year'] ?? 1); if ($years > 5) return 'We register at most 5 years at a time.'; return null; }); ``` #### Taking the register or transfer result actiondomain.created `RegistrarModule::create():122` once per call Runs right after the provider call returned. It is **no promise of success**: the result value can carry a failure too. Parameters 3 $servicearrayThe service record being provisioned. $resultarray|boolWhat the provider returned: a config and status array, or `false` on failure. This is the value to read first. $methodstringThe method that ran: `register` or `transfer`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.created', 10, function ($service, $result, $method) { if ($result === false) return; // failure has its own hook if ($method === 'register') Dns::applyTemplate($service['name'] ?? ''); }); ``` #### Catching a failed registration actiondomain.register_failed `RegistrarModule::create()` on failure only Runs where the provider call came back a failure, right after the previous hook. Parameters 4 $servicearrayThe service record the provisioning was attempted for. $optionsarrayThe parameters that were sent to the provider. $methodstringThe method that ran. $errorstringThe error message the module reported. It can be empty. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.register_failed', 10, function ($service, $options, $method, $error) { Ops::alert('domain-provision', [ 'name' => $service['name'] ?? '', 'method' => $method, 'error' => $error ?: 'the provider gave no reason', ]); }); ``` #### Stopping a renewal request gatedomain.renew `RegistrarModule::renew():144` before a paid call Runs before the renewal request reaches the provider. Parameters 2 $servicearrayThe service being renewed. Its end date and term are in here. $optionsarrayThe renewal parameters: `sld`, `tld`, `year`. Return 1 stringA non-empty string stops the renewal. This hook is also on the path of overnight jobs — stopping it leaves the domain unrenewed. Listener PHP ```php Hook::add('gate:domain.renew', 10, function ($service, $options) { // Renewing spends money where the customer already asked to cancel. if (Acme::cancelRequested((int) ($service['id'] ?? 0))) return 'A cancellation is open, so the renewal was stopped.'; return null; }); ``` #### Learning that a renewal landed actiondomain.renewed `Invoices::handle_renewal_domain_paid():1040` from the payment path Runs after a paid renewal item extended the end date and the provider was updated. Parameters 1 $servicearrayThe renewed service record. The end date is the **new** one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.renewed', 10, function ($service) { ExternalDns::syncExpiry($service['name'] ?? '', $service['duedate'] ?? ''); }); ``` #### Catching a failed renewal actiondomain.renew_failed `RegistrarModule::renew()` expiry at risk Runs where the provider's renewal call came back a failure. Parameters 3 $servicearrayThe service the renewal was attempted for. $optionsarrayThe renewal parameters. $errorstringThe error message the module reported; it can be empty. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.renew_failed', 10, function ($service, $options, $error) { // The customer paid and the domain did not renew: this wants a person. Ops::page('domain-renew-failed', $service['name'] ?? '', $error); }); ``` #### Following a transfer through actiondomain.transfer.completed `Services::check_transfer_status():686` the service is live now Runs after the provider reported the transfer done, the service went live and word was sent. Parameters 1 $servicearrayThe service record **read afresh** after activation; its start and end dates are current. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.transfer.completed', 10, function ($service) { ExternalDns::provision($service['name'] ?? ''); }); ``` #### Catching a failed transfer check actiondomain.transfer.failed `Services::check_transfer_status():659` the check repeats Runs where the provider's transfer status check failed, right before the error is thrown. Parameters 1 $servicearrayThe domain service whose transfer check failed. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.transfer.failed', 10, function ($service) { // The check repeats on a schedule: alert on a streak, not on one failure. if (Acme::failStreak((int) ($service['id'] ?? 0)) >= 3) Ops::alert('domain-transfer-stuck', $service['name'] ?? ''); }); ``` #### Following the transfer code being saved actiondomain.transfer_authcode_saved `ClientDomains` secret value Runs after a customer saves the authorisation code for an incoming transfer. This is where you tell the outside system that drives the transfer. Parameters 2 $servicearrayThe domain record. $codestringThe saved authorisation code, **in the clear**. Whoever holds this code can move the domain elsewhere: keep it out of your own records and out of your logs. Return 1 voidThe return is ignored. The code is already saved. Listener PHP ```php Hook::add('action:domain.transfer_authcode_saved', 10, function ($service, $code) { // Carry the fact that a code arrived, NOT the code itself. Acme::transferReady((int) ($service['id'] ?? 0), $service['name'] ?? ''); }); ``` ### Pitfalls > **The result hook is no promise of success** > > The register hook runs when the provider call **returned**, not when it succeeded. Where the second parameter is `false` the work **did not land**. Setting up DNS or telling the customer "your domain is ready" without checking treats a record that does not exist as real. > **The renewal gate is also on the overnight path** > > Renewals do not run from an operator's hand alone: the post-payment flow and scheduled jobs pass the same gate. A condition you put there can leave a domain **unrenewed** with nobody noticing. Record it somewhere when you stop the gate; a silent veto is the costliest kind. > **The transfer check repeats** > > The transfer status is asked on a schedule, and every failed check runs the failure hook **again**. A listener that alerts on each call produces dozens of messages for one pending transfer. Count the streak rather than reacting to a single failure. > **The gate is the last stop before a paid call** > > The register and renew gates are the **last** point before money is spent. Fraud checks, quota limits and premium confirmations belong here. The same check made after the call does not bring the money back. ### Related Articles - [Service Lifecycle Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - Invoice and Payment Hooks - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Domain DNS Hooks https://dev.wisecp.com/en/domain-dns-hooks The ten hooks setting where a domain points: name servers, DNS records, DNSSEC and child name servers. ### Overview What these four surfaces share: they all work **live**. Reads and writes alike send a real request to the provider, and our record is only a mirror. Learn the pattern once and all four follow it: a gate in front of a write, an event behind it, and on the read side a filter handing you the list by reference. ### Reference #### Stopping a name server write gatedomain.nameservers_save `AdminServices::save_nameservers()` `ClientDomains:197` from both paths Runs before the name server list is written to the provider, with the input already checked. **Both** paths, panel and customer panel, pass through here. Parameters 2 $servicearrayThe domain service record. $dnsarrayThe name server list about to be written. The non-empty `ns1..ns4` values, in order. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.nameservers_save', 10, function ($service, $dns) { // Changing name servers mid-transfer breaks the transfer. if (($service['status'] ?? '') === 'transfer') return 'Name servers cannot change while a transfer runs.'; return null; }); ``` #### Following a name server change actiondomain.nameservers_saved `AdminServices::save_nameservers()` on success only Runs after the provider call succeeded, the values landed on the service and the activity was recorded. Parameters 2 $servicearrayThe domain service record. $dnsarrayThe list that was written to the provider. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.nameservers_saved', 10, function ($service, $dns) { DnsMonitor::resync($service['name'] ?? '', $dns); }); ``` #### Stopping a DNS record change gatedomain.dns_record_save `AdminServices::save_dns_record()` `AdminServices::delete_dns_record()` three actions, one hook Runs before a DNS record changes at the provider. Adding, updating and deleting **all three** pass here, and the third parameter says which. Parameters 3 $servicearrayThe domain service record. $recordarrayThe record being worked on: `type`, `name`, `value`, `identity`. On an update also `ttl` and `priority`. $taskstringThe action: `create`, `update` or `delete`. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.dns_record_save', 10, function ($service, $record, $task) { $type = strtoupper((string) ($record['type'] ?? '')); // Stop a delete that cuts the mail flow; adds and updates carry on. if ($task === 'delete' && $type === 'MX') return 'An MX record cannot be deleted; change the mail settings first.'; return null; }); ``` #### Following a DNS record change actiondomain.dns_record_saved `AdminServices::save_dns_record()` on success only Runs after the change at the provider finished successfully. Parameters 3 $servicearrayThe domain service record. $recordarrayThe record that was worked on. $taskstringThe action that ran. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.dns_record_saved', 10, function ($service, $record, $task) { Audit::dns($service['name'] ?? '', $task, $record); }); ``` #### Stopping a DNSSEC record action gatedomain.dnssec_save `AdminServices` create and delete Runs before a DS record is created or deleted at the provider. Parameters 3 $servicearrayThe domain service record. $recordarrayThe DS record: `digest`, `key_tag`, `digest_type`, `algorithm`. On a delete also `identity`. $verbstring`create` or `delete`. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.dnssec_save', 10, function ($service, $record, $verb) { // Deleting the last DS record switches validation off entirely. if ($verb === 'delete' && Acme::dsCount((int) ($service['id'] ?? 0)) <= 1) return 'The last DNSSEC record cannot be deleted.'; return null; }); ``` #### Following a DNSSEC change actiondomain.dnssec_saved `AdminServices` on success only Runs after the DS record landed at the provider. Parameters 3 $servicearrayThe domain service record. $recordarrayThe DS record that was handled. $verbstringThe action that ran. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.dnssec_saved', 10, function ($service, $record, $verb) { Audit::dnssec($service['name'] ?? '', $verb, $record['key_tag'] ?? ''); }); ``` #### Stopping a child name server action gatedomain.child_ns_save `ClientDomains` create and delete Runs before a child name server is added at the provider or removed from it. Parameters 4 $servicearrayThe domain service record. $hoststringThe fully qualified name, such as `ns1.example.com`. $ipstringThe address behind the name. It can arrive empty on a delete. $verbstring`create` or `delete`. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.child_ns_save', 10, function ($service, $host, $ip, $verb) { if ($verb === 'delete') return null; // Do not glue a name to an address outside your own block. if (!Acme::ownsIp($ip)) return 'That address is not ours.'; return null; }); ``` #### Following a child name server change actiondomain.child_ns_saved `ClientDomains` on success only Runs after the child name server landed at the provider. Parameters 4 $servicearrayThe domain service record. $hoststringThe name that was handled. $ipstringThe address behind it. $verbstringThe action that ran. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.child_ns_saved', 10, function ($service, $host, $ip, $verb) { Ipam::glue($verb, $host, $ip); }); ``` #### Changing the child name server list filterdomain.child_ns_list `Hook::runRefs` by reference Runs after the list read from the provider was normalised, before it reaches the screen. Parameters 2 $listarrayrefThe normalised list: every row carries `ns` and `ip`. Keep the shape; the screen draws to this contract. $servicearrayThe domain service record. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.child_ns_list', 10, function (&$list, $service) { // Sort without breaking the shape: the screen wants ns and ip keys. usort($list, fn ($a, $b) => strcmp($a['ns'] ?? '', $b['ns'] ?? '')); }); ``` #### Changing the DNSSEC list filterdomain.dnssec_records `Hook::runRefs` by reference Runs after the DS records read from the provider were normalised. Parameters 2 $recordsarrayrefThe normalised records: `identity`, `digest`, `key_tag`, `digest_type`, `algorithm`. A delete sends `identity` back; do not drop it. $servicearrayThe domain service record. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.dnssec_records', 10, function (&$records, $service) { // KEEP the identity field: a delete targets the record with it. $records = array_values(array_filter($records, fn ($r) => (int) ($r['algorithm'] ?? 0) !== 5)); // hide the old algorithm }); ``` ### Pitfalls > **One gate carries three actions** > > The DNS record gate is the **same** hook for adding, updating and deleting. Without reading the third parameter, a rule you wrote to "stop deletes" stops adds as well. The same holds for the DNSSEC and child name server gates, where the action sits in the fourth parameter. > **Keep the shape in list filters** > > Read filters hand you the array the screen **draws directly**. Renaming a key or dropping `identity` breaks more than the list: it leaves **the delete call** without a target, because the record is found by that field. > **The gate is on both paths** > > The name server gate sits on the panel path and the customer path alike. A rule written as "the customer should not" stops **the operator** too. To tell the paths apart, look at the calling context rather than at the service record. > **Propagation is not instant** > > The event says the provider **accepted** the change, not that the world sees it. Propagation takes minutes. A listener acting on the new value gets **the old answer** where it checks straight away. ### Related Articles - [Domain Acquisition Hooks](https://dev.wisecp.com/en/domain-acquisition-hooks) - Domain Hooks - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Domain Contact Hooks https://dev.wisecp.com/en/domain-contact-hooks The ten hooks over the person behind a domain, its privacy and its transfer lock. ### Overview These three go together. Contact details are what the registry **requires**, privacy hides them from outside, and the transfer lock stops the name moving without permission. All three are written live to the provider. Saved contact profiles, on the other hand, live **on our side**: they are templates kept so a customer types the same details once, and they carry hooks of their own. ### Reference #### Stopping a contact write gatedomain.contacts_save `AdminServices` all four roles Runs before the contact details are written to the provider. Parameters 2 $servicearrayThe domain service record. $whoisarrayThe four roles: `registrant`, `administrative`, `technical`, `billing`. Each role carries name, surname, e-mail, phone and address fields. Return 1 stringA non-empty string **stops** the action and the text is thrown as the error. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.contacts_save', 10, function ($service, $whois) { // The registrant e-mail cannot change before it is verified. $mail = (string) ($whois['registrant']['EMail'] ?? ''); if ($mail !== '' && !Acme::verified($mail)) return 'The registrant e-mail wants verifying first.'; return null; }); ``` #### Following a contact change actiondomain.contacts_saved `AdminServices` on success only Runs after the contact details reached the provider. Parameters 2 $servicearrayThe domain service record. $whoisarrayThe four roles as written to the provider. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.contacts_saved', 10, function ($service, $whois) { Audit::whois($service['name'] ?? '', $whois['registrant']['EMail'] ?? ''); }); ``` #### Stopping a privacy change gatedomain.privacy_change `AdminServices` on and off Runs before privacy changes at the provider. Parameters 2 $servicearrayThe domain service record. $statusstringThe state being asked for: `enable` or `disable`. What was asked, not what it became. Return 1 stringA non-empty string **stops** the action and the text is thrown as the error. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.privacy_change', 10, function ($service, $status) { // Some extensions offer no privacy at all. if ($status === 'enable' && Acme::noPrivacyTld($service['name'] ?? '')) return 'This extension offers no privacy.'; return null; }); ``` #### Following the privacy state actiondomain.privacy_changed `AdminServices` the new state is a bool Runs after privacy changed at the provider. Parameters 2 $servicearrayThe domain service record. $enabledboolThe new state: `true` means privacy is on. Unlike the text value at the gate, this one is a boolean. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.privacy_changed', 10, function ($service, $enabled) { Billing::privacyFee((int) ($service['id'] ?? 0), (bool) $enabled); }); ``` #### Stopping a transfer lock change gatedomain.transfer_lock_change `AdminServices` on and off Runs before the lock changes at the provider. Parameters 2 $servicearrayThe domain service record. $statusstringThe state being asked for: `enable` to lock, `disable` to open. Return 1 stringA non-empty string **stops** the action and the text is thrown as the error. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.transfer_lock_change', 10, function ($service, $status) { // Opening the lock is the first step to the name moving away. if ($status === 'disable' && Acme::recentlyChangedOwner($service)) return 'The lock stays on for 60 days after an owner change.'; return null; }); ``` #### Following the lock state actiondomain.transfer_lock_changed `AdminServices` the new state is a bool Runs after the lock changed at the provider. Parameters 2 $servicearrayThe domain service record. $lockedboolThe new state: `true` means locked. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.transfer_lock_changed', 10, function ($service, $locked) { if (!$locked) Ops::watch('domain-unlocked', $service['name'] ?? ''); }); ``` #### Changing the lock state reported filterdomain.transfer_lock_status `Hook::runRefs` by reference Runs before the lock state reaches a screen or an API answer. Parameters 2 $lockedboolrefThe state about to be reported. Where the provider says nothing, you can fill it in here. $servicearrayThe domain service record. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.transfer_lock_status', 10, function (&$locked, $service) { // Fill from our own record where the provider does not know. if ($locked === null) $locked = Acme::lockMirror((int) ($service['id'] ?? 0)); }); ``` #### Stopping a transfer code request gatedomain.epp_code_get `AdminServices` the code goes to the owner Runs before the transfer code is asked of the provider. Parameters 2 $servicearrayThe domain service record. $serviceIdintThe service id. Return 1 stringA non-empty string **stops** the action and the text is thrown as the error. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.epp_code_get', 10, function ($service, $serviceId) { // The transfer code is the key to the name changing hands. if (Acme::openDispute($serviceId)) return 'No code while a dispute is open.'; return null; }); ``` #### Following a transfer code request actiondomain.epp_code_retrieved `AdminServices` two kinds of value Runs after the code came back from the provider. Parameters 2 $servicearrayThe domain service record. $codestring|boolThe code itself, or `true`. `true` means "the code was not returned, it was e-mailed to the owner". Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.epp_code_retrieved', 10, function ($service, $code) { // NEVER log the code: it is the key to the name. Audit::note('epp-requested', $service['name'] ?? '', $code === true ? 'sent by e-mail' : 'shown on screen'); }); ``` #### Stopping a contact profile save gatedomain.whois_profile_save `ClientDomains` on our side Runs before a saved contact profile is written to the database. This one **never reaches** the provider; a profile is a template kept on our side. Parameters 3 $uidintThe account id owning the profile. An **account** arrives here, not a service. $informationarrayThe contact fields: `FirstName`, `LastName`, `Company`, `EMail`, `Phone`, address fields. $profileIdint`0` for a new profile; a value above zero is the id of the one being edited. Return 1 stringA non-empty string **stops** the action and the text is thrown as the error. `null` or an empty string lets it carry on. Listener PHP ```php Hook::add('gate:domain.whois_profile_save', 10, function ($uid, $information, $profileId) { if ($profileId > 0) return null; // leave edits alone if (Acme::profileCount($uid) >= 10) return 'At most 10 profiles are kept.'; return null; }); ``` #### Following a profile save actiondomain.whois_profile_saved `ClientDomains` the id is filled in now Runs after the profile was saved. Parameters 3 $uidintThe account id. $profileIdintThe id of the saved profile. Filled in even for a new one. $informationarrayThe fields that were saved. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.whois_profile_saved', 10, function ($uid, $profileId, $information) { Acme::indexProfile($uid, $profileId, $information['EMail'] ?? ''); }); ``` ### Pitfalls > **The transfer code is the key to the name** > > Anyone holding the code can move the domain to another provider. Do **not** log the value the event hands you, put it in a notification or send it to an outside system. Where the value is `true` the code already went to the owner by e-mail; you do not hold it, and you should not. > **Text at the gate, a boolean at the event** > > The privacy and lock gates hand you **the state asked for as text** (`enable`/`disable`), while the events hand you **the state reached as a boolean**. Mixing them up fails silently: a text value always counts as true. > **Profile hooks carry an account, not a service** > > The first parameter of the saved-profile hooks is an **account id**, not a service record. A listener used to the other domain hooks reaches for `$service['name']` here and finds nothing. A profile belongs to an account, not to a domain. > **Changing the registrant can trigger verification** > > Changing the registrant e-mail starts **re-verification** on most extensions, and an unverified name can be suspended. A listener touching the contact write should be written knowing that; the verification state has a filter of its own. ### Related Articles - [Domain Acquisition Hooks](https://dev.wisecp.com/en/domain-acquisition-hooks) - [Domain DNS Hooks](https://dev.wisecp.com/en/domain-dns-hooks) - Domain Hooks ## Domain Catalogue Hooks https://dev.wisecp.com/en/domain-catalogue-hooks The ten hooks running while a customer searches for a name and an operator builds the extension catalogue: availability, suggestions, categories, the price matrix. ### Overview Selling domains has two sides. On the customer's side there is **the search**: a name is typed, the provider is asked, a result and suggestions come back. On the operator's side there is **the catalogue**: which extensions sell, in which category, at what price. Every hook here works **by reference**: you are handed an array, you change it, and your return is not read. The shape contract is tight, because the screen and the cart read the output directly. ### Reference #### Changing the availability answer filterdomain.availability `AdminOrders::check_domain():336` a panel order Runs after the provider's availability answer was worked out, before the response is built. Parameters 4 $availableboolrefThe availability the provider reported. Setting it to `false` takes the name off sale. $sldstringThe name itself, without the extension. $tldstringThe extension. $check_resultarrayThe provider's raw answer. Read the detail here to justify your decision. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.availability', 10, function (&$available, $sld, $tld, $check_result) { // Brand protection: nobody registers our own name. if (Acme::brandTerm($sld)) $available = false; }); ``` #### Changing the customer search result filterdomain.client_search_results `ClientDomain::check():124` already sorted Runs after the primary result and the suggestions were built, the cart state was applied **and the suggestions were sorted**. Parameters 2 $responsearrayrefThe whole answer: `status`, `type` (`register` or `transfer`), `currency`, `primary` (the primary result or `null`), `suggestions`. Cart state arrives on each entry as `in_cart`. $searchContextarrayThe query context: `sld`, `tld`, `transfer`, `ucid` (the display currency). Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.client_search_results', 10, function (&$response, $searchContext) { // Sorting already ran: put your suggestion FIRST, not last. $extra = Acme::suggest($searchContext['sld'] ?? ''); if ($extra) array_unshift($response['suggestions'], $extra); }); ``` #### Changing the featured extensions filterdomain.spotlight_tlds `ClientDomain::spotlight_tlds():275` no dot, lower case Runs after the list from the language file was lower-cased and any leading dot trimmed. Parameters 1 $tldsarrayrefThe extension list: plain strings, **no dot and lower case** (`["net","org","io"]`). A dot or a capital makes the extension unfindable. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.spotlight_tlds', 10, function (&$tlds) { $tlds = ['com', 'net', 'co.uk']; // no dot, lower case }); ``` #### Changing the extension categories filterdomain.tld_categories `domain::build_categories():86` key and label Runs after the category keys were matched with their translated labels. Parameters 1 $outarrayrefThe ordered category list: every row carries `key` and `label`. The key has to match the one on the extension rows; an invented key returns no extensions. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.tld_categories', 10, function (&$out) { // The key HAS TO MATCH the category list on the extension rows. $out[] = ['key' => 'local', 'label' => 'Local']; }); ``` #### Changing the extension price table filterdomain.tld_table `website/domain` prices resolved Runs after the extension rows were built and the prices resolved into the display currency. Parameters 2 $outarrayrefThe extension rows. Each row: `name` (**without a dot**), `categories` (comma-separated keys), the register and renew prices. $ucidintThe display currency id. The prices are in this currency; give any row you add in the same one. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.tld_table', 10, function (&$out, $ucid) { // Hide rows without a price so nobody clicks a priceless extension. $out = array_values(array_filter($out, fn ($r) => (float) ($r['register'] ?? 0) > 0)); }); ``` #### Changing the renewal price filterdomain.premium_renewal_price `Invoices::_renewal_pricing_domain()` already converted Runs after the extension's renewal price was found and the currency conversion and year multiplier applied. Parameters 4 $renewal_amountfloatrefThe unit price in the target currency. The multiplier is applied already, and what you write here reaches the invoice. $servicearrayThe domain service being renewed. $tldarrayThe extension record: `id`, `name`, `min_years`. $target_currencyintThe currency the amount is in. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.premium_renewal_price', 10, function (&$renewal_amount, $service, $tld, $target_currency) { // A premium name renews at the provider's price, not the catalogue's. $real = Acme::premiumRenewal($service['name'] ?? '', $target_currency); if ($real > 0) $renewal_amount = $real; }); ``` #### Changing the price matrix before it is saved filterdomain.pricing_save `AdminProducts` type by year by currency Runs after the costs pulled from the provider were turned into prices with the profit rate, before they are written. Parameters 2 $pricingarrayrefThe price matrix: `[type][year][currency]` → `cost` and `promo`. Types: `register`, `renewal`, `transfer`. $contextarrayThe context: `module`, `tld`, `cost_cid`, `profit_rate`. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.pricing_save', 10, function (&$pricing, $context) { // Sell the first year at cost; leave renewals alone. foreach ($pricing['register'][1] ?? [] as $cid => $row) $pricing['register'][1][$cid]['promo'] = $row['cost']; }); ``` #### Changing a new extension record filterdomain.tld_save_data `AdminProducts` on create only Runs before a new extension is written to the database. Parameters 2 $insert_dataarrayrefThe record about to be written: `name`, `module`, `status`, `rank`, `dns_manage`, `forwarding`, `whois_privacy`, `epp_code`. $extensionstringThe extension being added. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.tld_save_data', 10, function (&$insert_data, $extension) { // A new extension arrives switched off, so nothing sells without a price. $insert_data['status'] = 0; }); ``` #### Stopping an extension delete gatedomain.tld_delete `AdminProducts` takes it off the catalogue Runs before an extension is deleted from the catalogue. Parameters 2 $extensionstringThe extension about to be deleted. $tldarrayThe extension record. Return 1 stringA non-empty string **stops** the delete; the text is thrown as the error. Listener PHP ```php Hook::add('gate:domain.tld_delete', 10, function ($extension, $tld) { // Deleting an extension with sold names leaves those records orphaned. if (Acme::soldCount((int) ($tld['id'] ?? 0)) > 0) return 'Names are sold on this extension; it cannot be deleted.'; return null; }); ``` #### Following a new extension actiondomain.tld_created `AdminProducts` the id is filled in Runs after the extension was created in the catalogue. Parameters 3 $tld_idintThe id of the created record. $extensionstringThe extension name. $registrarstringThe provider module assigned. Without a module chosen, an empty key arrives. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.tld_created', 10, function ($tld_id, $extension, $registrar) { Ops::note('tld-added', $extension . ' -> ' . ($registrar ?: 'no module')); }); ``` #### Taking over a single lookup filterdomain.available `Registrar::check` takes over by returning Runs before the availability question is asked for one extension. Return a filled answer and the system asks neither WHOIS nor the registrar: you answered. Parameters 1 $queryarrayWhat is being asked: `sld`, `tld`, `module`. An empty `module` puts you on the WHOIS path, a filled one on the registrar path. Both pass through here. Return 1 array|falseA filled array **takes over** the lookup: `status` (`available`, `unavailable` or `error`), with optional `message`, `premium`, `premium_price`. An empty array, `false` or no return lets the system carry on. With several listeners answering, the last one holds. Listener PHP ```php Hook::add('filter:domain.available', 10, function ($query) { // Never ask the registrar about names on your block list. if (Acme::blocked($query['sld'] ?? '')) return ['status' => 'unavailable', 'message' => 'this name is not offered']; return false; // let the system ask for the rest }); ``` #### Changing the collected result map filterdomain.whois_result `Registrar::check` passed by link Runs once every extension has answered, right before the result goes back to the caller. You hold the whole map, not one extension. Parameters 2 $resultarrayby linkDomain against result; each result carries `sld`, `tld`, `status`, and may carry `message`, `premium`, `premium_price`. $contextarrayWhat was asked: `sld` and `tlds`. Return 1 voidThe return is ignored; you write over the map. Overriding a premium price here is easier than catching each extension lookup. Listener PHP ```php Hook::add('filter:domain.whois_result', 10, function (&$result, $context) { foreach ($result as $domain => $row) { if (($row['premium'] ?? false) !== true) continue; // Add your own margin to the premium price. $result[$domain]['premium_price'] = round(((float) $row['premium_price']) * 1.15, 2); } }); ``` #### Masking the raw record filterdomain.whois_record `ClientDomain::whois` passed by link personal data Runs before the raw text from the registrar is shown to a visitor. That text holds somebody else's name, email and phone, so this is where masking belongs. Parameters 2 $rawstringby linkThe full record text. It **arrives empty** when no record could be fetched, which is also where you can feed text from a source of your own. $whoisContextarrayWhich name was asked for: `sld`, `tld`, `status`. Return 1 voidThe return is ignored; you write over the text. Listener PHP ```php Hook::add('filter:domain.whois_record', 10, function (&$raw, $whoisContext) { if ($raw === '') return; // Hide the email and phone lines. $raw = preg_replace('~^(.*(?:Email|Phone).*)$~mi', '[hidden]', $raw); }); ``` #### Following a document requirement change actiondomain.doc_saved `AdminProductsDomain` compliance record Runs after the required document list of an extension changes. The change reaches backwards: orders already waiting on that extension may now need a different document. Parameters 4 $tldstringThe extension, without a dot and in lower case. $addedarrayNewly added document definitions. $updatedarrayChanged definitions, id against data. $removedarrayThe ids of definitions taken away. Return 1 voidThe return is ignored. The record is already written. Listener PHP ```php Hook::add('action:domain.doc_saved', 10, function ($tld, $added, $updated, $removed) { // A new document means waiting orders on that extension deserve a second look. if ($added) Acme::reviewPendingOrders($tld); }); ``` ### Pitfalls > **The search filter runs AFTER sorting** > > The suggestions are sorted by state before you see them. A suggestion appended to the list stays **at the bottom**, where most customers never look. To put yours forward, place it at the **front**. > **The extension format is strict** > > The featured list and the table rows want the extension **without a dot and in lower case**. Writing `.COM` breaks the match: the extension is found in no catalogue, no price resolves, and the screen quietly shows nothing. > **Price hooks come after conversion** > > Both the renewal price and the table rows hand you a **converted** amount. Write your own price in that same currency; dropping the provider's cost in as-is shows the customer **a number from another currency**. > **A category key has to match the extension rows** > > Adding a key to the category list opens **the button** and nothing else. That key also has to appear in the category list on the extension rows, or a customer pressing it sees **an empty list**. ### Related Articles - [Domain Acquisition Hooks](https://dev.wisecp.com/en/domain-acquisition-hooks) - Order Flow Hooks - Domain Hooks ## Domain Forwarding Hooks https://dev.wisecp.com/en/domain-forwarding-hooks The eight hooks carrying an arriving visitor and post somewhere else, plus the add-on purchase opening those surfaces. ### Overview Forwarding comes in two kinds and both sit behind **the same add-on**: one carries whoever arrives at the address, the other carries the post. They differ in number. An address forward is **one** per domain, while mail forwarding is **a list**. That difference shows in the parameters: one carries a single record, the other source and target pairs. ### Reference #### Stopping an address forward gatedomain.url_forwarding_change `ClientDomains` set and clear Runs before the forward is set at the provider or cleared from it. Parameters 3 $servicearrayThe domain service record. $fwdarrayThe forward: `protocol` (`http`/`https`), `target` (the target without a scheme, trailing slash trimmed), `method` (`301`/`302`). On a clear it arrives as an **empty array**. $verbstring`save` or `cancel`. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. Listener PHP ```php Hook::add('gate:domain.url_forwarding_change', 10, function ($service, $fwd, $verb) { if ($verb === 'cancel') return null; // leave a clear alone // A permanent forward is cached by browsers; refuse an insecure target. if (($fwd['protocol'] ?? '') !== 'https' && (int) ($fwd['method'] ?? 0) === 301) return 'A permanent forward wants a secure address.'; return null; }); ``` #### Following an address forward actiondomain.url_forwarding_changed `ClientDomains` on success only Runs after the forward was set at the provider or cleared from it. Parameters 3 $servicearrayThe domain service record. $fwdarrayThe forward that was set. On a clear it is an empty array, so do not read a target from it. $verbstringThe action that ran. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.url_forwarding_changed', 10, function ($service, $fwd, $verb) { Audit::note('forwarding', $service['name'] ?? '', $verb === 'cancel' ? 'cleared' : ($fwd['target'] ?? '')); }); ``` #### Changing the forward that was read filterdomain.url_forwarding `Hook::runRefs` by reference Runs after the forward read from the provider was normalised, before it reaches the screen. Parameters 2 $forwardingarrayrefA single record: `active`, `protocol`, `method`, `domain` (target without a scheme), `url` (the full address). `active` decides the screen's "is there a forward" state and `url` pre-fills the target box. $servicearrayThe domain service record. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.url_forwarding', 10, function (&$forwarding, $service) { // Keep active and url in step: never one filled with the other empty. if (empty($forwarding['url'])) $forwarding['active'] = false; }); ``` #### Stopping a mail forward gatedomain.email_forward_save `ClientDomains` create and delete Runs before a mail forwarding rule is created at the provider or deleted from it. Parameters 4 $servicearrayThe domain service record. $prefixstringThe local part on the domain. Only the part arrives, not the full address: `info` means `info@example.com`. $targetstringThe target box. It can arrive empty on a delete. $verbstring`create` or `delete`. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error and the provider is never called. Listener PHP ```php Hook::add('gate:domain.email_forward_save', 10, function ($service, $prefix, $target, $verb) { if ($verb === 'delete') return null; // Forwarding the control boxes outward opens the door to a takeover. if (in_array(strtolower($prefix), ['admin', 'postmaster', 'hostmaster'], true)) return 'That local part cannot be forwarded.'; return null; }); ``` #### Following a mail forward actiondomain.email_forward_saved `ClientDomains` on success only Runs after the rule landed at the provider. Parameters 4 $servicearrayThe domain service record. $prefixstringThe source local part. $targetstringThe target box. $verbstringThe action that ran. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.email_forward_saved', 10, function ($service, $prefix, $target, $verb) { MailAudit::rule($verb, $prefix . '@' . ($service['name'] ?? ''), $target); }); ``` #### Changing the forwarding list filterdomain.email_forwards `Hook::runRefs` by reference Runs after the rules read from the provider were normalised. Parameters 2 $forwardsarrayrefThe rule list: every row carries `identity`, `prefix`, `source` (`prefix@domain`) and `target`. A delete sends those three back, so do not drop them. $servicearrayThe domain service record. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.email_forwards', 10, function (&$forwards, $service) { // KEEP identity, prefix and target: a delete targets the rule with them. usort($forwards, fn ($a, $b) => strcmp($a['prefix'] ?? '', $b['prefix'] ?? '')); }); ``` #### Stopping an add-on purchase gatedomain.addon_purchase `ClientDomains` before the invoice Runs before the customer buys the add-on, with **no record created yet**. Parameters 2 $servicearrayThe domain service record. $addonKeystringThe add-on being bought: `dns-manage`, `whois-privacy` or `forwarding`. The key uses **hyphens**; an underscore matches nothing. Return 1 stringA non-empty string **stops** the purchase; it reaches the customer as the error and **no record is created**. Listener PHP ```php Hook::add('gate:domain.addon_purchase', 10, function ($service, $addonKey) { // Selling a yearly add-on on a name about to expire is not right. if (Acme::daysLeft($service) < 30) return 'The domain is about to expire; renew it first.'; return null; }); ``` #### Following an add-on order actiondomain.addon_ordered `ClientDomains` the invoice is unpaid Runs after the order was placed. The add-on is **waiting** here: it goes live once its invoice is paid. Parameters 3 $servicearrayThe domain service record. $addonKeystringThe add-on that was ordered. $contextarrayThe order context: `invoice_id` (the **unpaid** invoice raised), `addon_id` (the waiting record), `addon_name`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.addon_ordered', 10, function ($service, $addonKey, $context) { // The add-on is NOT live yet: it opens once the invoice is paid. Crm::pendingAddon((int) ($context['invoice_id'] ?? 0), $addonKey); }); ``` ### Pitfalls > **On a clear the forward arrives empty** > > While an address forward is being cleared, the hook hands you an **empty array** as the second parameter. A listener reaching for the old target finds nothing and quietly does the wrong thing. Where you need to know what was cleared, read it **at the gate** and keep it. > **The mail hook hands you the local part** > > The source parameter is **the local part alone**, not a full address. You add the domain yourself, taking it from the service record. A listener expecting a full address matches **nothing**. > **The order hook is not the moment the add-on opens** > > When an add-on order is placed the record is **waiting** and its invoice **unpaid**. Opening the surface here means handing over a service nobody paid for. The real opening happens on the payment side hooks. > **The add-on key uses hyphens** > > The keys are `dns-manage`, `whois-privacy` and `forwarding`. Writing one with an underscore matches silently nothing: your listener runs and your condition **never** holds. ### Related Articles - [Domain DNS Hooks](https://dev.wisecp.com/en/domain-dns-hooks) - Invoice and Payment Hooks - Domain Hooks ## Domain Lifecycle End Hooks https://dev.wisecp.com/en/domain-lifecycle-end-hooks The eight hooks along an unpaid domain's road to the end: the grace period, redemption, the drop, and importing. ### Overview A domain past its date does not vanish in **one step**. First comes the grace period, where the name still renews at the normal price. Then redemption: the name stops working and getting it back is **far more expensive**. Finally the drop, where the name opens to everyone. Each of the three has its own hook and all of them run from a **scheduled task**. Nobody presses a button and no one is watching a screen. ### Reference #### Catching the start of the grace period actiondomain.grace_started `cronjobs/DomainLifecycle` a scheduled task Runs the moment the domain entered its grace period; the name still works and renews at the normal price. Parameters 2 $service_idintThe id of the domain service. $statearrayThe lifecycle state: `stage`, `days_past_due`, `days_in_stage`. This stage also carries `grace_days`, `grace_fee` and `grace_fee_active`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.grace_started', 10, function ($service_id, $state) { // Reaching the customer at this stage is the cheapest way back. Crm::nudge($service_id, 'domain-grace', (int) ($state['grace_days'] ?? 0)); }); ``` #### Catching the start of redemption actiondomain.redemption_started `cronjobs/DomainLifecycle` the name stopped working Runs where the grace period ended. The name has **stopped working** here and a redemption fee applies. Parameters 2 $service_idintThe id of the domain service. $statearrayThe lifecycle state: `stage`, `days_past_due`, `days_in_stage`. This stage carries `redemption_days`, `redemption_fee`, `amount_cid` and `can_restore`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.redemption_started', 10, function ($service_id, $state) { // The fee and its currency arrive in the state; do not work them out. if (!empty($state['can_restore'])) Crm::urgent($service_id, (float) ($state['redemption_fee'] ?? 0), (int) ($state['amount_cid'] ?? 0)); }); ``` #### Stopping the drop request gatedomain.delete_request `cronjobs/DomainPurge` in a cron context Runs before the cancellation request reaches the provider. It sits inside a scheduled task, so stopping it means **nobody sees an error**. Parameters 3 $servicearrayThe domain service about to be dropped. $statearrayThe lifecycle state: `stage`, `days_past_due`, `days_in_stage`. $modulestringThe provider module's name. It arrives empty where no module is assigned. Return 1 stringA non-empty string **stops** the drop. The task tries again next round, so keep your own mark for a lasting hold. Listener PHP ```php Hook::add('gate:domain.delete_request', 10, function ($service, $state, $module) { // Hold the drop a round where the customer promised to pay. if (Acme::promiseToPay((int) ($service['id'] ?? 0))) return 'A payment was promised; the drop was held.'; return null; }); ``` #### Following the drop request actiondomain.delete_requested `cronjobs/DomainPurge` it reached the provider Runs after the cancellation request was sent to the provider. Parameters 3 $servicearrayThe domain service being dropped. $statearrayThe lifecycle state: `stage`, `days_past_due`, `days_in_stage`. $modulestringThe provider module's name; it can be empty. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.delete_requested', 10, function ($service, $state, $module) { Audit::note('domain-drop', $service['name'] ?? '', $module ?: 'no module'); }); ``` #### Catching the finished drop actiondomain.purged `cronjobs/DomainPurge` no way back Runs after the service was cancelled and the domain dropped. This is the **last step**. Parameters 3 $service_idintThe id of the domain service. $statearrayThe lifecycle state: `stage`, `days_past_due`, `days_in_stage`. At this point `stage` is the drop stage. $modulestringThe provider module's name; it can be empty. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.purged', 10, function ($service_id, $state, $module) { // The name is no longer ours: clear what hangs off it. DnsMonitor::forget($service_id); }); ``` #### Following an expiry notice actiondomain.expired_notice_sent `cronjobs/DomainExpired` once per milestone Runs after a notice was sent for a domain past its date. Parameters 2 $service_idintThe id of the domain service. $delayed_dayintThe days past the date. It is the notice milestone: the hook runs **more than once** for one name, with different day values. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.expired_notice_sent', 10, function ($service_id, $delayed_day) { // It runs per milestone: day 1, day 7 and day 15 arrive separately. if ($delayed_day >= 15) Ops::escalate('domain-expiry', $service_id); }); ``` #### Following an import actiondomain.imported `Imports` a bulk job Runs after domains were imported from a provider. The hook runs **once for the whole batch**, not per name. Parameters 2 $importedarrayThe imported service labels: strings shaped like `"example.com (#123)"`. Labels arrive, **not** service records, so the id wants picking out of the text. $module_namestringThe provider module the import ran against. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.imported', 10, function ($imported, $module_name) { // Runs ONCE for the whole batch, not per name. Ops::note('domain-import', $module_name . ': ' . count($imported)); }); ``` #### Changing the grace and redemption fee filterdomain.grace_redemption_save `AdminProductsDomain` passed by link Runs before a grace or redemption fee is saved. This is how you hold one fee policy in one place instead of typing it extension by extension. Parameters 3 $set_dataarrayby linkThe price record about to be written: `amount`, `cid` (currency), `owner_id`, `status` and the rest. $typestring`grace` or `redemption`. $idintThe extension id. **A zero** means the record is not for one extension but the default for all of them. Return 1 voidThe return is ignored; you write over the record. The hook fires once per currency and per type, so expect several calls for a single save. Listener PHP ```php Hook::add('filter:domain.grace_redemption_save', 10, function (&$set_data, $type, $id) { // Make the grace period free on every extension. if ($type === 'grace') $set_data['amount'] = 0; }); ``` ### Pitfalls > **No screen is watching these hooks** > > Every hook here runs from a scheduled task. Stopping a gate means **nobody sees an error**: not the customer, not the operator. Record your decision on your own side, or no one can tell why the domain never dropped. > **Stopping is not permanent** > > The task **tries again** next round. Thinking "I stopped it once" at the gate is wrong; your condition is asked afresh every round. For a lasting hold, keep your own mark and read it at the gate. > **The notice hook repeats for one name** > > Expiry notices go out milestone by milestone and the hook runs **again at each**. A listener reacting without reading the day value produces a run of alerts for a single name. > **The import hands you labels, not records** > > The import hook hands you **strings** shaped like `"example.com (#123)"`. Expecting service records is wrong; where you need an id, pick it out of the text and read the record yourself. ### Related Articles - [Domain Acquisition Hooks](https://dev.wisecp.com/en/domain-acquisition-hooks) - Scheduled Task Hooks - Domain Hooks ## Domain Verification Hooks https://dev.wisecp.com/en/domain-verification-hooks The nine hooks over registrant verification and reading from the provider — even a read has a gate of its own. ### Overview Some extensions ask the registrant to prove who they are **with a document**. An unverified name can be suspended, so that flow has hooks of its own: a gate stopping the submission, an event reporting the outcome, and a filter handing you the whole screen. The second group is less known: **read gates**. Reading name servers or contact details means a real request to the provider, so a gate stands in front of a read as well. ### Reference #### Stopping a verification submission gatedomain.verification_submit `ClientDomains` before any file is handled Runs before the submitted documents are handled and **nothing is saved yet**. Parameters 3 $servicearrayThe domain service record. $defsarrayThe field definitions the extension asks for: each with `key`, `type`, `name`, `required`, `options`. They differ by extension and module. $tldstringThe name's extension. Return 1 stringA non-empty string **stops** the submission; no file is handled and no field is written. Listener PHP ```php Hook::add('gate:domain.verification_submit', 10, function ($service, $defs, $tld) { // A second submission while a review is open only causes confusion. if (Acme::reviewOpen((int) ($service['id'] ?? 0))) return 'Your earlier submission is still under review.'; return null; }); ``` #### Following a verification submission actiondomain.verification_submitted `ClientDomains` how many fields landed Runs after the documents were handled and the fields saved. Parameters 3 $servicearrayThe domain service record. $submittedintHow many fields were saved. It can be zero, meaning a submission happened and no field landed. $defsarrayThe extension's field definitions. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.verification_submitted', 10, function ($service, $submitted, $defs) { // Zero fields means the submission was empty: keep it out of the queue. if ($submitted > 0) Ops::queue('registrant-review', $service['id'] ?? 0); }); ``` #### Changing the verification screen filterdomain.verification_state `Hook::runRefs` keep all three keys Runs after the verification screen's whole payload was built, before it appears. Parameters 2 $dataarrayrefThe screen payload: `state` (`none`, `form`, `review`, `rejected`, `verified`), `fields`, `operator_note`. `state` picks which shell is shown; leave all three keys in place. $servicearrayThe domain service record. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.verification_state', 10, function (&$data, $service) { // Leave state, fields and operator_note in place; the shell follows state. if (($data['state'] ?? '') === 'rejected') $data['operator_note'] = Acme::helpText($data['operator_note'] ?? ''); }); ``` #### Stopping a contact read gatedomain.contacts_get `AdminServices` a read Runs before the contact details are read from the provider. Parameters 2 $servicearrayThe domain service record. $serviceIdintThe service id. Return 1 stringA non-empty string **stops** the read; the text is thrown as the error and the provider is never reached. Listener PHP ```php Hook::add('gate:domain.contacts_get', 10, function ($service, $serviceId) { // Where the provider charges per query, hold the reads down. if (Acme::readQuotaSpent($serviceId)) return 'Today\'s query allowance is spent.'; return null; }); ``` #### Following contact details that were read actiondomain.contacts_fetched `AdminServices` straight from the provider Runs after the contact details came back from the provider. Parameters 2 $servicearrayThe domain service record. $whoisarrayThe contact data read from the provider. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.contacts_fetched', 10, function ($service, $whois) { // Personal data: do not log it, only check it against our mirror. Acme::compareMirror((int) ($service['id'] ?? 0), $whois); }); ``` #### Stopping a name server read gatedomain.nameservers_get `AdminServices` a read Runs before the name servers are read from the provider. Parameters 2 $servicearrayThe domain service record. $serviceIdintThe service id. Return 1 stringA non-empty string **stops** the read; the text is thrown as the error and the provider is never reached. Listener PHP ```php Hook::add('gate:domain.nameservers_get', 10, function ($service, $serviceId) { if (($service['status'] ?? '') === 'transfer') return 'Name servers cannot be read while a transfer runs.'; return null; }); ``` #### Following name servers that were read actiondomain.nameservers_fetched `AdminServices` a normalised list Runs after the name servers came back from the provider. Parameters 2 $servicearrayThe domain service record. $nameserversarrayThe list that was read, normalised and ordered. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.nameservers_fetched', 10, function ($service, $nameservers) { // A provider value different from ours means the two have drifted. Acme::driftCheck((int) ($service['id'] ?? 0), $nameservers); }); ``` #### Stopping a DNS record read gatedomain.dns_records_get `AdminServices` a read Runs before the DNS records are read from the provider. Parameters 2 $servicearrayThe domain service record. $serviceIdintThe service id. Return 1 stringA non-empty string **stops** the read; the text is thrown as the error and the provider is never reached. Listener PHP ```php Hook::add('gate:domain.dns_records_get', 10, function ($service, $serviceId) { // Without the DNS management add-on, reading makes no sense either. if (!Acme::addonActive($serviceId, 'dns-manage')) return 'DNS management is off.'; return null; }); ``` ### Pitfalls > **A read gate leaves the screen empty** > > Stopping a read does more than cut the request: that part of the screen **never fills**, and the customer or operator may not see why. The text you return is shown to them, so say what was stopped and why **in that sentence**. > **Contact data that was read is personal data** > > The contact read hook hands you names, addresses, phone numbers and e-mail. Do **not** put those in logs, outside systems or notification bodies. Where you need a comparison, keep **whether a difference exists** rather than the values. > **The verification screen draws from three keys** > > `state` picks the shell, `fields` fills it, and `operator_note` carries the rejection reason. Dropping one breaks the screen: without `state`, for one, there is no telling which shell to draw. > **Zero fields is still a submission** > > The verification event also runs where no field landed, with the counter at `0`. Assuming "a document arrived" without reading the number drops an empty submission into the review queue. ### Related Articles - [Domain Contact Hooks](https://dev.wisecp.com/en/domain-contact-hooks) - [Domain DNS Hooks](https://dev.wisecp.com/en/domain-dns-hooks) - Domain Hooks ## Domain Profile Hooks https://dev.wisecp.com/en/domain-profile-hooks Saved contact profiles, default name servers and the renewal invoice — the last eight hooks of the domain area. ### Overview Most hooks here belong to **an account** rather than to one domain. Contact profiles and default name servers are templates a customer fills once and uses on every name. The last two sit on the money side: where a customer renews a domain by hand, a gate stands in front of the invoice and an event behind it. ### Reference #### Following a profile delete actiondomain.whois_profile_deleted `ClientDomains` a last snapshot Runs after a saved contact profile was deleted. Parameters 3 $uidintThe account id. A sub-user's action is recorded against **the owner**. $profileIdintThe id of the deleted profile. $profilearrayThe profile **as it was before**: `id`, `name`, `information`, `detouse`. Take what you need from here; the record is gone. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.whois_profile_deleted', 10, function ($uid, $profileId, $profile) { // The record is gone: everything you need is in the third parameter. Acme::dropProfileIndex($uid, $profileId); }); ``` #### Following the default profile actiondomain.whois_profile_default_set `ClientDomains` the record holds the OLD value Runs after a profile was made the default. Parameters 3 $uidintThe account id. A sub-user's action is recorded against **the owner**. $profileIdintThe id of the profile made default. $profilearrayThe profile record **from before the change**. Its default field still holds the **old** value, so do not read the new state from it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.whois_profile_default_set', 10, function ($uid, $profileId, $profile) { // The new default is the SECOND parameter; the third is the old record. Acme::defaultProfile($uid, $profileId); }); ``` #### Changing the profile form filterdomain.whois_profile_data `Hook::runRefs` the key names are fixed Runs before a saved profile is loaded into the form. Parameters 3 $dataarrayrefThe fields going to the form: `id`, `name`, `first`, `last`, `org`, `email`, `phone`, `address`, `postal`, `city`, `country`. Keep the key names; the form fills by them. $profilearrayThe raw database record. Fields the form leaves out (the state, a second address line) are **in here**. $uidintThe account id. A sub-user's action is recorded against **the owner**. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.whois_profile_data', 10, function (&$data, $profile, $uid) { // A field the form leaves out sits in the raw record: take it from there. $data['state'] = $profile['information']['State'] ?? ''; }); ``` #### Following the default name servers actiondomain.default_ns_saved `ClientDomains` account-wide Runs after the customer's default name server set was saved. The set belongs to the whole account, **not to one name**. Parameters 2 $uidintThe account id. A sub-user's action is recorded against **the owner**. $nsarrayThe saved set: the `ns1` … `ns4` keys, filled ones only. At least two are present. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.default_ns_saved', 10, function ($uid, $ns) { // This set applies to EVERY new name on the account, not to one. Acme::rememberDefaults($uid, $ns); }); ``` #### Following provider settings actiondomain.registrar_settings_saved `AdminProducts` an operator setting Runs after a domain provider's settings were saved. Parameters 2 $modulestringThe provider module whose settings were saved. $settingsarrayThe saved settings. They can hold credentials; do not log them. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.registrar_settings_saved', 10, function ($module, $settings) { // Record that they changed, never what is inside them. Audit::note('registrar-settings', $module, 'updated'); }); ``` #### Stopping a renewal invoice gatedomain.renewal_invoice_create `ClientDomains` before the invoice Runs where a customer starts a manual renewal, before the invoice is raised. Stopping it means **no invoice and no line** is created. Parameters 5 $servicearrayThe domain service being renewed. $yearsintThe number of years chosen. $subtotalfloatThe subtotal without tax. In the service's own currency. $tldstringThe extension, **without a dot and lower case**. $uidintThe account id. Return 1 stringA non-empty string **stops** the invoice; the text reaches the customer as the error. Listener PHP ```php Hook::add('gate:domain.renewal_invoice_create', 10, function ($service, $years, $subtotal, $tld, $uid) { // Some extensions take no renewal longer than ten years. if ($years > 10) return 'Renewals run to ten years at most.'; return null; }); ``` #### Following a renewal invoice actiondomain.renewal_invoice_created `ClientDomains` the invoice is unpaid Runs after the renewal invoice was raised. The invoice is **unpaid** here and the domain is not renewed yet. Parameters 5 $servicearrayThe domain service being renewed. $yearsintThe number of years chosen. $invoiceIdintThe id of the **unpaid** invoice. $subtotalfloatThe subtotal without tax. $duedateFullstringThe domain's **current** due date. Not the new one: the renewal lands after payment. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.renewal_invoice_created', 10, function ($service, $years, $invoiceId, $subtotal, $duedateFull) { // The domain is NOT renewed yet; that follows the payment. Crm::pendingRenewal($invoiceId, $service['name'] ?? '', $years); }); ``` #### Following DNS records that were read actiondomain.dns_records_fetched `AdminServices` straight from the provider Runs after the DNS records came back from the provider. Parameters 2 $servicearrayThe domain service record. $recordsarrayThe records that were read, normalised. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:domain.dns_records_fetched', 10, function ($service, $records) { Acme::snapshotZone((int) ($service['id'] ?? 0), $records); }); ``` #### Following the detail page opening actiondomain.detail.viewed `website/domains` on every opening Runs when a customer opens the management page of their own domain. Access checks are already behind you: the opener is confirmed as the owner. Parameters 2 $servicearrayThe domain record: `id`, `name`, `owner_id`, `status`, `duedate` and the rest. $serviceIdintThe id of the domain. Return 1 voidThe return is ignored. It runs while the page opens, so whatever you do here is added to the customer's wait. Hand a slow call to the queue. Listener PHP ```php Hook::add('action:domain.detail.viewed', 10, function ($service, $serviceId) { // Keep it light: the customer is waiting for the page. Acme::touchLastSeen($serviceId); }); ``` ### Pitfalls > **The default-profile record holds the OLD state** > > On the default hook the third parameter is the profile record **from before the change**: its default field still holds the old value. Which profile is now the default is **the second parameter**. Reading the record and concluding "this one is not the default" comes from here. > **These hooks belong to an account, not a domain** > > Profiles and default name servers are **account-wide** templates. The hooks hand you an account id rather than a service record, so a listener reaching for domain details finds nothing. A sub-user's action is recorded against **the owner**. > **The date on the invoice is the old due date** > > The date field on the renewal invoice hook is the domain's **current** due date, not the one after renewing. The new date exists only once the **payment** lands. Using this one as "the new end" shows the customer the wrong day. > **Provider settings carry credentials** > > The settings hook can hand you the **keys and passwords** used to reach the provider. Recording that they changed is fair; recording **what is in them** is not. Log which module was updated and nothing more. ### Related Articles - [Domain Contact Hooks](https://dev.wisecp.com/en/domain-contact-hooks) - Invoice and Payment Hooks - Domain Hooks ## Domain Capability Hooks https://dev.wisecp.com/en/domain-capability-hooks The two hooks that hand a single domain capability to a provider other than the registrar. ### Overview A domain service resolves to exactly one module: its registrar. So a provider that is not the registrar has no slot of its own. A DNS server, a forwarding service, a WHOIS privacy provider: none of them can register a domain, and none of them can be the module. These two hooks open that door. One decides **what the screens offer**, the other decides **who answers the call**. The registrar keeps every capability you do not claim. The capability names are shared: `nameservers`, `child_ns`, `transfer_lock`, `auth_code`, `dns_records`, `dns_edit`, `dnssec`, `email_forwarding`, `domain_forwarding`, `whois`, `whois_privacy`. **Use both.** Only the matrix gives you a tab with nothing behind it. Only the provider gives you a capability nobody can reach. ### Reference #### Opening a capability filterdomain.capabilities `Hook::runRefs` by reference from four screens Runs once the module probe is done and before the tabs are built. Four screens compute this matrix separately and every one of them calls the hook: the customer panel, the customer API, the management panel and the admin API. Parameters 2 $capsarrayrefThe capability matrix, `name => bool`. Raw names on all four screens; the admin payload renames them to `has_*` afterwards. $contextarrayref`service` the domain record, `instance` the registrar or `null`, `surface` one of `website`, `client_api`, `admin_panel`, `admin_api`. Return 1 voidThe matrix changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.capabilities', 10, function (&$caps, $context = []) { $service = $context['service'] ?? []; if (!MyDns::handles((int) ($service['id'] ?? 0))) return; $caps['dns_records'] = true; $caps['dns_edit'] = true; }); ``` #### Answering the call filterdomain.capability_provider `Services::instance_module()` by reference Runs when a screen asks for the module instance and names the capability it is about to use. A plain lookup never reaches this hook, so the service lifecycle is untouched. Parameters 2 $providerobject|falserefArrives as `false`. Assign an object and the call goes to it; anything else is ignored. $contextarrayref`service` the domain record, `capability` the name being asked for, `instance` the registrar or `null`. Return 1 voidThe provider is handed over **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:domain.capability_provider', 10, function (&$provider, $context = []) { if (!in_array((string) ($context['capability'] ?? ''), ['dns_records', 'dns_edit'], true)) return; $service = $context['service'] ?? []; if (!MyDns::handles((int) ($service['id'] ?? 0))) return; $provider = new MyDnsProvider($service); }); ``` #### What the provider must look like Your provider carries the same method names the registrar would. Declare them as real methods. Provider PHP ```php class MyDnsProvider { public string $error = ''; public function set_service(int|array $service = []): void {} public function get_dns_records(): array|false { // ... read the zone return $records; } public function add_dns_record($type, $name, $value, $ttl, $priority): bool { // ... write one record return true; } } ``` ### Pitfalls **A magic `__call()` will not work.** Every screen asks `method_exists()` before it calls anything, and a method served by `__call()` is invisible to that check. Your provider is then never called and nothing reports an error. Declare each method for real. **One hook alone is not enough.** Open the matrix without a provider and the customer opens an empty tab. Hand over a provider without opening the matrix and the tab never shows up. **Four screens, one answer.** The matrix is computed separately on each screen. A capability opened in the panel but missed in the API reads as the tab being visible while the endpoint refuses the request. **The lifecycle is not yours.** A capability handover only covers the capability. Creation, renewal, transfer and cancellation keep going to the registrar, which is what keeps this safe. ### Related Articles - [Domain DNS Hooks](https://dev.wisecp.com/en/domain-dns-hooks) - [Domain Contact Hooks](https://dev.wisecp.com/en/domain-contact-hooks) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) # Hooks / Support Tickets ## Ticket Lifecycle Hooks https://dev.wisecp.com/en/ticket-lifecycle-hooks The eight hooks over creating, closing, transferring and locking a ticket. ### Overview The path of a ticket lives here: it opens, its status moves, it is transferred, locked, and finally deleted or resolved on its own. Two status hooks look alike but are separate: one reports a change made by staff, the other an automatic resolution **nobody touched**. ### Reference #### Stopping a ticket opened by staff gateticket.admin_create `AdminTickets` before the write Runs when staff open a ticket on behalf of a customer, before anything is written. Parameters 3 $set_requestarrayThe ticket record to be written: title, status, department, priority and assignment. $messagestringThe first message, with placeholders already resolved. $udataarrayThe customer who will own the ticket. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.admin_create', 10, function ($set_request, $message, $udata) { if (Acme::blacklisted((int) ($udata['id'] ?? 0))) return 'No ticket can be opened for this account.'; return null; }); ``` #### Following a ticket being created actionticket.created `AdminTickets` first message included Runs after a ticket is created. Parameters 1 $ticketarrayThe full ticket, **with the first message in it**: owner, title, status, department, priority and assignment. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.created', 10, function ($ticket) { // The first message is not a separate parameter; it sits in the ticket. Acme::classify((int) ($ticket['id'] ?? 0), $ticket['message'] ?? ''); }); ``` #### Following a status change actionticket.status_changed `AdminTickets` five base states Runs after the status of a ticket changes. Parameters 4 $ticketarrayThe snapshot from **before** the change. $old_statusstringThe previous base state: open, waiting, in progress, answered or solved. $new_statusstringThe new base state. $new_cstatusintThe new custom state. An operator may define their own states; a **zero** means none. A rule looking only at the base state never sees them. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.status_changed', 10, function ($ticket, $old_status, $new_status, $new_cstatus) { // Custom states are a second axis beside the base state. if ($new_status === 'solved') Acme::stopSlaClock((int) ($ticket['id'] ?? 0)); }); ``` #### Following an automatic resolution actionticket.auto_resolved `cronjobs` with nobody touching it Runs when a ticket left without a reply closes itself. **Nobody closed it**: silence did. Parameters 3 $ticket_idintThe id of the resolved ticket. $ticketarrayThe current row, its status already written as solved. $delayed_dayintHow many days passed since the last reply. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.auto_resolved', 10, function ($ticket_id, $ticket, $delayed_day) { // Nobody closed it: silence did. Acme::surveyLater($ticket_id, $delayed_day); }); ``` #### Following staff opening a ticket actionticket.viewed `AdminTickets` already marked read Runs when staff open a ticket. Parameters 2 $ticketarrayThe loaded ticket. The unread mark is **already cleared**: you cannot answer "was it new" from here. $idintThe ticket id. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.viewed', 10, function ($ticket, $id) { // The unread mark is already cleared. Acme::trackHandling($id); }); ``` #### Following a ticket being transferred actionticket.transferred `AdminTickets` owner change Runs after a ticket is transferred to another customer. Parameters 3 $ticketIdintThe id of the transferred ticket. $fromUserIdintThe previous owner. $toUserIdintThe new owner. Its history moves with it: a conversation the previous owner can no longer see now sits with the new one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.transferred', 10, function ($ticketId, $fromUserId, $toUserId) { // The history moved with it. Acme::reindexOwner($ticketId, $toUserId); }); ``` #### Following a lock change actionticket.lock_changed `AdminTickets` replies close Runs when a ticket is locked or unlocked. A customer **cannot reply** to a locked ticket. Parameters 2 $ticketarrayThe record before the change. $lockedintThe new lock state: one for locked, zero for open. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.lock_changed', 10, function ($ticket, $locked) { // A customer cannot reply to a locked ticket. if ($locked === 1) Acme::notifyLocked((int) ($ticket['user_id'] ?? 0)); }); ``` #### Following a ticket being deleted actionticket.deleted `AdminTickets` the id only Runs after a ticket is deleted. Parameters 1 $ticket_idintThe id of the deleted ticket. No record is passed: if you need its content you must have kept it **before** the deletion. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.deleted', 10, function ($ticket_id) { // No record is passed: keep the content beforehand if you need it. Acme::dropFromIndex('ticket', $ticket_id); }); ``` ### Pitfalls > **In the view hook the unread mark is already cleared** > > The hook runs when staff open a ticket, but the unread flag is cleared **before** it. You cannot answer "was this new" from here; track the first opening separately. > **The delete hook carries only the id** > > Unlike other deletion hooks **no ticket record is passed**. If you need its title, owner or content you must have kept it beforehand; by the time the hook runs there is nowhere left to read it from. ### Related Articles - Support Ticket Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Customer-side Ticket Hooks https://dev.wisecp.com/en/client-ticket-hooks The eight hooks over what a customer does: opening, replying, closing and rating. ### Overview Everything a customer can do on the support side lives here: opening a ticket, replying, closing, reopening and rating. The difficulty of this branch is the question **who did it**. A sub-user may act on somebody else’s account and a guest may belong to no account at all, so the hooks carry the account and the login separately. ### Reference #### Following a customer opening a ticket actionticket.opened_by_client `ClientTickets` zero for a guest Runs when a ticket is opened by a customer or through email. Parameters 4 $ticketAfterarrayThe freshly created ticket. $messagestringThe first message with its normalised body. $clientIdintThe owner. It is **zero** for an **unrecognised sender** arriving by email: do not use it without looking the account up. $replyIdintThe id of the first reply row. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.opened_by_client', 10, function ($ticketAfter, $message, $clientId, $replyId) { // It arrives as ZERO for an unrecognised sender. if (!$clientId) { Acme::queueIdentify($ticketAfter); return; } Acme::classify((int) ($ticketAfter['id'] ?? 0), $message); }); ``` #### Stopping a customer reply gateticket.reply_by_client `ClientTickets` shape varies by source Runs before a customer replies to a ticket. Replies from the panel, from email and from a guest link all pass here. Parameters 5 $ticketarrayThe ticket being replied to. ? **Its shape varies by source**: what arrives from the panel and what arrives by email do not carry the same fields. Test a field before reaching for it. $messagestringThe text to be sent, as plain text. $ownerIdintThe account that owns the ticket. **Zero for a guest.** $loginIdintThe login doing it. It **differs** from the owner when a sub-user replies on another account; zero for a guest. $isGuestboolWhether it came through a guest link. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.reply_by_client', 10, function ($ticket, $message, $ownerId, $loginId, $isGuest) { // For a guest both ids arrive as ZERO. if ($isGuest && Acme::guestRepliesClosed()) return 'Guest replies are closed.'; return null; }); ``` #### Following a customer reply actionticket.reply_added_by_client `ClientTickets` zero for a guest Runs after a customer replies to a ticket. Parameters 4 $ticketAfterarrayThe fresh ticket after the reply. $messagestringThe message added. $clientIdintWho wrote it; it can be zero on an anonymous email reply. $replyIdintThe id of the reply added. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.reply_added_by_client', 10, function ($ticketAfter, $message, $clientId, $replyId) { Acme::restartSlaClock((int) ($ticketAfter['id'] ?? 0)); }); ``` #### Following a customer closing a ticket actionticket.closed_by_client `ClientTickets` two separate ids Runs when a customer closes their own ticket. Parameters 3 $ticketarrayThe snapshot from **before** closing. $ownerIdintThe account the ticket belongs to. $loginIdintThe identity that **actually did it**. When a sub-user closes another account’s ticket the two differ: this is the one that belongs in an audit record. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.closed_by_client', 10, function ($ticket, $ownerId, $loginId) { // The LOGIN id is the one that belongs in the audit. Acme::audit('ticket-closed', $loginId, $ownerId); }); ``` #### Following a customer reopening a ticket actionticket.reopened_by_client `ClientTickets` two separate ids Runs when a customer reopens a closed ticket. Parameters 3 $ticketarrayThe snapshot from **before** reopening. $ownerIdintThe account the ticket belongs to. $loginIdintThe login that did it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.reopened_by_client', 10, function ($ticket, $ownerId, $loginId) { Acme::restartSlaClock((int) ($ticket['id'] ?? 0)); }); ``` #### Following a rating actionticket.rated `ClientTickets` two scopes Runs when a customer rates a ticket or a single staff reply. **Two separate scopes** land on the same hook. Parameters 5 $scopestringWhat was rated: `ticket` or `reply`. Check this first: the two scopes fill their parameters differently. $ratingintThe score, one to five. A value outside that range **never reaches** the hook. $ticketIdintThe ticket id; **filled in both scopes**. $replyIdintThe reply rated. It is **zero** in the ticket scope. $ownerIdintThe account the ticket belongs to. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.rated', 10, function ($scope, $rating, $ticketId, $replyId, $ownerId) { // Check the scope first: the reply id is ZERO in the ticket scope. if ($scope === 'reply') { Acme::scoreAgent($replyId, $rating); return; } Acme::scoreTicket($ticketId, $rating); }); ``` #### Following a guest viewing actionticket.guest.viewed `ClientTickets` carries the access value Runs when somebody not signed in views a ticket through a link. Parameters 2 $ticketarrayThe resolved ticket. ? It holds the **sender’s name, email and access value**. Whoever sees that value can open the ticket: keep it out of your records and logs. $tokenstringThe access value from the address, already verified. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.guest.viewed', 10, function ($ticket, $token) { // Write the access value NOWHERE. Acme::countGuestView((int) ($ticket['id'] ?? 0)); }); ``` #### Changing a customer message filterticket.client_message `ClientTickets` passed by link Runs before the text a customer wrote is saved. New tickets and replies both pass here. Parameters 2 $messagestringby linkThe message body, as plain text. $ctxarrayContext: the source, the ticket id, the account and whether it is a guest. On a new ticket the id is **zero**: the ticket does not exist yet. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.client_message', 10, function (&$message, $ctx) { // On a new ticket the ticket id is ZERO. $message = Acme::stripSecrets($message); }); ``` ### Pitfalls > **The account and the login are not the same thing** > > The closing, reopening and reply hooks carry two ids: the **account the ticket belongs to** and the login that **actually did it**. When a sub-user works on somebody else’s account they differ. The login belongs in the audit record; the permission decision follows the account. > **The guest view hook carries the access value** > > The ticket data holds the sender’s name, email and **access value**. Whoever sees that value can open the ticket: writing it into your records, your logs or an outside service copies the link to a third place. ### Related Articles - Support Ticket Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Staff Reply Hooks https://dev.wisecp.com/en/staff-reply-hooks The eight hooks over staff replies, internal notes and assignment. ### Overview Every point where staff touch a ticket lives here: writing a reply, editing it, deleting it, adding an internal note and assigning. There are three reply hooks and they should not be confused: the one staff write, the one a customer writes and the one a **scheduled task** writes. The last carries its parameters in a single array. ### Reference #### Stopping a staff reply gateticket.reply `AdminTickets` signature attached Runs before a staff reply is sent. Parameters 3 $ticketarrayThe ticket being replied to. $messagestringThe final text. It carries markup and the **signature is already attached**: allow for that if you measure length. $admin_idintThe staff member writing it. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.reply', 10, function ($ticket, $message, $admin_id) { // The signature is already in: allow for it when measuring length. if (Acme::containsSecret($message)) return 'The reply holds a value that must not be shared.'; return null; }); ``` #### Following a staff reply actionticket.reply_added `AdminTickets` hydrated reply Runs after a staff reply is added. Parameters 3 $ticketarrayThe fresh ticket after the reply. $reply_idintThe id of the reply added. $replyarrayThe whole reply: its text, author, attachments and address. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.reply_added', 10, function ($ticket, $reply_id, $reply) { Acme::stopSlaClock((int) ($ticket['id'] ?? 0)); }); ``` #### Following an automatic reply actionticket.replied `cronjobs` one context array Runs when a scheduled task adds a reply to a ticket. Unlike its siblings the parameters arrive in **a single array**. Parameters 1 $contextarrayEverything is here: the source, the current ticket and the reply added. Expect no separate parameters; this hook carries one array. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.replied', 10, function ($context) { // One array: different from its siblings. $ticket = $context['request'] ?? []; Acme::noteAutoReply((int) ($ticket['id'] ?? 0)); }); ``` #### Following a reply being edited actionticket.reply_updated `AdminTickets` after the edit Runs after a reply is edited. Parameters 3 $ticketIdintThe ticket id. $replyIdintThe id of the edited reply. $replyarrayThe updated reply. The previous version is not passed: to compare, you must have kept a copy yourself. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.reply_updated', 10, function ($ticketId, $replyId, $reply) { // The previous version is not passed. Acme::reindexReply($replyId, $reply['message'] ?? ''); }); ``` #### Following a reply being deleted actionticket.reply_deleted `AdminTickets` after deletion Runs after a reply is deleted. Parameters 3 $ticketIdintThe ticket id. $replyIdintThe id of the deleted reply. $replyarrayThe deleted reply record: its owner and whether it was from staff. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.reply_deleted', 10, function ($ticketId, $replyId, $reply) { Acme::dropFromIndex('reply', $replyId); }); ``` #### Stopping an internal note gateticket.note_add `AdminTickets` the customer never sees it Runs before staff add an internal note. Notes are **never shown to the customer**. Parameters 3 $ticketarrayThe ticket the note goes on. $messagestringThe note content. $admin_idintThe staff member adding it. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.note_add', 10, function ($ticket, $message, $admin_id) { if (Acme::tooLong($message)) return 'The note is too long.'; return null; }); ``` #### Following an internal note actionticket.note_added `AdminTickets` the note may be empty Runs after an internal note is added. Parameters 3 $ticketarrayThe ticket the note went on. $admin_idintThe staff member who added it. $notearrayThe note data: its text, whether it is pinned, and attachments. ? It **can arrive empty** when the note could not be read. Test before reaching into it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.note_added', 10, function ($ticket, $admin_id, $note) { // The note CAN be empty. if (!$note) return; Acme::mirrorNote((int) ($ticket['id'] ?? 0), $note['message'] ?? ''); }); ``` #### Following an assignment actionticket.assigned `AdminTickets` zero means unassigned Runs when a ticket is assigned to staff or unassigned. Parameters 3 $ticketarrayThe ticket **before** the assignment. $old_assigned_idintThe previous assignee; a **zero** means none. $new_assigned_idintThe new assignee; a **zero** means it was unassigned. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.assigned', 10, function ($ticket, $old_assigned_id, $new_assigned_id) { // Zero means it was unassigned. if (!$new_assigned_id) { Acme::backToPool((int) ($ticket['id'] ?? 0)); return; } Acme::notifyAgent($new_assigned_id, (int) ($ticket['id'] ?? 0)); }); ``` ### Pitfalls > **The note data can be empty** > > The third parameter of the internal note hook is **empty** when the note could not be read. A listener reaching straight into it fails there; test on the first line. > **The automatic reply hook carries one array** > > Its siblings give the ticket, the reply id and the reply as separate parameters; the scheduled-task reply carries all of it in **a single context array**. A listener expecting the same signature works with empty values here. ### Related Articles - Support Ticket Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Ticket Organisation Hooks https://dev.wisecp.com/en/ticket-organisation-hooks The seven hooks over merging, splitting and moving tickets. ### Overview The operations that reorganise tickets live here: merging, splitting, and changing the department, priority or linked service. The first two cannot be undone. A merge **deletes** everything but the target; a split moves replies into a new ticket. That is what makes the gates valuable. ### Reference #### Stopping a ticket merge gateticket.merge `AdminTickets` the others are deleted Runs before tickets are merged. A merge cannot be undone: **everything but the target is deleted**. Parameters 2 $primary_idintThe target ticket; the others move into it. $merge_idsarrayThe other tickets; the target is not in this list. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.merge', 10, function ($primary_id, $merge_ids) { // Everything but the target is DELETED: this cannot be undone. foreach ($merge_ids as $id) if (Acme::underLegalHold((int) $id)) return 'A ticket under legal hold cannot be merged.'; return null; }); ``` #### Following a merge actionticket.merged `AdminTickets` after the merge Runs after the tickets are merged. Parameters 2 $primary_idintThe target ticket. $merged_idsarrayThe ids merged in and **deleted**. Those ids no longer exist: point them at the target in your own records. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.merged', 10, function ($primary_id, $merged_ids) { // Those ids are gone: point them at the target. foreach ($merged_ids as $id) Acme::redirectRef((int) $id, $primary_id); }); ``` #### Stopping a ticket split gateticket.split `AdminTickets` an unverified list Runs before some replies of a ticket are moved into a new one. Parameters 2 $sourcearrayThe source ticket being split. $reply_idsarrayThe ids of the replies to move. They are cleaned but **not yet checked against the database**: an id here may not really belong to that ticket. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.split', 10, function ($source, $reply_ids) { // The list has not been checked against the database yet. if (count($reply_ids) > 50) return 'At most 50 replies can move at once.'; return null; }); ``` #### Following a split actionticket.split `AdminTickets` a verified list Runs after the new ticket is created and the replies have moved. Parameters 3 $sourcearrayThe source ticket. $new_ticketarrayThe newly created ticket. $moved_reply_idsarrayThe ids that actually moved. Unlike in the gate this list is **verified**: anything asked for but not moved is absent. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.split', 10, function ($source, $new_ticket, $moved_reply_ids) { // This list is verified: what really moved. Acme::reindexSplit((int) ($new_ticket['id'] ?? 0), $moved_reply_ids); }); ``` #### Following a department change actionticket.department_changed `AdminTickets` zero means none Runs after a ticket moves to another department. The department decides who sees the ticket. Parameters 3 $ticketarrayThe record before the change. $oldDepartmentIdintThe previous department; a zero means none. $newDepartmentIdintThe new department. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.department_changed', 10, function ($ticket, $oldDepartmentId, $newDepartmentId) { // The department decides who sees it. Acme::notifyDepartment($newDepartmentId, (int) ($ticket['id'] ?? 0)); }); ``` #### Following a priority change actionticket.priority_changed `AdminTickets` four levels Runs after the priority of a ticket changes. Parameters 3 $ticketarrayThe record before the change. $oldPriorityintThe previous level. $newPriorityintThe new level. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.priority_changed', 10, function ($ticket, $oldPriority, $newPriority) { if ($newPriority > $oldPriority) Acme::escalate((int) ($ticket['id'] ?? 0)); }); ``` #### Following the linked service changing actionticket.service_changed `AdminTickets` zero means unlinked Runs after the service a ticket is linked to changes. Parameters 3 $ticketarrayThe record before the change. $oldServiceIdintThe previous service; a zero means it was not linked. $newServiceIdintThe new service; a **zero means the link was removed**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.service_changed', 10, function ($ticket, $oldServiceId, $newServiceId) { // Zero means the link was removed. if (!$newServiceId) return; Acme::attachServiceContext((int) ($ticket['id'] ?? 0), $newServiceId); }); ``` ### Pitfalls > **A merge deletes the other tickets** > > In a merge everything but the target is **deleted**, not merely moved. The ids in the event hook are records that no longer exist: point them at the target on your side, or you are left with broken references. > **The list in the split gate is not verified yet** > > The reply ids reaching the gate are cleaned but **not matched against the database**: the list may hold an id that does not belong to that ticket. To see what really moved, read the list in the event hook. ### Related Articles - Support Ticket Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Ticket View Hooks https://dev.wisecp.com/en/ticket-view-hooks The nine hooks over the lists, the conversation and the pickers on screen. ### Overview Everything visible on screen passes through these hooks: the lists, the conversation thread, the department and service pickers, the data of the detail page. One works differently from the rest: the list query hands you not ready rows but **the query itself**, and it is called twice for one listing. ### Reference #### Changing the department list filterticket.departments `ClientTickets` passed by link Runs once the department list shown to a customer is built. Hiding a department also stops tickets being opened there. Parameters 2 $departmentsarrayby linkThe visible departments. $langstringThe active language. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.departments', 10, function (&$departments, $lang) { // Hiding one also stops tickets being opened there. $departments = array_values(array_filter($departments, fn ($d) => Acme::departmentOpen((int) ($d['id'] ?? 0)))); }); ``` #### Changing the priority options filterticket.priorities `ClientTickets` selected flag included Runs once the priorities a customer may pick are prepared. Parameters 1 $prioritiesarrayby linkThe options, each carrying a value, a label and whether it is selected. Drop the selected one and the form is left with nothing marked. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.priorities', 10, function (&$priorities) { // Drop the selected one and nothing stays marked on the form. $priorities = array_values(array_filter($priorities, fn ($p) => (int) ($p['value'] ?? 0) < 4)); }); ``` #### Changing the service picker filterticket.related_services `ClientTickets` grouped structure Runs once the service picker shown while opening a ticket is prepared. Parameters 2 $serviceGroupsarrayby linkThe grouped picker data; each group has a label and items. Do not flatten it: the template expects the group layer. $uidintThe **active account** opening the ticket. Not the login: a sub-user may be listing another account’s services. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.related_services', 10, function (&$serviceGroups, $uid) { // Do not flatten it: the template expects the group layer. $serviceGroups[] = ['label' => 'Acme', 'items' => Acme::servicesFor($uid)]; }); ``` #### Changing the conversation thread filterticket.thread `ClientTickets` newest first Runs before the reply thread of a ticket reaches the screen. Parameters 2 $threadarrayby linkThe reply rows, **newest first**. Each carries its author, date and whether it came from staff. $ctxarrayContext: the ticket id, the login id and whether it is a guest. For a guest the login id is zero. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.thread', 10, function (&$thread, $ctx) { // The order is newest first. foreach ($thread as $i => $row) if ($row['is_staff'] ?? false) $thread[$i]['author'] = Acme::displayName($row); }); ``` #### Changing the detail page data filterticket.detail_data `AdminTickets` all the page data Runs once the ticket detail page is prepared. What you hold is **all** the data going to the template. Parameters 2 $dataarrayby linkAll the page data. Take care not to overwrite existing keys: a value lost here leaves a blank area on the page. $ticketarrayThe active ticket. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.detail_data', 10, function (&$data, $ticket) { // Do not overwrite existing keys. $data['acme_risk'] = Acme::riskScore((int) ($ticket['user_id'] ?? 0)); }); ``` #### Narrowing the list query filterticket.list_query `AdminTickets` the query builder Runs before the ticket list query is executed. What you hold is not ready rows but **the query itself**. Parameters 3 $stmtobjectby linkThe query builder; you may chain conditions onto it. $filtersarrayThe resolved filters: status, department, customer, assignment and priority. $rCountboolWhether this is the **counting** query. ? The hook runs **twice** for one listing: once to count, once for the data. Add your condition to only one and the paging stops matching the rows. Return 1 voidThe return is ignored; you chain the condition onto the builder. Listener PHP ```php Hook::add('filter:ticket.list_query', 10, function (&$stmt, $filters, $rCount) { // The hook runs TWICE: to count and for the data. Add it to both. $stmt->where('t.did', '!=', Acme::INTERNAL_DEPT); }); ``` #### Changing the list rows filterticket.list_rows `ClientTickets` per page Runs before the customer ticket list reaches the screen. Parameters 2 $rowsarrayby linkThe rows of that page: reference, subject, status, rating and department. Only the **page being viewed** arrives, not the whole list. $uidintThe active account; the rows are already scoped to it. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.list_rows', 10, function (&$rows, $uid) { // Only the page being viewed arrives. foreach ($rows as $i => $r) $rows[$i]['subject'] = Acme::shorten($r['subject'] ?? ''); }); ``` #### Narrowing a bulk action filterticket.bulk_action_ids `AdminTickets` narrowing only Runs before a bulk action is applied. This is how you keep certain tickets out of it. Parameters 2 $idsarrayby linkThe tickets to be handled. This list is for **narrowing**: remove entries. Adding one closes or deletes a ticket the administrator never picked. $actionstringThe action applied: closing, deleting and their blocking variants. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.bulk_action_ids', 10, function (&$ids, $action) { // It is for NARROWING: do not add. $ids = array_values(array_filter($ids, fn ($id) => !Acme::underLegalHold((int) $id))); }); ``` #### Changing the access groups filterticket.access_groups `ClientTickets` passed by link Runs once the access groups shown while opening a ticket are prepared. Parameters 2 $groupsarrayby linkThe visible groups: id, name and icon. $langstringThe active language of the page. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.access_groups', 10, function (&$groups, $lang) { $groups = array_values(array_filter($groups, fn ($g) => Acme::groupVisible((int) ($g['id'] ?? 0)))); }); ``` ### Pitfalls > **The list query runs twice for one listing** > > The hook is called once for the **count** and once for the **data**. Add your condition to only one and the paging stops matching the real row count: the user sees empty pages. Unless you branch on the counting flag, add it to both. > **The bulk list is for narrowing** > > Entries are **removed** through this filter. Adding one closes or deletes a ticket the administrator never picked, and the list they approved on screen parts ways with the list actually handled. ### Related Articles - Support Ticket Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Ticket Field and Email Hooks https://dev.wisecp.com/en/ticket-field-and-mail-hooks The nine hooks over custom fields and tickets arriving by email. ### Overview Two subjects meet here: the **custom fields** an operator defines, and the handling of tickets arriving by email. The email gates stand apart from other gates: your block raises no error and is **skipped silently**. It stops the unwanted message but leaves no trace, so you must record the reason yourself. ### Reference #### Stopping a custom field being saved gateticket.custom_field.save `AdminTickets` zero means new Runs before a ticket custom field is saved. Parameters 3 $idintThe id being edited; a **zero on a new record**. $didintThe department it belongs to; a **zero** makes it show in every department. $typestringThe field type: text, long text, password, select, radio or checkbox. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.custom_field.save', 10, function ($id, $did, $type) { // A zero department means it shows everywhere. if ($type === 'password' && $did === 0) return 'A password field cannot be opened to every department.'; return null; }); ``` #### Following a custom field being saved actionticket.custom_field.saved `AdminTickets` language data separate Runs after a custom field is saved. Parameters 2 $fieldarrayThe **structural** record: id, department, status and rank. Its name and description are **not here**: they live in the language record and are read separately. $isNewboolTrue when newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.custom_field.saved', 10, function ($field, $isNew) { // Name and description live in the language record, not here. Acme::syncFieldSchema((int) ($field['id'] ?? 0)); }); ``` #### Stopping a custom field deletion gateticket.custom_field.delete `AdminTickets` always a list Runs before custom fields are deleted. Parameters 1 $idarrayThe ids to be deleted. A single deletion is still a **one-element array**: a check expecting a number catches nothing. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:ticket.custom_field.delete', 10, function ($id) { // It is an array even for a single deletion. foreach ($id as $one) if (Acme::fieldHasData((int) $one)) return 'A field holding data cannot be deleted.'; return null; }); ``` #### Following a custom field being deleted actionticket.custom_field.deleted `AdminTickets` language record in hand Runs after a custom field is deleted. Parameters 2 $iintThe id of the deleted field. $flangarrayIts language record as taken **before** the deletion: name, description and options. If you need its name, this is your only source. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:ticket.custom_field.deleted', 10, function ($i, $flang) { // If you need the name, the language record is the only source. Acme::dropFieldSchema($i, $flang['name'] ?? ''); }); ``` #### Changing the custom field list filterticket.custom_fields `AdminTickets` passed by link Runs after the custom fields are read. Add a field of your own to the list here. Parameters 2 $fieldsarrayby linkThe field rows: id, department, name, type and options. $ctxarrayby linkContext: language, department and status filter. A **zero** department means every department is being asked for. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.custom_fields', 10, function (&$fields, &$ctx) { // A zero department means every department is being asked for. $fields = array_values(array_filter($fields, fn ($f) => Acme::fieldVisible((int) ($f['id'] ?? 0)))); }); ``` #### Changing the group fields filterticket.access_group_fields `AdminTickets` the language is never empty Runs after the custom fields tied to an access group are read. Parameters 2 $fieldsarrayby linkThe group-scoped field rows. $ctxarrayby linkContext: the language and the group id. Even when the call leaves it empty the language is filled in before the hook: it **never arrives empty**. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:ticket.access_group_fields', 10, function (&$fields, &$ctx) { // The language never arrives empty. $fields = Acme::orderFields($fields, $ctx['lang']); }); ``` #### Skipping an incoming email gateticket.pipe_import `cronjobs` a skip, not a failure Runs before an incoming email becomes a ticket. This is where you keep unwanted mail out of the system entirely. Parameters 3 $mailarrayThe raw email: subject, body, sender, recipient, attachments and address. $didintThe department from the source; a **zero** means it will be resolved from the address. $msgIdstringThe unique id of the message. Return 1 string|null**A non-empty text skips the email entirely**. ? Unlike other gates **nothing is thrown**: the scheduled task records it as skipped and the mail job is **not counted as failed**. Your block is silent. Record the reason yourself. Listener PHP ```php Hook::add('gate:ticket.pipe_import', 10, function ($mail, $did, $msgId) { // The veto is SILENT: record the reason yourself. if (Acme::isAutoReply($mail)) { Acme::log('auto-reply skipped', $msgId); return 'auto-reply'; } return null; }); ``` #### Stopping a ticket opening from email gateticket.open `cronjobs` a skip, not a failure Runs immediately before an incoming email becomes a ticket. The message has passed the mail gate and its department is resolved. Parameters 6 $subjectstringThe ticket title, normalised. $messagestringThe first message body. $departmentIdintThe resolved department. $clientIdintThe customer id; **zero for an unrecognised sender**. $clientEmailstringThe sender address. $mailarrayThe raw email object. Return 1 string|null**A non-empty text stops the ticket being opened**. Same shape as the mail gate: nothing is thrown, the job is recorded as skipped. Listener PHP ```php Hook::add('gate:ticket.open', 10, function ($subject, $message, $departmentId, $clientId, $clientEmail, $mail) { // For an unrecognised sender the customer id is ZERO. if (!$clientId && Acme::strangersBlocked()) return 'unrecognised sender'; return null; }); ``` #### Cleaning the text from an email filterticket.pipe_message_text `cronjobs` both by link Runs before the body and subject of an incoming email are written to a ticket. Trimming quoted blocks and signatures belongs here. Parameters 3 $messagestringby linkThe normalised body. $subjectstringby linkThe ticket subject; the reference tag is already stripped. $mailarrayThe raw email object, there for context. Return 1 voidThe return is ignored; **both** are passed by link and both may be changed. Listener PHP ```php Hook::add('filter:ticket.pipe_message_text', 10, function (&$message, &$subject, $mail) { // Both are passed by link. $message = Acme::stripQuotedReply($message); }); ``` ### Pitfalls > **The veto in the email gates is silent** > > When other gates block, an error is thrown and the user sees the reason. The email gates do not work that way: your block is recorded as **skipped**, the job is not counted as failed and nobody sees anything. It is the right place to stop unwanted mail, but without recording the reason yourself no trace remains. > **The field name lives in the language record** > > The custom field save event gives the **structural** row: id, department, status, rank. The name and description are **not** there; they sit in the language record. A listener looking for the name works with an empty value. ### Related Articles - Support Ticket Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) # Hooks / Products ## Product Lifecycle Hooks https://dev.wisecp.com/en/product-lifecycle-hooks The eight hooks over creating, updating, deleting and switching a product. ### Overview A product travels three steps: it is created, updated and deleted. Each has a gate you can stop at in front of it and an event that reports it behind. Two status hooks sit beside them. The single one fires per product; the bulk one fires **once** when an action reaches many products from the list. ### Reference #### Stopping a product being created gateproduct.create `AdminProducts` before the write Runs before a new product is created. Parameters 2 $inputarrayThe form input: type, group, category, name, module and address. $statearrayContext: the group keys, the default currency and the language keys. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.create', 10, function ($input, $state) { if (!Acme::namingOk($input['name'] ?? '')) return 'The product name breaks the naming rule.'; return null; }); ``` #### Following a product being created actionproduct.created `AdminProducts` zero means it failed Runs after a product is created. Parameters 2 $new_idintThe id of the new product. ? **A zero means no product was created**. A listener that uses the id straight away then works on a record that is not there. $inputarrayThe creation input: type, group, category, name, module and address. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.created', 10, function ($new_id, $input) { // ZERO = no product was created. if (!$new_id) return; Acme::registerCatalogItem($new_id, $input['name'] ?? ''); }); ``` #### Stopping a product update gateproduct.update `AdminProducts` before and after in hand Runs before a product is updated. You hold both the new data and the old record, so you can compare what changed. Parameters 3 $idintThe product id. $inputarrayThe new data about to be written. $detailarrayThe record as it was **before** the change. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.update', 10, function ($id, $input, $detail) { // You hold both records: measure the difference. if (($detail['module'] ?? '') !== ($input['module'] ?? '') && Acme::hasLiveServices($id)) return 'The module cannot change while live services run on it.'; return null; }); ``` #### Following a product update actionproduct.updated `AdminProducts` before and after in hand Runs after the product is updated. Parameters 3 $idintThe product id. $inputarrayThe full edit input: pricing, languages, module data and resource limits. $detailarrayThe record from **before** the update. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.updated', 10, function ($id, $input, $detail) { Acme::syncCatalog($id, $input, $detail); }); ``` #### Stopping a product deletion gateproduct.delete `AdminProducts` before the write Runs before a product is deleted. Parameters 2 $idintThe id of the product to be deleted. $detailarrayThe product record: type, module and group. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.delete', 10, function ($id, $detail) { if (Acme::hasLiveServices($id)) return 'Live services still run on this product.'; return null; }); ``` #### Following a product deletion actionproduct.deleted `AdminProducts` after deletion Runs after the product is deleted. Parameters 2 $product_idintThe id of the deleted product. $detailarrayIts last state. The record is gone: take what you need from here. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.deleted', 10, function ($product_id, $detail) { Acme::dropCatalogItem($product_id); }); ``` #### Following a product status change actionproduct.status_changed `AdminProducts` one product Runs when a product is opened for sale or closed. Parameters 2 $product_idintThe product id. $statusstringThe new state: `active` or `inactive`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.status_changed', 10, function ($product_id, $status) { Acme::setCatalogVisible($product_id, $status === 'active'); }); ``` #### Following a bulk action actionproduct.bulk_action_applied `AdminProducts` one call, many products Runs when an action is applied to several products at once from the list. Unlike the single status event, this is **one call**, not one per product. Parameters 2 $actionstringThe action applied: `active` or `inactive`. $idsarrayThe ids of the products handled. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.bulk_action_applied', 10, function ($action, $ids) { // One call, many products: the single status event does not fire here. Acme::bulkVisibility($ids, $action === 'active'); }); ``` ### Pitfalls > **A zero in the create event means it failed** > > The new product id can arrive as **zero**, which means no product was created. A listener that uses the id straight away then works on a record that is not there. Check it on the first line. > **A bulk action does not raise the single status event** > > When an action reaches many products from the list, **only the bulk event** fires. Work hung on the single status hook never runs on that path; listen on both. ### Related Articles - Product Hooks - [Service Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Product Data and Catalogue Hooks https://dev.wisecp.com/en/product-data-hooks The eight hooks over product data, the catalogue, the card lists and stock. ### Overview Product data flows two ways: through the save filter **on the way in**, and through the read filter **on the way out**. The second is a hot path and its result goes into the cache. The rest build the lists a customer sees: catalogue plans, related products, software cards. The stock hook stands apart; it is moved not by an administrator but by an **order**. ### Reference #### Changing the product data before it is saved filterproduct.save_data `AdminProducts` passed by link Runs before the product data is written. This is how you hold one pricing policy in one place. Parameters 2 $inputarrayby linkThe data to be written: type, options, module data, languages, pricing and tax. $detailarrayThe existing record before the change. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.save_data', 10, function (&$input, $detail) { // Hold the pricing policy in one place. $input['pricing'] = Acme::applyMargin($input['pricing'] ?? []); }); ``` #### Changing a product as it is read filterproduct.get `Products::get` the result is cached Runs after a product is read from the database. **Every** product read passes here: the catalogue, the basket, the service detail. Parameters 2 $productarrayby linkThe resolved product row with its options and module data unpacked. When no product was found an **empty array** arrives; look before you reach into it. $contextarrayThe id asked for and the language. Return 1 voidThe return is ignored; you write over the product. ? The array you change **goes into the cache**: a mistake here shows up not on one read but on every read that follows. Listener PHP ```php Hook::add('filter:product.get', 10, function (&$product, $context) { // A product that was not found arrives as an EMPTY array. if (!$product) return; $product['acme_badge'] = Acme::badgeFor((int) ($context['id'] ?? 0)); }); ``` #### Changing the product configuration fields filterproduct.config_options `AdminProducts` empty means no form Runs before the configuration fields offered by the module appear. Parameters 3 $config_optionsarrayby linkThe field definitions to be shown. $moduleobjectThe product module instance. $module_dataarrayThe current module data of the product. Return 1 voidThe return is ignored; you write over the definitions. If the array is **left empty** after the filter, no form appears at all: clearing the fields removes the form. Listener PHP ```php Hook::add('filter:product.config_options', 10, function (&$config_options, $module, $module_data) { // Emptying the array removes the form entirely. unset($config_options['legacy_option']); }); ``` #### Changing the catalogue plans filterproduct.catalog_plans `website/products` passed by link Runs once the plan list on the catalogue page is prepared. Ordering, hiding and badging belong here. Parameters 2 $plansarrayby linkThe plan list. $ctxarrayContext: the category, the product kind and the layout. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.catalog_plans', 10, function (&$plans, $ctx) { $plans = Acme::sortByPopularity($plans); }); ``` #### Changing the related product cards filterproduct.related_products `website/products` passed by link Runs once the related product list under a product detail is built. The core default is up to four products from the same catalogue. Parameters 2 $relatedarrayby linkThe card list. $idintby linkThe id of the product on screen; this is the "related to what" context. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.related_products', 10, function (&$related, &$id) { $related = Acme::recommendFor($id) ?: $related; }); ``` #### Changing the software card list filterproduct.software_list `website/products` passed by link Runs once the card list of the software store is prepared. Parameters 2 $outarrayby linkThe card list; each card carries a title, category, image and tags. $ctxarrayContext: the currency the cards are priced in. The currency varies by visitor: if you produce a price, use this value rather than the default. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.software_list', 10, function (&$out, $ctx) { // If you produce a price, use the currency from the context. $out = Acme::attachOffers($out, (int) ($ctx['currency'] ?? 0)); }); ``` #### Following a stock change actionproduct.stock_changed `Orders` driven by an order Runs when the stock of a product moves. The move is not manual: an **order** becoming active, or ceasing to be, drives it. Parameters 4 $product_idintThe product id. $deltaintWhich way it moved: **minus one** means an order became active and stock came down, **plus one** means an order left and stock returned. $new_stockintThe new stock written. It never goes below zero: expect no negative value. $order_idintThe order that drove the change. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.stock_changed', 10, function ($product_id, $delta, $new_stock, $order_id) { // Stock never goes below zero. if ($new_stock === 0) Acme::alertOutOfStock($product_id); }); ``` #### Changing per-country message pricing filterproduct.intl_sms_country_pricing_save `AdminProducts` passed by link Runs before international text message prices are saved. Parameters 1 $current_listarrayby linkCountry code against price: cost, selling amount, currency and status. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.intl_sms_country_pricing_save', 10, function (&$current_list) { foreach ($current_list as $code => $row) $current_list[$code]['amount'] = Acme::markup((float) ($row['cost'] ?? 0)); }); ``` ### Pitfalls > **The read filter result goes into the cache** > > A change you make in the product read filter is **kept** and comes back on later reads. A mistake here shows up not on one page but everywhere until the cache is cleared. > **An emptied configuration removes the form** > > If the array is empty after the configuration fields filter, **no form appears at all**. Clearing everything while meaning to hide one field leaves the administrator unable to configure the product. ### Related Articles - Product Hooks - [Service Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Product Group and Add-on Hooks https://dev.wisecp.com/en/product-group-hooks The seven hooks over groups, categories, add-ons and requirements. ### Overview The structures around products live here: the **groups** that hold them, the **add-ons** sold beside them and the **requirements** asked for during an order. Two shapes surprise. Group options arrive as an array or as text depending on the context. And the category gate receives not one record but a **batch**, which may include deletions. ### Reference #### Changing the group data filterproduct.group_save_data `AdminProducts` options in two shapes Runs before a product group or category is saved. Parameters 3 $set_dataarrayby linkThe data to be written: status, visibility, type and options. ? The **shape of the options field depends on the context**: an array on a constant group, text elsewhere. Reading it as an array without checking fails. $categorystringThe category context; empty for a top group, filled for a category kind. $detailarrayThe existing record; empty on a new one. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.group_save_data', 10, function (&$set_data, $category, $detail) { // Options may be an array or text: check the type first. if (!is_array($set_data['options'] ?? null)) return; $set_data['options']['acme_tag'] = Acme::tagFor($category); }); ``` #### Following a group being saved actionproduct.group_saved `AdminProducts` zero on a constant record Runs after a product group or category is saved. Parameters 3 $idintThe id of the saved record. On a constant category record it can arrive as **zero**. $is_newboolTrue when newly created. $categorystringThe category kind; empty means a top group. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.group_saved', 10, function ($id, $is_new, $category) { // On a constant category the id can be ZERO. if ($id) Acme::refreshGroupMenu($id); }); ``` #### Stopping a group deletion gateproduct.group_delete `AdminProducts` before the write Runs before a product group or category is deleted. Parameters 3 $idintThe id to be deleted. $categorystringThe category context. $detailarrayThe language record of what goes, title included. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.group_delete', 10, function ($id, $category, $detail) { if (Acme::groupHasProducts($id)) return 'A group holding products cannot be deleted.'; return null; }); ``` #### Stopping a batch category operation gateproduct.category_save `AdminProducts` a batch of operations Runs before add-on or requirement categories are saved. What arrives is not one record but **a batch**: one request can hold creations, updates and deletions together. Parameters 2 $typestringThe category kind: `addon` or `requirement`. $categoriesarrayThe operations to apply, each carrying its kind and an id or a title. A deletion can sit in this list too: a check that only looks at creations misses it. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.category_save', 10, function ($type, $categories) { // The batch may hold DELETIONS too: walk all of them. foreach ($categories as $op) if (($op['action'] ?? '') === 'delete' && Acme::categoryInUse((int) ($op['id'] ?? 0))) return 'A category in use cannot be deleted.'; return null; }); ``` #### Changing the add-on data filterproduct.addon_save_data `AdminProducts` passed by link Runs before a product add-on is saved. Parameters 2 $addonDataarrayby linkThe add-on data to be written: category, status, rank, tax exemption and the product it is tied to. $detailarrayThe existing record; empty on a new one. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.addon_save_data', 10, function (&$addonData, $detail) { $addonData['status'] = Acme::allowedAddon($addonData) ? ($addonData['status'] ?? 0) : 0; }); ``` #### Stopping an add-on deletion gateproduct.addon_delete `AdminProducts` before the write Runs before a product add-on is deleted. Parameters 2 $idintThe id of the add-on to be deleted. $detailarrayThe add-on record. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.addon_delete', 10, function ($id, $detail) { if (Acme::addonInUse($id)) return 'An add-on live on a customer cannot be deleted.'; return null; }); ``` #### Stopping a requirement deletion gateproduct.requirement_delete `AdminProducts` before the write Runs before a product requirement is deleted. Requirements are what a customer is asked for during an order, so removing one touches **orders already waiting**. Parameters 2 $idintThe id of the requirement to be deleted. $detailarrayThe requirement record. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.requirement_delete', 10, function ($id, $detail) { if (Acme::pendingOrdersNeed($id)) return 'Orders still waiting use this requirement.'; return null; }); ``` ### Pitfalls > **The shape of group options depends on context** > > The options field arrives as an **array** on a constant group record and as **text** otherwise. Reading it as an array without checking raises an error; test the type on the first line. > **The category gate gets a batch, not one record** > > One request can hold creations, updates and **deletions** together. A check that only looks at creations misses the deletion sitting in the same request. ### Related Articles - Product Hooks - [Service Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Server Record Hooks https://dev.wisecp.com/en/server-record-hooks The eight hooks over server records, server groups and imports. ### Overview Server records hold the details of the machines services are built on. Saving, updating, deleting and importing all live here. ? One distinction matters: **in the save filter the password is in the clear**, while in the events it is encrypted. The filter sits ahead of the encryption. ### Reference #### Changing the server data before it is saved filterproduct.server_save_data `AdminProducts` password IN THE CLEAR Runs before a server record is written. This is the point **before** the access details are encrypted. Parameters 3 $set_dataarrayby linkThe server data to be written: name, address, username, **password**, name servers, capacity and access key. ? The password is **in the clear** here (encryption happens after this filter). Writing the record as it stands into a log, an outside service or a table of your own leaks that password in plain text. $typestringThe server module type. $detailarrayThe existing record; empty on a new one. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.server_save_data', 10, function (&$set_data, $type, $detail) { // THE PASSWORD IS IN THE CLEAR HERE: do not write the record anywhere as it stands. $set_data['ns1'] = Acme::defaultNs(1); $set_data['ns2'] = Acme::defaultNs(2); }); ``` #### Following a server being added actionproduct.server_created `AdminProducts` password encrypted Runs after a new server record is created. Parameters 3 $idintThe id of the new server. $set_dataarrayThe saved data. Unlike in the filter the password here is **encrypted**: you cannot read and use it. $typestringThe server module type. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.server_created', 10, function ($id, $set_data, $type) { // The password is encrypted here: give monitoring only the address. Acme::addMonitor($id, $set_data['ip'] ?? ''); }); ``` #### Following a server update actionproduct.server_updated `AdminProducts` before and after in hand Runs after a server record is updated. Parameters 4 $idintThe server id. $set_dataarrayThe new data; the password is encrypted. $typestringThe new module type. $detailarrayThe record from **before** the update, there to measure the difference. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.server_updated', 10, function ($id, $set_data, $type, $detail) { // Move monitoring when the address changed. if (($detail['ip'] ?? '') !== ($set_data['ip'] ?? '')) Acme::moveMonitor($id, $set_data['ip'] ?? ''); }); ``` #### Stopping a server deletion gateproduct.server_delete `AdminProducts` before the write Runs before a server record is deleted. The record goes; **the machine and the accounts on it stay**. Parameters 2 $idintThe id of the server to be deleted. $detailarrayThe server record: name, address and type. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.server_delete', 10, function ($id, $detail) { // The record goes but the accounts on the machine remain. if (Acme::serverHasServices($id)) return 'Live services still run on this server.'; return null; }); ``` #### Following a server being deleted actionproduct.server_deleted `AdminProducts` after deletion Runs after the server record is deleted. Parameters 2 $iintThe id of the deleted server. $detailarrayThe record as it stood at deletion. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.server_deleted', 10, function ($i, $detail) { Acme::dropMonitor($i); }); ``` #### Following an import from a server actionproduct.server_imported `AdminProducts` a list of summary text Runs after the accounts on a server are brought in as services. Parameters 2 $serverarrayThe server record the import ran against. $importedarrayA summary of the services created. Each entry is **text**, not a record: it holds the name and id together. If you need the id, query the service rather than parsing the text. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:product.server_imported', 10, function ($server, $imported) { // The entries are TEXT, not records. Acme::notifyOps(count($imported) . ' services imported from ' . ($server['name'] ?? '')); }); ``` #### Changing server group data filterproduct.server_group_save_data `AdminProducts` servers as a text list Runs before a server group is saved. Parameters 2 $dataarrayby linkThe group data to be written: name, fill type and its servers. The server list is **comma-separated text, not an array**: split it before treating it as one. $detailarrayThe existing record; empty on a new one. Return 1 voidThe return is ignored; you write over the data. Listener PHP ```php Hook::add('filter:product.server_group_save_data', 10, function (&$data, $detail) { // The server list is COMMA TEXT, not an array. $ids = array_filter(explode(',', (string) ($data['servers'] ?? ''))); $data['servers'] = implode(',', Acme::onlyHealthy($ids)); }); ``` #### Stopping a server group deletion gateproduct.server_group_delete `AdminProducts` before the write Runs before a server group is deleted. Parameters 2 $idintThe id of the group to be deleted. $detailarrayThe group record. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:product.server_group_delete', 10, function ($id, $detail) { if (Acme::groupBoundToProducts($id)) return 'Products are still bound to this group.'; return null; }); ``` ### Pitfalls > **In the save filter the password is in the clear** > > The server save filter runs **before the encryption**: the password field holds plain text. Writing that array to a log, sending it to an outside service or copying it into a table of your own leaks the server password in the clear. In the events the same field is encrypted. > **Deleting the record does not empty the machine** > > The deletion removes only the **record**. The accounts, domains and data on the server stay where they are; the system stops knowing about them. The delete gate is your last chance to notice. ### Related Articles - Product Hooks - [Service Hooks](https://dev.wisecp.com/en/service-lifecycle-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) # Hooks / Invoicing and Payment ## Invoice Lifecycle Hooks https://dev.wisecp.com/en/invoice-lifecycle-hooks Eight hooks from an invoice being raised to it being deleted: the creation gate, two separate creation events, status changes and deletion. ### Overview The first thing to watch on the invoice hooks is that there are **two separate creation events**. One runs on renewal invoices alone, the other on **every** invoice. Their names look alike and they are often mixed up. The second is where the money sits: raising an invoice is not taking payment. Payment arrives on its own hooks and the status settles when it turns **paid**. ### Reference #### Skipping a renewal invoice gateinvoice.create `cronjobs/InvoiceGenerate` the job returns cancelled Runs before a renewal invoice is raised. This gate sits in a scheduled task, so stopping it **raises no error** and skips the job. Parameters 3 $target_typestring`service` or `addon`. $target_idintThe id of the record being invoiced. $duedatestringThe renewal due date. Return 1 stringA non-empty string **skips** the invoice: the job ends as cancelled and your text is recorded as **the reason**. The customer sees nothing; the invoice is never raised. Listener PHP ```php Hook::add('gate:invoice.create', 10, function ($target_type, $target_id, $duedate) { // Raising a renewal while a cancellation is open only annoys the customer. if ($target_type === 'service' && Acme::cancelPending($target_id)) return 'A cancellation is open; no renewal invoice was raised.'; return null; }); ``` #### Changing the invoice record before it is written filterinvoice.create_payload `Hook::runRefs` by reference Runs before the invoice row is written to the database. Parameters 1 $dataarrayrefThe record about to be written: `user_id`, `user_data`, `currency`, `status`, the totals, `pmethod`. The JSON fields are **already encoded** here; do not expect a raw array. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:invoice.create_payload', 10, function (&$data) { // Add your own reference; the JSON fields arrive encoded already. $data['notes'] = trim(($data['notes'] ?? '') . ' ' . Acme::stamp()); }); ``` #### Catching every invoice actioninvoice.created.any `Invoices::create()` all of them Runs after **every** invoice is written: renewals, manual ones, order invoices, all of them. Parameters 2 $invoice_idintThe new invoice id. Where the write failed it arrives as `0`, so check before using it. $dataarrayThe data that was written, with its JSON fields encoded. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.created.any', 10, function ($invoice_id, $data) { if ($invoice_id === 0) return; // the write failed Accounting::mirror($invoice_id, $data); }); ``` #### Catching a renewal invoice actioninvoice.created `cronjobs/InvoiceGenerate` renewals only Runs after a renewal invoice was raised. **Not for every invoice**: manual and order invoices do not arrive here. Parameters 1 $invoice_idintThe id of the invoice raised or merged into. Several renewals can have been merged into one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.created', 10, function ($invoice_id) { // These are renewals ONLY; for all invoices use the .any hook. Dunning::scheduleReminder((int) $invoice_id); }); ``` #### Catching a manually raised invoice actioninvoice.created_manually `AdminInvoices` an operator raised it Runs where an operator raised an invoice from the panel. Parameters 2 $invoice_idintThe id of the raised invoice. $dataarrayThe invoice data. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.created_manually', 10, function ($invoice_id, $data) { // A manual invoice is reported apart from the automatic ones. Accounting::tagManual((int) $invoice_id); }); ``` #### Stopping an invoice status change gateinvoice.status_change `Invoices::change_status()` both states Runs before an invoice status changes. Parameters 4 $invoicearrayThe invoice record, decoded. $statusstringThe target: `paid`, `unpaid`, `cancelled`, `refund`, `waiting`. $old_statusstringThe current status. $optionsarrayThe transition options: notification, payment method, refund route, who is acting. Context you cannot change. Return 1 stringA non-empty string **stops** the change; the text is thrown as the error. Listener PHP ```php Hook::add('gate:invoice.status_change', 10, function ($invoice, $status, $old_status, $options) { // Taking a paid invoice back breaks the books. if ($old_status === 'paid' && $status === 'unpaid') return 'A paid invoice cannot go back to unpaid.'; return null; }); ``` #### Following an invoice status actioninvoice.status_changed `Invoices::change_status()` a freshly read record Runs after the status was written. Parameters 4 $invoicearrayThe invoice **read afresh** after the write. Unlike many other hooks, the record here is **current**. $statusstringThe new status. $old_statusstringThe previous status. $optionsarrayThe transition options: payment method, refund route, who acted, notification. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.status_changed', 10, function ($invoice, $status, $old_status, $options) { // Did money REALLY arrive? React to the move into paid alone. if ($status === 'paid' && $old_status !== 'paid') Accounting::settled((int) ($invoice['id'] ?? 0), $options['pmethod'] ?? ''); }); ``` #### Stopping an invoice delete gateinvoice.delete `Invoices::delete()` it may be a legal record Runs before an invoice is deleted. Parameters 2 $idintThe id of the invoice to be deleted. $invoicearrayThe invoice record: `status`, `taxed`, `total`. Deleting a formalised invoice leaves no legal trail; check these fields. Return 1 stringA non-empty string **stops** the delete; the text is thrown as the error. Listener PHP ```php Hook::add('gate:invoice.delete', 10, function ($id, $invoice) { // A formalised invoice is not deleted; it is cancelled. if (!empty($invoice['taxed'])) return 'A formalised invoice cannot be deleted.'; return null; }); ``` #### Following an invoice deletion actioninvoice.deleted `Invoices::delete` after deletion Runs after an invoice is deleted. The record is gone, so the snapshot you hold is the last copy of it. Parameters 2 $idintThe id of the deleted invoice. $invoicearrayThe snapshot from **before** the deletion. You cannot go back to the database for it; take what you need from here. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.deleted', 10, function ($id, $invoice) { // The record is gone: take what you need from the snapshot. Acme::voidInAccounting($id, $invoice['number'] ?? ''); }); ``` #### Following lines being split off actioninvoice.items_split `AdminInvoices` two invoices Runs after lines are moved from one invoice into a new one. There are two invoices now. Parameters 3 $source_invoice_idintThe invoice the lines came from. $new_invoice_idintThe newly created invoice. $item_idsarrayThe ids of the lines that moved. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.items_split', 10, function ($source_invoice_id, $new_invoice_id, $item_ids) { // Both invoices need to reach accounting separately. Acme::resync($source_invoice_id); Acme::resync($new_invoice_id); }); ``` #### Following a renewal invoice being made actioninvoice.renewal_generated `generate_renewal` renewal Runs after a renewal invoice is made, or after a line joins one that already exists. Both services and add-ons land here. Parameters 3 $invoice_idintThe id of the invoice made or joined. $target_typestring`service` or `addon`. $targetarrayThe record being renewed. One invoice can take several lines: the hook fires per line while the invoice id stays the same. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.renewal_generated', 10, function ($invoice_id, $target_type, $target) { // The same invoice can arrive more than once: make this safe to repeat. Acme::noteRenewal($invoice_id, $target_type, (int) ($target['id'] ?? 0)); }); ``` #### Following a payment reminder actioninvoice.reminder_sent `cronjobs` on each reminder Runs after a payment reminder goes out. It can fire more than once for the same invoice, because reminders repeat. Parameters 1 $invoicearrayThe invoice reminded about: its number, owner, total, status and due date. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.reminder_sent', 10, function ($invoice) { // Nudge over a second channel as well. Acme::smsReminder((int) ($invoice['user_id'] ?? 0), $invoice['number'] ?? ''); }); ``` #### Following a customer opening an invoice actioninvoice.viewed_by_client `website/invoices` on every opening Runs when a customer views an invoice. Knowing an invoice was opened is a useful signal for reminder and collection flows. Parameters 3 $invoicearrayThe invoice record. $idintThe id of the invoice. $ctxarrayThe viewing context. To add data to the template use the view data filter that runs right after, not this hook. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.viewed_by_client', 10, function ($invoice, $id, $ctx) { // Knowing it was read softens the collection flow. Acme::markSeen($id, (int) ($invoice['user_id'] ?? 0)); }); ``` #### Stopping a status change gateinvoice.update_status `AdminInvoices` before the write Runs while an administrator changes an invoice status by hand. Marking paid, cancelling and refunding all pass through this gate. Parameters 4 $idintThe invoice id. $statusstringThe target status: `paid`, `unpaid`, `refund` or `cancelled`. $pmethodstringThe method chosen when marking as paid. $refund_methodstringThe method chosen when refunding. Return 1 string|null**A non-empty text blocks the change** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:invoice.update_status', 10, function ($id, $status, $pmethod, $refund_method) { // No manual refunds once the period is closed. if ($status === 'refund' && Acme::periodClosed($id)) return 'This period is closed, so the refund belongs in accounting.'; return null; }); ``` #### Stopping a share link gateinvoice.share_view `ClientInvoices` access without login Runs when an invoice is opened through a share link. Whoever opens it **may not be logged in**: the only thing standing behind that access is the secret in the address. Parameters 5 $invoicearrayThe resolved invoice record. $idintThe invoice id. $ownerintThe account that owns the invoice. $tokenstringThe share value. Keep it out of your own records: whoever sees it can open both the invoice and the payment page. $isOwnerboolWhether the opener is the signed-in owner. A false here means access without a session; tighten your check accordingly. Return 1 string|null**A non-empty text cuts the access**: the view becomes a not-found page and the payment is blocked. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:invoice.share_view', 10, function ($invoice, $id, $owner, $token, $isOwner) { // Hold access without a session to your own address list. if (!$isOwner && !Acme::ipAllowed()) return 'access denied'; return null; }); ``` #### Changing the customer details on an invoice filterinvoice.client_details `AdminInvoices` passed by link Runs once the customer and address details are prepared for an invoice, before they are saved. What you write here becomes a permanent part of the invoice. Parameters 2 $merged_dataarrayby linkWhat goes onto the invoice: name, surname, email, tax number, address. $invoicearrayThe invoice being edited. Return 1 voidThe return is ignored; you write over the array. An invoice is an archive document: a wrong value written here does not correct itself later, it stays with the invoice. Listener PHP ```php Hook::add('filter:invoice.client_details', 10, function (&$merged_data, $invoice) { // Match the company title to the official name in accounting. $official = Acme::officialName($merged_data['company_tax_number'] ?? ''); if ($official !== '') $merged_data['company'] = $official; }); ``` ### Pitfalls > **Two creation events, two different scopes** > > The plainly named event runs on **renewals only**, while the one ending in `.any` runs on **every** invoice. An accounting mirror, an outside sync or anything wanting a full count belongs on `.any` — otherwise manual and order invoices slip past quietly. > **A zero id means the write failed** > > The `.any` event runs **even where the database write failed**, with the id arriving as `0`. Using it unchecked means writing against an invoice that does not exist. > **Raising an invoice is not taking money** > > On the creation hooks the invoice is **unpaid**. Opening a service, switching an add-on on or saying "thank you" does not belong here. Money arrives when the status turns **paid**. > **A change to the same status still runs the hook** > > The status event can run even where the new and old statuses are **the same**. You see it where a payment page is called twice or a job runs again. A listener reacting **without comparing** the two does its work twice. ### Related Articles - Invoice and Payment Hooks - [Service Renewal Hooks](https://dev.wisecp.com/en/service-renewal-hooks) - Order Flow Hooks ## Payment Hooks https://dev.wisecp.com/en/payment-hooks The seven hooks where money truly arrives: the payment gates, the recorded payment row and automatic collection. ### Overview An invoice does not have to be paid **in one go**: part payments add up and the invoice turns paid on its own once the balance clears. So the payment hooks run **more often** than the invoice status hook. The second point is who paid. On a payment made through a share link **the payer has no identity**; the account id the hook hands you is the invoice's **owner**, not the person paying. ### Reference #### Stopping a customer payment gateinvoice.client_pay `ClientInvoicePay` before money moves Runs while a customer pays an invoice, before **any money moves**. Parameters 4 $invoicearrayThe invoice record. The currency sits on the record itself; no separate parameter arrives. $uidintThe invoice **owner**. Still the owner on a shared payment; it never points at the payer. $methodarrayThe resolved payment method: the flow, the fee rate, the raw balance, bank accounts, gateway details. $shareboolWhether this is a payment through a share link. With `true` there is **no session** and the payer is unknown. Return 1 stringA non-empty string **stops** the payment: no fee is booked, no balance is taken, and no payment record is opened. Listener PHP ```php Hook::add('gate:invoice.client_pay', 10, function ($invoice, $uid, $method, $share) { // On a shared payment the payer is unknown: shape your risk rules for that. if ($share && (float) ($invoice['total'] ?? 0) > 5000) return 'This amount cannot be paid through a share link.'; return null; }); ``` #### Stopping a bulk payment gateinvoice.bulk_pay `ClientInvoicePay` the currency arrives separately Runs while a customer pays several invoices together. Parameters 3 $invoicesarrayThe invoices about to be paid. $cidintThe currency of the selection. Unlike the single payment gate it arrives as **its own parameter**, because the selection is gathered in one currency. $methodarrayThe resolved payment method. Return 1 stringA non-empty string **stops** the bulk payment. Listener PHP ```php Hook::add('gate:invoice.bulk_pay', 10, function ($invoices, $cid, $method) { if (count($invoices) > 50) return 'At most 50 invoices are paid at once.'; return null; }); ``` #### Changing the payment row before it is written filterinvoice.payment_data `Hook::runRefs` by reference Runs before the payment row is written. **After that row** the invoice can turn paid on its own. Parameters 2 $payment_rowarrayrefThe payment about to be written: `owner_id`, `amount_in`, `currency`, `rate`, `fees`, `pmethod`, `transaction_id`, `paid_at`. $invoicearrayThe invoice the payment applies to. Return 1 voidThe value changes **by reference**; the return is not read. Changing the amount decides whether the invoice counts as paid. Listener PHP ```php Hook::add('filter:invoice.payment_data', 10, function (&$payment_row, $invoice) { // Carry your own reference; changing the amount decides the paid outcome. $payment_row['transaction_id'] = Acme::ref($payment_row['transaction_id'] ?? ''); }); ``` #### Learning that a payment was recorded actioninvoice.payment_recorded `Invoices` it can be partial Runs after the payment row was written. The invoice can **still be unpaid** at this point. Parameters 3 $payment_idintThe id of the new payment record. $invoice_idintThe invoice the payment went to. $paymentarrayThe recorded payment: amount, currency, rate, method, transaction number, time, fees, who recorded it. The amount is **not necessarily the whole invoice**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.payment_recorded', 10, function ($payment_id, $invoice_id, $payment) { // It can be partial: check the balance before saying "invoice closed". Accounting::received($invoice_id, (float) ($payment['amount_in'] ?? 0)); }); ``` #### Following a payment added actioninvoice.payment_added `AdminInvoices` flat parameters Runs after a payment was added to an invoice. Unlike the previous hook the values arrive **separately**. Parameters 5 $invoice_idintThe invoice id. $amountfloatThe amount, in the **invoice's** currency. $currencyIdintThe id of the invoice currency. $pmethodstringThe payment method. $txn_idstringThe external transaction number. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.payment_added', 10, function ($invoice_id, $amount, $currencyId, $pmethod, $txn_id) { Accounting::line($invoice_id, (float) $amount, (int) $currencyId, $pmethod); }); ``` #### Following a payment deleted actioninvoice.payment_deleted `AdminInvoices` the invoice id can be 0 Runs after a payment record was deleted. Parameters 2 $payment_idintThe id of the deleted payment. $invoice_idintThe invoice it belonged to. Where the payment was not tied to one it arrives as `0`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.payment_deleted', 10, function ($payment_id, $invoice_id) { if ($invoice_id === 0) return; // tied to no invoice Accounting::reversed($invoice_id, (int) $payment_id); }); ``` #### Following an automatic collection actioninvoice.auto_payment_attempted `cronjobs/InvoiceAutoPayment` the attempt outcome Runs after an automatic payment was attempted — **whether it worked or not**. Parameters 3 $invoice_idintThe invoice attempted. $final_statusstringThe outcome: `paid` or `unpaid`. Read the attempt's success from here. $resultarrayThe step-by-step outcome: the balance step and the card step separately (`outcome`, `amount`, `error`), plus the starting and closing balance. Why a card failed is answered here. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.auto_payment_attempted', 10, function ($invoice_id, $final_status, $result) { // The card step's error arrives apart: tell the customer the real reason. if ($final_status !== 'paid') Dunning::failed($invoice_id, $result['card_step']['error'] ?? ''); }); ``` #### Following a bank transfer notice actioninvoice.bank_transfer_notified `ClientInvoicePay` the customer says so Runs when a customer says they sent a transfer. **No money has arrived**: this is a claim, and the invoice stays unpaid. Parameters 3 $idintThe id of the invoice the notice is about. $uidintThe **owner** of the invoice. With a share link somebody else may have sent the notice; this value still points at the owner. $transferarrayThe notice payload: bank id and name, sender name and the **transfer reference**. That reference is the key for matching a bank statement: the invoice number for a single payment, one shared value for a bulk one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.bank_transfer_notified', 10, function ($id, $uid, $transfer) { // No money yet, only a claim: match the statement on the reference. Acme::watchStatement($transfer['rce'] ?? '', $id); }); ``` #### Following a cash record actioninvoice.cash_recorded `AdminMoney` cash record Runs when an income or expense record is entered into the books by hand. Parameters 4 $inex_idintThe id of the record created. $typestring`income` or `expense`. $amountfloatThe amount, stripped of formatting. $currencyintThe id of the currency. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.cash_recorded', 10, function ($inex_id, $type, $amount, $currency) { Acme::postToLedger($inex_id, $type, $amount, $currency); }); ``` #### Following a refund through the gateway actioninvoice.refunded_via_module `AdminInvoices` through the gateway Runs when an invoice is refunded through the payment gateway. The money really went back. Parameters 2 $invoicearrayThe refunded invoice. $pmethodstringThe payment module that handled it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.refunded_via_module', 10, function ($invoice, $pmethod) { Acme::recordRefund((int) ($invoice['id'] ?? 0), $pmethod); }); ``` #### Following a payment method change actioninvoice.gateway_changed `AdminInvoices` method change Runs after the payment method on an invoice changes. Parameters 4 $idintThe invoice id. $oldPmethodstringThe previous method; empty when none was set. $newPmethodstringThe new method. $invoicearrayThe invoice record **as it was before the change**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.gateway_changed', 10, function ($id, $oldPmethod, $newPmethod, $invoice) { // Close the pending session at the old gateway. if ($oldPmethod !== '') Acme::dropPendingSession($id, $oldPmethod); }); ``` #### Following the auto-payment order of a card actionpayment.card_autopay_changed `AccountCards` chain change Runs when a card moves within the auto-payment chain. That chain decides which card is tried first at renewal. Parameters 3 $uidintThe owner of the card. $cardIdintThe id of the record. $actionstringWhat happened: `on` joined the chain, `off` left it, `promote` moved to the front. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.card_autopay_changed', 10, function ($uid, $cardId, $action) { // An empty chain leaves the customer with no way to renew. if ($action === 'off') Acme::warnIfChainEmpty($uid); }); ``` #### Following a subscription charge actionsubscription.payment_recorded `SubscriptionPoll` from the agreement Runs after a subscription charge from the gateway is processed. The result need not be a success: rejected and repeated notices land here too. Parameters 2 $identifierstringThe subscription identifier from the gateway. $resultarrayThe outcome: its status, the invoice and payment ids, and a reason when there is one. The status may be `paid`, but it may equally be partial, duplicated or rejected. Do not assume success. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:subscription.payment_recorded', 10, function ($identifier, $result) { // Do not assume success: the status may be a rejection. if (($result['status'] ?? '') !== 'paid') return; Acme::confirmCharge($identifier, (int) ($result['invoice_id'] ?? 0)); }); ``` ### Pitfalls > **A payment record does not mean the invoice closed** > > Part payments add up as separate rows and the hook runs **on each**. The invoice turns paid only once the balance clears, and that moment shows on the **status hook**. Writing "payment arrived, open the service" here opens it **on an underpayment**. > **On a shared payment the payer is unknown** > > The account id on the payment gate is **always the invoice owner**. The person paying through a share link can be somebody else entirely, with no session. Answering "who paid this" from that id points at **the wrong person**. > **The two payment events have different shapes** > > One hands the payment over **in a single array**, the other gives the amount, currency, method and transaction number as **separate parameters**. Writing the wrong one drops the listener and looks like "it does not work". > **Automatic collection has two steps** > > The balance is tried first, then the card. The result array reports **both separately**. Before telling a customer "your card was declined", read what the balance step did: the invoice may have been partly cleared from it. ### Related Articles - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - Invoice and Payment Hooks - Order Flow Hooks ## Invoice Amount Hooks https://dev.wisecp.com/en/invoice-amount-hooks The eight hooks touching the numbers on an invoice: the items, the totals, the late fee, the renewal price and formalising. ### Overview These hooks decide **what the customer pays**. They all work by reference and the value you write goes straight onto the invoice; a mistake in arithmetic here **becomes money**. The last two differ: formalising turns an invoice into a **legal record**. After that the amount cannot change, the invoice cannot be deleted and its number is fixed. ### Reference #### Changing the invoice totals filterinvoice.totals `Invoices` before they are saved Runs after the totals were worked out, before they are written to the invoice. Parameters 3 $totalsarrayrefThe totals about to be written: `subtotal`, `tax`, `additional_tax`, `pmethod_commission`, `total`, `discounts`. Changing one alone leaves the figures **out of step**; touching the subtotal means fixing the grand total too. $invoicearrayThe invoice row: the tax rate, whether it is formalised, the currency, the commission rate. Context you cannot change. $itemsarrayThe items used in the arithmetic. Context you cannot change. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:invoice.totals', 10, function (&$totals, $invoice, $items) { // Having changed one figure, go over the GRAND TOTAL as well. if (Acme::roundUp($invoice)) { $totals['total'] = ceil((float) $totals['total']); } }); ``` #### Changing the invoice discounts filterinvoice.items `AdminInvoices` the discount payload Runs while an invoice is edited, before the discounts are saved. Parameters 4 $idintThe id of the invoice being edited. $invDiscountsarrayrefThe discounts about to be saved, including the per-item custom ones. Despite the name this is the **discount structure**, not a list of items. $pendingCustomDiscountsarrayThe custom discounts keyed by item id. $invoicearrayThe current invoice row. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:invoice.items', 10, function ($id, &$invDiscounts, $pendingCustomDiscounts, $invoice) { // The name misleads: this is the discount structure, not the items. Acme::capDiscounts($invDiscounts, (float) ($invoice['subtotal'] ?? 0)); }); ``` #### Changing the late fee filterinvoice.late_fee_amount `cronjobs/InvoiceLateFee` rounded afterwards Runs after the late fee was worked out, before it is added to the invoice as an item. Parameters 3 $feefloatrefThe raw late fee in the invoice's currency. It is **rounded again** after the filter, so no need to fuss over the last decimal here. $invoicearrayThe invoice row: the subtotal, currency and owner. $cyclestringThe fee cycle: once, or daily. On a daily cycle this hook runs **every day**. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:invoice.late_fee_amount', 10, function (&$fee, $invoice, $cycle) { // On a daily cycle this runs every day: hold it under a ceiling. $cap = (float) ($invoice['subtotal'] ?? 0) * 0.2; if ($fee > $cap) $fee = $cap; }); ``` #### Following a late fee added actioninvoice.late_fee_applied `cronjobs/InvoiceLateFee` an item was created Runs after the late fee was added to the invoice as an item. Parameters 4 $invoice_idintThe invoice the fee went on. $fee_amountfloatThe amount added — the **final** value after the filter. $fee_typestring`percentage` or `fixed`. $item_idintThe id of the created invoice item. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.late_fee_applied', 10, function ($invoice_id, $fee_amount, $fee_type, $item_id) { // Tell the customer the invoice grew; a silent rise becomes a complaint. Notify::lateFee($invoice_id, (float) $fee_amount); }); ``` #### Changing the renewal price filterinvoice.renewal_amount `Invoices` the unit price Runs after a service's renewal price was resolved. Parameters 1 $resultarrayrefThe price result: `amount` (the **unit** price), `quantity`, `currency`, `taxexempt`, `additional_taxes`, `discounts`, `pricing_source`, `period_time`. The amount is **per unit**; the total is multiplied afterwards. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:invoice.renewal_amount', 10, function (&$result) { // amount is the UNIT price: the core multiplies by quantity. if (($result['pricing_source'] ?? '') === 'locked') return; $result['amount'] = Acme::loyaltyPrice((float) $result['amount']); }); ``` #### Changing the renewal description filterinvoice.renewal_description `Invoices` it shows on the invoice Runs after the renewal item's description was built. Parameters 1 $descriptionstringrefThe item description. The text the customer reads on the invoice, so write it in their language. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:invoice.renewal_description', 10, function (&$description) { $description .= ' — ' . Acme::periodNote(); }); ``` #### Stopping an invoice being formalised gateinvoice.formalize `AdminInvoices` no way back Runs before an invoice is formalised. After this step it can be **neither changed nor deleted**. Parameters 2 $invoicearrayThe invoice about to be formalised. $user_idintThe id of the person acting. Return 1 stringA non-empty string **stops** the formalising; the text is thrown as the error. Listener PHP ```php Hook::add('gate:invoice.formalize', 10, function ($invoice, $user_id) { // Formalising cannot be undone: not with tax details missing. if (!Acme::taxDetailsComplete($invoice)) return 'Complete the tax details before formalising.'; return null; }); ``` #### Following a formalised invoice actioninvoice.formalized `AdminInvoices` read afresh Runs after the invoice was formalised. Parameters 2 $invoicearrayThe invoice **read afresh** with its formalised mark, including the document file where one was produced. $user_idintThe id of the person who acted. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.formalized', 10, function ($invoice, $user_id) { // A legal record now: send it to the books here, not earlier. Accounting::submit($invoice); }); ``` #### Changing the list summary cards filterinvoice.list_stats `admin/invoices` passed by link Runs after the summary cards above the invoice list are worked out. What you change reaches both the cards and the visual hook that adds to that area. Parameters 2 $initial_statsarrayby linkThe card data: unpaid, paid and overdue, each with a formatted total and a count. $stats_cardsarrayThe card setup: type and period. Return 1 voidThe return is ignored; you write over the array. Listener PHP ```php Hook::add('filter:invoice.list_stats', 10, function (&$initial_stats, $stats_cards) { // Keep reseller invoices out of the summary. $initial_stats['unpaid']['formatted'] = Acme::excludeResellers($initial_stats['unpaid']); }); ``` #### Changing the invoice document font filterinvoice.pdf_font `Invoices::create_pdf` through the object Runs while the invoice document is built. The default font cannot draw letters outside the Latin alphabet, so this is where you supply the right one. Parameters 2 $pdfobjectThe document builder. Being an object it changes without a by-link mark: calling a method on it is enough. $invoicearrayThe invoice record. It carries the customer’s stored language; it is a copy, so changing it has no effect. Return 1 voidThe return is ignored. You make the change by calling a method on the object. Listener PHP ```php Hook::add('filter:invoice.pdf_font', 10, function ($pdf, $invoice) { // The default font cannot draw letters outside the Latin alphabet. if (($invoice['user_data']['lang'] ?? '') === 'ru') $pdf->setDefaultFont('dejavusans'); }); ``` #### Adding a payment method logo registerinvoice.module_logos `templates/admin` returns one URL Runs while the payment method badges are drawn on the invoice screen. Add the logo of your own method here. Parameters 0 —It takes no parameters. Return 1 string|nullYou return **a single address**, not markup: the template places it in an image. Return `null` when you have no logo to show. Empty text, zero and false are filtered out anyway. Listener PHP ```php Hook::add('register:invoice.module_logos', 10, function () { // Return a single address, not markup. return Acme::assetUrl('acme-pay.svg'); }); ``` #### Following a customer applying a coupon actioninvoice.coupon_applied_by_client `ClientInvoices` applied by the customer Runs after a customer applies a coupon to their own invoice. The discount is already worked in. Parameters 4 $uidintThe customer who applied it. $idintThe invoice id. The invoice itself is left out on purpose: by the time the hook runs the totals have moved, so a copy would be stale. Read it fresh if you need it. $couponarrayThe coupon record. Its amount field may have shifted during the application. $couponCtxarrayWhat the application produced: the discount, the currency and the lines it touched. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:invoice.coupon_applied_by_client', 10, function ($uid, $id, $coupon, $couponCtx) { // The invoice row is left out on purpose: read it fresh if you need it. Acme::trackDiscount($uid, $coupon['code'] ?? '', (float) ($couponCtx['discount'] ?? 0)); }); ``` ### Pitfalls > **Changing one figure does not fix the rest** > > Changing the subtotal on the totals filter and leaving the grand total as it was makes the invoice **disagree with itself**: the customer reads one figure and the payment asks for another. Touching one field means going over **the ones tied to it**. > **The discount filter is mistaken for an item list** > > The name suggests items, and yet this filter hands you the **discount structure**. A listener expecting a list of items finds an array it does not know and quietly writes in the wrong place. Check what the second parameter is **from its entry**. > **A daily late fee runs every day** > > Where the cycle is daily, the filter and the event run **again each day**. A rule with no ceiling turns an unpaid invoice **unpayable** within weeks. Sending a notification, mind that it does not go out daily either. > **Formalising is a point of no return** > > A formalised invoice **cannot be deleted and its amount cannot change**. Sending to the books, reserving a number and archiving belong on the **formalised event**; work written earlier treats an invoice that is later cancelled as a legal record. ### Related Articles - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [Payment Hooks](https://dev.wisecp.com/en/payment-hooks) - [Service Renewal Hooks](https://dev.wisecp.com/en/service-renewal-hooks) ## Payment Gateway Hooks https://dev.wisecp.com/en/gateway-hooks The eight hooks talking to a payment gateway: settlement, callbacks, stored cards and subscription collection. ### Overview Talking to a gateway runs **both ways**. We ask for a charge, and the gateway answers either at once or later as a **callback**. That second route can arrive from a browser or straight from their servers. On stored cards the card number **never reaches us**: the gateway keeps a token and we hold only the display details (last four digits, brand, expiry). Those are all the hooks receive. ### Reference #### Following a settlement actionpayment.settled `PaymentGatewayModule` it can be pending Runs after the settlement with the gateway completed. Parameters 4 $modulePaymentGatewayModuleThe module object that settled. $checkoutarrayThe checkout record **after the write**: its status is paid, with the settlement time and transaction number inside. `settled_status` can be `pending`, meaning the money is **not final yet**. $resultarrayThe module's **raw** result: status, message, the paid mark, subscription details. It speaks the gateway's own language. $responsearrayThe answer going back to the core: the status, the pending mark, the redirect address. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.settled', 10, function ($module, $checkout, $result, $response) { // It can be PENDING: open nothing before the money is final. if (($checkout['data']['settled_status'] ?? '') !== 'successful') return; Accounting::gatewaySettled($module->name, $checkout); }); ``` #### Following a callback actionpayment.callback_returned `PaymentGatewayModule` the record can be empty Runs after the gateway's callback was interpreted. Parameters 4 $modulePaymentGatewayModuleThe module that read the callback. $checkoutarrayThe checkout record. Where the module could not resolve it, an **empty array** arrives: the callback may be forged or unmatched. $settlearrayThe settlement result: status, redirect, message and the `already` mark. `already` means "this callback was handled before". $is_s2sboolWhether the callback came **straight from their servers**. With `false` the customer's browser brought it, and they may never have opened that page. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.callback_returned', 10, function ($module, $checkout, $settle, $is_s2s) { // An empty checkout is an unmatched callback; check the repeat mark too. if (!$checkout || !empty($settle['already'])) return; Ops::note('gateway-callback', $module->name, $is_s2s ? 'server' : 'browser'); }); ``` #### Stopping a card being stored gatepayment.card_add `AccountCards` no number arrives Runs while a customer stores a card. **The card number never reaches this hook**; you decide on the account and module alone. Parameters 3 $uidintThe account storing the card. Confirmed to be their own. $moduleNamestringThe gateway module storing the card. $autoPayboolWhether the card joins the automatic payment chain. Always `false` where the module does not support it. Return 1 stringA non-empty string **stops** the card being stored. Listener PHP ```php Hook::add('gate:payment.card_add', 10, function ($uid, $moduleName, $autoPay) { // The card number NEVER arrives here: decide on the account. if (Acme::cardCount($uid) >= 5) return 'At most 5 cards are kept.'; return null; }); ``` #### Following a card stored actionpayment.card_stored `AccountCards` display details only Runs after the card was stored at the gateway. Parameters 4 $userIdintThe card's owner. $cardIdintThe id of the stored card record. $modulestringThe module that stored it. $cardarray**Display details only**: the last four digits, brand, type, expiry month and year. The card number, the security code and the gateway token are **not here** and never will be. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.card_stored', 10, function ($userId, $cardId, $module, $card) { // Display details only; never the number or the token. Notify::securityEvent($userId, 'card-added', $card['ln4'] ?? ''); }); ``` #### Following the default card actionpayment.card_default_set `AccountCards` auto-payment uses this one Runs after a card was made the default. Parameters 2 $uidintThe card's owner. $cardIdintThe id of the card made default. Automatic payment tries this one from now on. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.card_default_set', 10, function ($uid, $cardId) { Audit::note('card-default', (string) $uid, (string) $cardId); }); ``` #### Following a card removed actionpayment.card_removed `AccountCards` auto-payment is affected Runs after a stored card was removed. Parameters 2 $uidintThe card's owner. $cardIdintThe id of the removed card. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.card_removed', 10, function ($uid, $cardId) { // With the last card gone auto-payment stops quietly: tell the customer. if (Acme::cardCount($uid) === 0) Notify::noCardLeft($uid); }); ``` #### Stopping a subscription cancellation gatepayment.subscription_cancel `AccountCards` the agreement at the gateway Runs while a customer cancels the recurring agreement at the gateway. Parameters 2 $uidintThe agreement's owner. $idintThe id of the agreement being cancelled. Return 1 stringA non-empty string **stops** the cancellation. Listener PHP ```php Hook::add('gate:payment.subscription_cancel', 10, function ($uid, $id) { // Cancelling the agreement closes no service, it stops the payments: warn first. if (Acme::activeServices($uid) > 0 && !Acme::confirmed($uid)) return 'You have live services; please confirm the cancellation.'; return null; }); ``` #### Following a subscription poll actionpayment.subscription_polled `cronjobs/SubscriptionPoll` it runs on a schedule Runs after the scheduled task queried the agreements at the gateway. Parameters 2 $subscriptionarrayThe agreement record that was polled. $resultarrayThe result from the gateway. The poll runs on a schedule and arrives **many times** for one agreement. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.subscription_polled', 10, function ($subscription, $result) { // It runs on a schedule: react to a CHANGE, not to every poll. Acme::syncSubscription($subscription, $result); }); ``` #### Following gateway settings being saved actionpayment.settings_saved `PaymentGatewayModule` after the write Runs after the settings of a payment module are saved. Parameters 2 $module_namestringThe name of the module whose settings were saved. $configarrayThe whole configuration written to disk. It holds API keys and secrets: keep it out of your own records and your logs. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.settings_saved', 10, function ($module_name, $config) { // Carry the fact that it changed, NOT the configuration itself. Acme::notifyOps('payment settings changed: ' . $module_name); }); ``` #### Adding a bank logo filterpayment.bank_logo `Payment` pattern map Runs while the logo of the detected bank is chosen. Add your own bank list here. Parameters 3 $bank_namestringThe bank name detected from the card. $card_brandstringThe card brand. $card_typestringThe card type: `debit` or `credit`. Return 1 arrayYou return a **map**: the key is a fragment the bank name will contain, the value is the logo address. Returns are merged into the existing list. Matching is a substring search, not an exact one: a short key catches banks you did not mean. Listener PHP ```php Hook::add('filter:payment.bank_logo', 10, function ($bank_name, $card_brand, $card_type) { // The key is a substring search: a short one catches other banks too. return ['Acme Bank' => Acme::assetUrl('acme-bank.svg')]; }); ``` #### Taking over the card lookup filterpayment.bin_lookup `Payment` passed by link Runs while the bank and type are resolved from the first digits of a card. Fill it in and the outside service is **never called**. Parameters 2 $resultarray|falseby linkThe lookup result. Expected fields: country, card type, scheme, bank name and brand. $bin_numberstringThe first six digits of the card. Return 1 voidThe return is ignored; you write over the result. Leave it alone and the outside lookup carries on as normal. Wiring your own cache here saves a call on every payment. Listener PHP ```php Hook::add('filter:payment.bin_lookup', 10, function (&$result, $bin_number) { // With a cache of your own there is no need to go outside. $cached = Acme::binCache($bin_number); if ($cached) $result = $cached; }); ``` #### Replacing the payment pane with your own filterpayment.gateway_pane `Checkout` passed by link Runs while the gateway pane is prepared at the payment step. Put your own screen here and the classic payment page is skipped. Parameters 2 $htmlstringby linkWhat will be drawn in the pane. $ctxarrayby linkThe context. Return 1 voidThe return is ignored. Leave the content **filled** and your screen is shown; leave it **empty** and the classic payment page takes over. Emptying it is a decision too. Listener PHP ```php Hook::add('filter:payment.gateway_pane', 10, function (&$html, &$ctx) { // Filled means your screen, empty means the classic page. $html = Acme::renderPane($ctx); }); ``` #### Following an agreement being cancelled actionpayment.subscription_cancelled `AccountSubscriptions` after cancellation Runs after a payment agreement is cancelled. No further charge arrives from it. Parameters 2 $subscription_idintThe id of the cancelled agreement. $subarrayThe agreement row **as it was before the cancellation**: owner, module, identifier, currency and period. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.subscription_cancelled', 10, function ($subscription_id, $sub) { // The services behind it now have no automatic charge. Acme::warnUnpaidRisk((int) ($sub['user_id'] ?? 0)); }); ``` #### Stopping a member being taken off an agreement gatepayment.subscription_member_remove `AccountSubscriptions` before removal Runs before a service or add-on is taken off a payment agreement. Parameters 6 $uidintThe owner of the agreement. $subscription_idintThe id of the agreement. $typestring`service` or `addon`. $midintThe id of the member. $memberarrayThe member row about to be removed. On an add-on the owner field holds the id of the **parent service**, not the user: read it accordingly. $subarrayThe agreement row. Return 1 string|null**A non-empty text blocks the removal** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:payment.subscription_member_remove', 10, function ($uid, $subscription_id, $type, $mid, $member, $sub) { // No removal while the commitment period still runs. if (Acme::underCommitment($mid, $type)) return 'It cannot leave before the commitment ends.'; return null; }); ``` #### Following a member being taken off actionpayment.subscription_member_removed `AccountSubscriptions` after removal Runs after the member is taken off the agreement. Parameters 5 $typestring`service` or `addon`. $idintThe id of the member. $memberarrayThe member row **as it was before removal**. Its agreement field still holds the **old** value: that is where "which agreement did it leave" is answered. $subarrayThe agreement row **before the change**; its status and amount are the old ones. $historyarrayWhat the operation produced, the same record written to the history. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:payment.subscription_member_removed', 10, function ($type, $id, $member, $sub, $history) { // "Which agreement did it leave" lives in the old value. Acme::detached($type, $id, (int) ($member['subscription_id'] ?? 0)); }); ``` ### Pitfalls > **A settlement is not "the money arrived"** > > The settlement record can carry a **pending** status: the gateway took the transaction and has not finalised it. A listener opening a service without checking hands out **a free service** for a payment that later fails. > **The record can be empty on a callback** > > Where the module cannot tie a callback to a checkout, the hook receives an **empty array**. That marks a forged or unmatched callback. The same callback can also arrive **again**; a listener ignoring the "handled before" mark does its work twice. > **The card number reaches no hook** > > The stored-card hooks carry **display details only**: the last four digits, the brand, the expiry. The number, the security code and the token never reach us. You cannot write a rule that reads the card; build the decision on **the account**. > **A browser return is not a reliable signal** > > Where a callback came **from their servers**, the gateway is speaking. From a browser, the customer may never have opened that page — closed it, lost the network, missed the redirect. Rest work that involves money on **the server callback**. ### Related Articles - [Payment Hooks](https://dev.wisecp.com/en/payment-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - Order Flow Hooks ## Currency and Coupon Hooks https://dev.wisecp.com/en/currency-and-coupon-hooks The eight hooks over currency conversion, how amounts read, and coupons. ### Overview The hooks here run on **every screen**. Currency conversion and amount formatting pass through every price the site shows, so heavy work here becomes **the whole site's** burden. Coupons differ: they run rarely and touch **money directly**. The apply gate sits in the cart, and the save gate runs where an operator defines a coupon. ### Reference #### Stopping a coupon being applied gatemoney.coupon_apply `Coupon::validate()` zero for a guest Runs before a coupon is applied to the cart. Parameters 3 $couponarrayThe coupon record: the code, the type (`percent`, `amount`, `fixed`), the rate or amount, the currency, the auto-apply and merge marks, the product limits. $uidintThe cart's owner. On a guest checkout it arrives as **`0`**; remember that when writing a rule about the account. $contextarrayThe pricing context: `items` (the priced cart lines), `subtotal`, `user_currency`. Return 1 stringA non-empty string **refuses** the coupon; the text is shown to the customer as the error. Listener PHP ```php Hook::add('gate:money.coupon_apply', 10, function ($coupon, $uid, $context) { // A guest arrives as 0: split the account rule on that first. if ($uid === 0) return 'Please sign in to use this coupon.'; if (Acme::alreadyUsed($uid, $coupon['code'] ?? '')) return 'The coupon was used already.'; return null; }); ``` #### Stopping a coupon being saved gatemoney.coupon.save `AdminMoney` an operator defines it Runs before an operator saves a coupon. Parameters 4 $codestringThe coupon code. $statearrayA snapshot of **every** value on the form: type, rate, amount, currency, limits. It is **not** the array about to be written; it is the raw form state. $isEditboolWhether this is an edit or a new record. $idintThe coupon id on an edit, `0` on a new one. Return 1 stringA non-empty string **stops** the save. Listener PHP ```php Hook::add('gate:money.coupon.save', 10, function ($code, $state, $isEdit, $id) { // A discount above 90 per cent is usually a typing slip. if (($state['type'] ?? '') === 'percent' && (float) ($state['rate'] ?? 0) > 90) return 'A discount above 90 per cent wants approval.'; return null; }); ``` #### Changing the coupon about to be saved filtermoney.coupon.save_data `Hook::runRefs` some fields get overwritten Runs before the coupon data is written to the database. Parameters 3 $dataarrayrefThe coupon data about to be written: code, type, rate, amount, currency, limits. On a new record `status` and the creation date are added **after** this filter, so what you write there is **overwritten**. $isEditboolWhether this is an edit or a new record. $idintThe coupon id, or `0`. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:money.coupon.save_data', 10, function (&$data, $isEdit, $id) { // Do not write status on a new record: it is overwritten AFTER this filter. $data['code'] = strtoupper((string) ($data['code'] ?? '')); }); ``` #### Following a coupon status actionmoney.coupon.status_changed `AdminMoney` a copy carries its source Runs after a coupon's status changed. Parameters 3 $coupon_idintThe id of the coupon affected. $source_idintThe id of the source it was copied from. Not a copy means `0`; this is how you tell a copy apart. $statusstringThe new status. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:money.coupon.status_changed', 10, function ($coupon_id, $source_id, $status) { // A filled source means this coupon is a copy. if ($source_id > 0) Acme::linkCopy($coupon_id, $source_id); }); ``` #### Changing a currency conversion filtermoney.exchange_rate `Money::exChange()` it runs constantly Runs after an amount was converted into another currency. It runs **on every conversion**. Parameters 4 $convertedfloatrefThe result as worked out. What you write here is the **final** amount. $amountfloatThe source amount. $fromarrayThe source currency: code, rate, prefix, suffix. $toarrayThe target currency: code, rate, prefix, suffix. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:money.exchange_rate', 10, function (&$converted, $amount, $from, $to) { // It runs on EVERY conversion: no queries, no remote calls here. $converted = round($converted * (1 + Acme::MARGIN), 4); }); ``` #### Changing how an amount reads filtermoney.format_output `Money::formatter()` on every price Runs after an amount was turned into text. Parameters 2 $outputstringrefThe formatted text. A format you change on the server has to **match** what the browser side produces, or the figure visibly changes as the page loads. $amountfloatThe raw amount being formatted. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:money.format_output', 10, function (&$output, $amount) { // Where the browser side formats differently, the figure flickers on load. if ((float) $amount === 0.0) $output = Acme::freeLabel(); }); ``` #### Following a rate update actionmoney.exchange_rates_updated `cronjobs/ExchangeRates` the changed ones only Runs after the exchange rates were updated. Parameters 2 $changesarrayThe rates that **changed** in this run, keyed by currency code. With nothing changed an **empty array** arrives, and the hook still runs. $localCodestringThe system's main currency code. The rates are expressed against it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:money.exchange_rates_updated', 10, function ($changes, $localCode) { if (!$changes) return; // nothing moved this run Ops::note('fx', $localCode . ': ' . implode(',', array_keys($changes))); }); ``` #### Stopping a currency being switched gatemoney.currency.status_change `AdminMoney` a wide effect Runs before a currency is switched on or off. Parameters 2 $currencyarrayThe currency record. $statusstringThe state being asked for. Return 1 stringA non-empty string **stops** the change. Listener PHP ```php Hook::add('gate:money.currency.status_change', 10, function ($currency, $status) { // Switching off a currency in use leaves prices with nowhere to resolve. if ($status !== 'active' && Acme::inUse($currency['id'] ?? 0)) return 'Live services use this currency; it cannot be switched off.'; return null; }); ``` #### Following a coupon being saved actioncoupon.saved `AdminMoney` create and edit Runs when a coupon is created or edited. Both land on the same hook, and a parameter tells you which. Parameters 4 $idintThe id of the coupon. $codestringThe coupon code. $isEditboolTrue when an existing coupon was edited, false when a new one was made. $dataarrayThe saved fields: type, rate, amount, currency, validity cycles and usage limit. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:coupon.saved', 10, function ($id, $code, $isEdit, $data) { // Announce only a new coupon to the campaign system. if (!$isEdit) Acme::publishCampaign($code, $data); }); ``` #### Following a coupon being deleted actionmoney.coupon.deleted `AdminMoney` after deletion Runs after a coupon is deleted. Parameters 2 $coupon_idintThe id of the deleted coupon. $couponarrayThe record before deletion. The coupon is gone: take the code from here if you need it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:money.coupon.deleted', 10, function ($coupon_id, $coupon) { Acme::retireCampaign($coupon['code'] ?? ''); }); ``` #### Following the base currency changing actionmoney.currency.local_changed `AdminMoney` base currency Runs when the base currency of the system changes. This is a large change: every rate is now read against the new unit. Parameters 2 $idintThe id of the currency that became the base. $currencyarrayThe record of that currency, **read before the change**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:money.currency.local_changed', 10, function ($id, $currency) { // Every rate is now read against the new unit. Acme::rebaseReports($id); }); ``` #### Stopping a tax rule being saved gatemoney.tax_rule.save `AdminMoney` before the write Runs before a tax rule is saved. A wrong rate reaches every new invoice, which makes a check here cheap. Parameters 3 $country_idintThe country of the rule. $state_idintThe state or city; a **zero** means the rule covers the whole country. $ratefloatThe total rate worked out. Return 1 string|null**A non-empty text blocks the save** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:money.tax_rule.save', 10, function ($country_id, $state_id, $rate) { // A rate outside the sane range is usually a typing slip. if ($rate < 0 || $rate > 40) return 'The tax rate falls outside the expected range.'; return null; }); ``` #### Following a tax rate change actionmoney.tax_rates_changed `AdminMoney` after the write Runs after a tax rule is saved. Parameters 3 $country_idintThe country of the rule. $state_idintThe state or city; a zero covers the whole country. $ratefloatThe new total rate. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:money.tax_rates_changed', 10, function ($country_id, $state_id, $rate) { Acme::syncTaxTable($country_id, $state_id, $rate); }); ``` #### Adjusting a currency change filtermoney.currency.save_data `AdminMoney` passed by link Runs while a currency is saved, before **only the changed fields** are written. What you hold is the difference, not the whole record. Parameters 3 $setsarrayby linkThe difference about to be written. It **can arrive empty** when nothing changed; be ready for that. $idintThe id of the currency. $currencyarrayThe current record, with the values from before the difference. Return 1 voidThe return is ignored; you write over the difference. A base currency switch is applied **after** this filter and adds further fields, so what you see here is not the final shape. Listener PHP ```php Hook::add('filter:money.currency.save_data', 10, function (&$sets, $id, $currency) { // The difference can be empty: look first. if (!$sets) return; // Pin the rate to your own source. if (isset($sets['rate'])) $sets['rate'] = Acme::officialRate($currency['code'] ?? ''); }); ``` #### Changing how an amount is written filtermoney.digit `Money::format` the last return wins Runs while an amount is formatted. **Every** money value in the system passes through here: invoices, the basket, lists, documents. Parameters 4 $amountfloatThe raw amount. $currencyarrayThe resolved currency record. $symbolboolWhether the symbol is shown. $exchangemixedThe conversion target, or false when there is none. Return 1 string|nullWhat you return **replaces** the format, and the last return wins. Return **nothing** for calls you do not care about: `null` is skipped safely. But `''`, `0` and `false` are not skipped; they are assigned and the amount **comes out blank**. Listener PHP ```php Hook::add('filter:money.digit', 10, function ($amount, $currency, $symbol, $exchange) { if (($currency['code'] ?? '') !== 'BTC') return; // do NOT return '': it blanks the amount return number_format($amount, 8, '.', ''); }); ``` #### Changing the fetched rates filtermoney.exchange_rates_fetch `Money` passed by link Runs after rates come in from the outside source and before they are saved. You can add one, change one, or drop one you do not trust. Parameters 3 $ratesarrayby linkCode against rate, read against the base currency. $localCodestringThe code of the base currency the rates are read against. $targetsarrayThe currencies to be synced. Return 1 voidThe return is ignored; you write over the list. Listener PHP ```php Hook::add('filter:money.exchange_rates_fetch', 10, function (&$rates, $localCode, $targets) { // Write your own official source over the outside service. $own = Acme::officialRates($localCode); foreach ($own as $code => $rate) $rates[$code] = $rate; }); ``` #### Following recurring expenses being recorded actionexpense.recurring_recorded `cronjobs` once per round Runs after the recurring expense rules are processed. It fires once for **the whole round**, not per expense. Parameters 2 $entriesarrayThe rules handled in this round, each with its rule and record id, description, amount, currency and status. $recordedintHow many expenses were added **successfully** this round. The hook does not fire when nothing was added, so this is always at least one. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:expense.recurring_recorded', 10, function ($entries, $recorded) { // One call per round: send them to accounting in one batch. Acme::pushExpenses($entries); }); ``` ### Pitfalls > **The rate and format hooks run on every price** > > A catalogue page shows hundreds of prices and these two hooks run **for each one**. A query, a file read or a remote call inside them slows the page hundreds of times over. Prepare what you need **once** and keep it in a static variable. > **On a guest cart the account id is zero** > > The account id at the coupon gate arrives as `0` on a checkout with nobody signed in. A rule like "has this customer used the coupon before" treats **every guest as one person**. Split the zero case out before writing an account rule. > **Some fields are overwritten after the coupon filter** > > On a new coupon the `status` and creation date are added **after the filter**. Values you write there vanish quietly — no error and no effect. To set a status, work from a hook that runs after the save. > **Server and browser must format alike** > > The format filter runs **on the server**. Some figures on a page are rewritten by the browser, and where the two format differently the customer watches the number **visibly change** as the page loads. Changing a format means changing both sides. ### Related Articles - [Invoice Amount Hooks](https://dev.wisecp.com/en/invoice-amount-hooks) - Order Flow Hooks - Invoice and Payment Hooks # Hooks / Orders ## Cart Hooks https://dev.wisecp.com/en/cart-hooks Eight hooks from a customer filling a cart to the order being placed. The add gates, the pricing chain and the checkout step. ### Overview Cart pricing is a **three-stage chain** and each stage has its own filter. The raw inputs come first, then the priced lines, then the summary. Knowing the order puts your rule on **the right link**. Picking the wrong link fails quietly. Change a total on the item filter and the summary **works it out again**, writing over you. ### Reference #### Stopping something going in the cart gateorder.cart_add `Cart` the product is validated Runs before the product is written into the cart, with the product and cycle **already validated**. Parameters 3 $productarrayThe product record: id, type, name, stock, add-ons, options. Already confirmed to be on sale. $cyclestringThe billing cycle chosen. Confirmed to be one the product actually offers. $itemarrayThe line about to be written: kind, product id, cycle, quantity, the configured mark, the options. Return 1 stringA non-empty string **stops** the action; the text is shown to the customer as the error. Listener PHP ```php Hook::add('gate:order.cart_add', 10, function ($product, $cycle, $item) { // The core checks stock; add your own business rule. if (Acme::limitReached($product['id'] ?? 0)) return 'The daily order limit for this product is spent.'; return null; }); ``` #### Changing the raw inputs filterorder.cart_inputs `Orders::buildCart()` the first link Runs on the raw order inputs, before pricing starts. Parameters 2 $inputsarrayrefThe raw inputs, each with its own group (domain, product, add-on). This is the **head** of the chain: adding a line here means the next two stages **price it** for you. $ucidintThe currency the pricing runs in. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.cart_inputs', 10, function (&$inputs, $ucid) { // The HEAD of the chain: a line added here gets priced by the core. if (Acme::freeSslCampaign()) $inputs[] = ['group' => 'product', 'product_id' => Acme::SSL_ID, 'cycle' => 'annually']; }); ``` #### Changing the priced lines filterorder.cart_items `Orders::buildCart()` the second link Runs after the lines were priced, before the summary is built. Parameters 4 $itemsarrayrefThe priced lines: type, product or extension, unit price, total, taxable amount, add-ons, reseller discount. You can change a line total, and yet the **grand total** is worked out again in the next stage. $user_idintThe order's owner. $user_currencyintThe order currency. $dealershiparrayThe customer's reseller settings, resolved. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.cart_items', 10, function (&$items, $user_id, $user_currency, $dealership) { // Change a line price here; the GRAND total is built in the next stage. foreach ($items as &$i) if (Acme::bundleEligible($i)) $i['price'] = Acme::bundlePrice($i); }); ``` #### Changing the cart summary filterorder.cart_totals `Orders::buildCart()` the last link Runs after the price summary was built — the **final** figures the customer sees are here. Parameters 5 $summaryarrayrefThe price summary: the subtotal, the display subtotal, the reseller discount, the coupon discount, tax. The **end** of the chain: these figures reach the customer and the invoice. $itemsarrayThe priced lines as the previous filter left them. Context you cannot change. $subtotalfloatThe subtotal before discounts and tax. $selectedCouponsarrayThe coupons in play. Fixed amounts are **already converted** into the customer's currency. $ctxarrayThe pricing context: currency, taxation type, tax rates, exemption. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.cart_totals', 10, function (&$summary, $items, $subtotal, $selectedCoupons, $ctx) { // The END of the chain: what you write here is what the customer sees. if (Acme::minimumOrder() > (float) $summary['subtotal']) $summary['acme_notice'] = Acme::minimumNotice(); }); ``` #### Stopping the checkout step gateorder.checkout `ClientCheckout` the lines are unpriced Runs before the order is placed. **A guest never reaches here**: the sign-in check runs earlier. Parameters 2 $memberarrayThe signed-in member's record. $cartItemsarrayThe raw cart lines. **Not priced yet**: for a rule that depends on an amount, work the prices out yourself or use the summary filter. Return 1 stringA non-empty string **stops** the action; the text is shown to the customer as the error. Listener PHP ```php Hook::add('gate:order.checkout', 10, function ($member, $cartItems) { // The lines are NOT priced yet: keep amount-based rules out of here. if (Acme::fraudScore((int) ($member['id'] ?? 0)) > 80) return 'Your order wants a review; our team will be in touch.'; return null; }); ``` #### Learning that an order was placed actionorder.checkout_completed `ClientCheckout` the invoice is unpaid Runs after the order was placed. Its invoice is **unpaid** at this point. Parameters 4 $order_idintThe id of the placed order. $invoice_idintThe order's invoice. Unpaid right now; a zero-total order or one settled from the balance closes **after this hook** in the same request. $pmethodstringThe resolved payment method. On a zero-total order `Free`; on a stored card the real gateway module's name. $memberarrayThe record of the member who ordered. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.checkout_completed', 10, function ($order_id, $invoice_id, $pmethod, $member) { // The invoice is NOT paid yet: leave opening services to the payment hook. Crm::orderPlaced($order_id, $pmethod, (string) ($member['email'] ?? '')); }); ``` #### Changing the coupon discount filterorder.coupon_discount `Orders` the computed discount Runs after the coupon discount was worked out. Parameters 1 $discountarrayrefThe computed discount. It feeds the cart summary and runs **before** the summary filter. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.coupon_discount', 10, function (&$discount) { Acme::capCouponDiscount($discount); }); ``` #### Changing the order number filterorder.number `Orders` it has to be unique Runs after the order number was produced. Parameters 1 $numberstringrefThe number produced. Changing it, **guarantee uniqueness yourself**: the core does not check what you produced. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.number', 10, function (&$number) { // Uniqueness is YOURS to keep: a clash breaks the order. $number = Acme::prefix() . '-' . $number; }); ``` ### Pitfalls > **The wrong link in the chain is quietly overwritten** > > Pricing runs in three stages: inputs, lines, summary. Touching the grand total at the line stage is wasted — the summary stage **works it out again**. A line price belongs on the item filter, a final figure on the summary filter, and a new product on the **input filter**. > **The lines are unpriced at the checkout gate** > > The checkout gate hands you the **raw** cart lines: no amounts, discounts or tax yet. Writing "stop orders above this amount" here means reading **a field that is not there**. > **Placing an order is not paying for it** > > On the order-completed hook the invoice is **unpaid**. Opening a service, switching an add-on on or delivering to the customer here means an order that never pays is **delivered anyway**. > **Change the number and its uniqueness is yours** > > The order number filter hands you a number already produced. Once you change it the core **does not check it again**: produce a clashing one and the order record breaks. Adding your own prefix is safe; producing the number from scratch is not. ### Related Articles - Order Flow Hooks - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) - [Currency and Coupon Hooks](https://dev.wisecp.com/en/currency-and-coupon-hooks) ## Order Status Hooks https://dev.wisecp.com/en/order-status-hooks Nine hooks across an order record's life: creation, status changes, configuration, expiry and deletion. ### Overview An order is the cart's **lasting record**. Its status decides when services open: a waiting order opens nothing, an active one builds the services. Carts left unpaid drop **on their own** after a while. That flow has a hook of its own and runs from a scheduled task. ### Reference #### Stopping an order record gateorder.create `Orders::create()` before the record Runs before the order record is written. Parameters 1 $dataarrayThe order data about to be written: owner, amount, currency, items, taxes, discounts, status, payment method. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error. Listener PHP ```php Hook::add('gate:order.create', 10, function ($data) { if (Acme::blocked((int) ($data['user_id'] ?? 0))) return 'This account cannot place new orders.'; return null; }); ``` #### Following an order record actionorder.created `Orders::create():215` the status can be waiting Runs after the order row was written. The services behind it are **not built yet**. Parameters 1 $orderarrayThe order data joined with its new id: owner, amount, currency, items, taxes, discounts, status, payment method, address, notes. The status is usually **waiting**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.created', 10, function ($order) { // The services are NOT built yet: they open when the status turns active. Crm::orderRecord((int) ($order['id'] ?? 0), $order); }); ``` #### Stopping an order status change gateorder.status_change `Orders::change_status()` whether it reaches the module Runs before an order status changes. Parameters 3 $order_idintThe order id. The record does not arrive; read it yourself where you need it. $statusstringThe target status. $apply_on_moduleboolWhether the change **reaches the provider module**. Where it is false no service is built and only the record moves. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error. Listener PHP ```php Hook::add('gate:order.status_change', 10, function ($order_id, $status, $apply_on_module) { // A change that skips the module builds nothing: shape the rule for that. if ($status === 'active' && $apply_on_module && !Acme::capacityFree()) return 'No new service can open now; capacity is full.'; return null; }); ``` #### Following an order status actionorder.status_changed `Orders::change_status()` the old status can be empty Runs after an order status changed. Parameters 3 $idintThe order id. $statusstringThe new status: `waiting`, `inprocess`, `active`, `cancelled`. $old_statusstringThe previous status. On the first write it arrives as an **empty string**; allow for that when comparing. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.status_changed', 10, function ($id, $status, $old_status) { // On the first write the old status is EMPTY: a plain comparison misleads. if ($old_status === '' || $status === $old_status) return; if ($status === 'active') Crm::orderActivated($id); }); ``` #### Following an order update actionorder.updated `AdminOrders` an operator edited it Runs after an order record was updated. Parameters 2 $idintThe order id. $dataarrayThe update data written. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.updated', 10, function ($id, $data) { Audit::orderEdited((int) $id, array_keys($data)); }); ``` #### Catching an abandoned cart actionorder.expired `cronjobs/OrderCleanup` a scheduled task Runs after an unpaid order timed out and was cancelled. Parameters 3 $order_idintThe id of the cancelled order. $ordernumstringThe order number the customer sees. $user_idintThe cart's owner. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.expired', 10, function ($order_id, $ordernum, $user_id) { // An abandoned cart: the best moment for a win-back. Retention::abandoned((int) $user_id, $ordernum); }); ``` #### Stopping an order delete gateorder.delete `AdminOrders` a list arrives, not one record Runs before an order is deleted. Parameters 1 $idsarrayThe ids about to be deleted. An **array** arrives even for one order; do not expect a single id. Return 1 stringA non-empty string **stops** the action; the text is thrown as the error. Listener PHP ```php Hook::add('gate:order.delete', 10, function ($ids) { // An ARRAY arrives even for a single delete. foreach ($ids as $id) if (Acme::hasPaidInvoice((int) $id)) return 'An order with a paid invoice cannot be deleted.'; return null; }); ``` #### Following an order delete actionorder.deleted `Orders::delete()` a last snapshot Runs after an order was deleted. Parameters 2 $idintThe id of the deleted order. $orderarrayA snapshot from **before** the delete, with items, taxes, discounts and details **decoded**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.deleted', 10, function ($id, $order) { // The items arrive decoded: no JSON parsing needed. Accounting::orderRemoved((int) $id, $order['items'] ?? []); }); ``` #### Changing the order total filterorder.total `Orders` the amount on the invoice Runs after the order totals were worked out. Parameters 4 $tax_calcarrayrefThe totals: `total`, `tax_amount`, the extra tax details, the display subtotal. `total` is the amount written **permanently** onto the order. $subtotalfloatThe subtotal before discounts and tax. $total_discountfloatThe reseller and coupon discounts together. $itemsarrayThe final cart lines. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.total', 10, function (&$tax_calc, $subtotal, $total_discount, $items) { // total is written PERMANENTLY; touching the tax keys shapes the // invoice lines as well. if (Acme::roundUp()) $tax_calc['total'] = ceil((float) $tax_calc['total']); }); ``` ### Pitfalls > **An order record is not a service** > > On the order-created hook the status is usually **waiting** and the services behind it are **not built**. A listener doing service work finds nothing here; the right moment is the status turning **active**. > **On the first write the old status is empty** > > The previous status on the status event can be an **empty string**: the order is being written for the first time. A listener comparing "it was this, now it is that" gets it wrong there. Handle the empty value **separately**. > **The delete gate takes a list** > > Even for a single order the gate takes an **array**. A listener expecting one id tries to read the array as a number and the rule **never** holds. Always walk it with a loop. > **A change that skips the module builds nothing** > > The third parameter of the status gate says whether the change **reaches the provider module**. Where it is false only the record moves and nothing opens on a server. A rule about capacity, servers or providers **has to read** it. ### Related Articles - [Cart Hooks](https://dev.wisecp.com/en/cart-hooks) - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) ## Order Configuration Hooks https://dev.wisecp.com/en/order-configuration-hooks The eight hooks on the screens where a customer makes choices while ordering: configuring, adding a domain, adding an add-on, and provisioning. ### Overview The configuration screen is where a customer shapes a product to suit them: the domain choice, add-ons, the term, custom fields. The hooks here touch both **the screen** and **whether a choice is allowed**. The last hook differs: it runs after payment, **immediately before the services are built**. It is the final stop between an order line and a service. ### Reference #### Stopping a configuration gateorder.item_configure `ClientOrder` the choices are gathered Runs before the configured product is written into the cart, with the choices **already gathered**. Parameters 3 $productarrayThe product record: id, type, name, add-ons, requirements, subdomains, options. $cyclestringThe chosen billing cycle, already validated. $optionsarrayThe gathered configuration, about to become the cart line's options: the domain choice (`none`, `owned`, `register`, `transfer`, `subdomain`), the add-ons, the custom fields. Every choice the customer made is here. Return 1 stringA non-empty string **stops** the action; the text is shown to the customer as the error. Listener PHP ```php Hook::add('gate:order.item_configure', 10, function ($product, $cycle, $options) { // Every choice sits in $options: check your business rule here. if (($options['domain']['option'] ?? '') === 'owned' && !Acme::domainReachable($options['domain']['name'] ?? '')) return 'The domain you entered cannot be reached.'; return null; }); ``` #### Changing the configuration screen filterorder.configure_data `ClientOrder` the whole page Runs after the configuration page's data was built. Parameters 2 $dataarrayrefThe page's **whole** template data: the product, the prices (raw amounts by cycle), the add-ons, the domain options. The prices arrive **unformatted** and are formatted on screen. $ctxarrayThe context: the raw product record, its type, its id and the `edit` mark. A filled `edit` means the customer is **editing** a cart line rather than adding a new product. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.configure_data', 10, function (&$data, $ctx) { // A filled edit means they are editing a cart line, not adding a product. if (!empty($ctx['edit'])) return; $data['acme_hint'] = Acme::upsellHint((int) ($ctx['id'] ?? 0)); }); ``` #### Changing the add-on screen filterorder.configure_addon_data `ClientOrder` the context is a copy Runs after the add-on purchase screen's data was built. Parameters 2 $dataarrayrefThe page's whole template data. This is **the one** to change. $ctxarrayrefThe context: the raw add-on record, the service list and the options. The lists are **copies**: passed by reference and yet a change here **never reaches** the screen. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.configure_addon_data', 10, function (&$data, &$ctx) { // The lists inside $ctx are COPIES: change $data instead. $data['services'] = Acme::filterServices($data['services'] ?? []); }); ``` #### Stopping a domain going in the cart gateorder.domain_add `ClientOrder` the extension is validated Runs before a domain is written into the cart. Parameters 2 $tldRowarrayThe extension record: id, name, status, privacy, DNS management, forwarding add-ons. Already confirmed to be on sale. $itemarrayThe line about to be written: the action (`register` or `transfer`), the full name, its parts, the years. The years is always `1` here; the term changes during configuration. Return 1 stringA non-empty string **stops** the action; the text is shown to the customer as the error. Listener PHP ```php Hook::add('gate:order.domain_add', 10, function ($tldRow, $item) { // An extra check on transfers; none needed on a registration. if (($item['action'] ?? '') === 'transfer' && Acme::recentlyRegistered($item['domain'] ?? '')) return 'A newly registered domain cannot transfer for 60 days.'; return null; }); ``` #### Stopping an add-on going in the cart gateorder.service_addon_add `ClientOrder` ownership is validated Runs before an add-on is attached to a service, with ownership **already confirmed**. Parameters 3 $servicearrayThe service the add-on attaches to: id, owner, product, type, status, term, due date. $addonarrayThe add-on definition: id, name, type (`select`, `radio`, `checkbox`, `quantity`), status, properties. $itemarrayThe line about to be written: the service, the add-on, the option, the quantity and `upgrade_of`. Above zero, `upgrade_of` marks an **upgrade** of an existing add-on rather than a new purchase. Return 1 stringA non-empty string **stops** the action; the text is shown to the customer as the error. Listener PHP ```php Hook::add('gate:order.service_addon_add', 10, function ($service, $addon, $item) { // Above zero, upgrade_of marks an UPGRADE, not a new purchase. if ((int) ($item['upgrade_of'] ?? 0) > 0) return null; if (($service['status'] ?? '') !== 'active') return 'An add-on wants a live service to attach to.'; return null; }); ``` #### Changing the services about to be built filterorder.services_items `Orders::buildServices()` the last stop before provisioning Runs after payment, **immediately before** services are built from the order lines. Parameters 2 $ctxarrayrefThe provisioning context: the order, the invoice, the owner, the currency, the status, the payment method, the languages, the time. $itemsarrayrefThe product and domain lines about to be built. Dropping one here means that service is **never built** — even though the customer paid for it. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:order.services_items', 10, function (&$ctx, &$items) { // DROPPING a line builds nothing: where the customer paid, make it good by hand. foreach ($items as &$i) $i['options']['acme_batch'] = Acme::batchFor((int) ($ctx['order_id'] ?? 0)); }); ``` #### Following an affiliate change actionorder.affiliate_changed `AdminOrders` zero means removed Runs after an order's affiliate changed. Parameters 2 $order_idintThe order id. $affiliate_idintThe new affiliate. `0` means the affiliate was **removed**, not that a new one arrived. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.affiliate_changed', 10, function ($order_id, $affiliate_id) { // 0 means the affiliate was REMOVED; the commission may want taking back. if ($affiliate_id === 0) Acme::revokeCommission((int) $order_id); }); ``` #### Following a service removed from an order actionorder.service.deleted `AdminOrders` the order stays Runs after a service was removed from an order. The order record **stays where it is**. Parameters 2 $order_idintThe order id. $service_idintThe id of the removed service. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:order.service.deleted', 10, function ($order_id, $service_id) { Audit::orderLineRemoved((int) $order_id, (int) $service_id); }); ``` ### Pitfalls > **Dropping a line at provisioning refunds nothing** > > The service-building filter runs **after payment completed**. Drop a line and that service is **never built**, while the customer has paid and the invoice stands. To prevent something, do it at **the cart or checkout gate**. > **On the add-on screen the context is a copy** > > The second parameter is passed by reference and the lists inside it are **copies**: a change there never reaches the screen and your listener looks like it **did nothing**. What you mean to change is in **the first parameter**. > **Adding an add-on can be an upgrade** > > Where `upgrade_of` on the add-on line is above zero the customer is **not buying something new** but upgrading what they have. A rule saying "they already have this, block a second" also blocks **the upgrade**. > **The configuration screen also opens in edit mode** > > A filled `edit` in the screen filter's context means the customer is **editing a cart line** rather than adding a product. Showing an upsell or a welcome note in edit mode asks again about **a choice they already made**. ### Related Articles - [Cart Hooks](https://dev.wisecp.com/en/cart-hooks) - [Order Status Hooks](https://dev.wisecp.com/en/order-status-hooks) - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) # Hooks / Modules ## Module Installation Hooks https://dev.wisecp.com/en/module-installation-hooks The nine hooks over installing, switching on, deleting and queueing modules. ### Overview A module travels four steps: it is uploaded, switched on, switched off and deleted. Each step has a gate you can stop at in front of it and an event that reports it behind. Two shapes differ. The switching hooks take a **list**, because the panel allows a multiple selection; the deletion hooks work with one module. ### Reference #### Stopping a module being switched on gatemodule.activate `AdminModules` a list arrives Runs before one or more modules are switched on. The panel allows a multiple selection, so what arrives is a **list**. Parameters 2 $groupstringThe module type: mail, text message, payment or product. $modulesarrayThe keys being switched on in this request. It is an array even for one module; walk all of them. Return 1 mixed**A filled return blocks it**: either a text or an array carrying a message, both accepted, and the message is shown to the user. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:module.activate', 10, function ($group, $modules) { // It is an array even when one module was picked. foreach ($modules as $key) if (!Acme::licensed($group, $key)) return 'You hold no licence for this module: ' . $key; return null; }); ``` #### Following modules being switched on actionmodule.activated `AdminModules` a list arrives Runs after the modules are switched on. Parameters 2 $groupstringThe module type. $modulesarrayThe keys that were switched on. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.activated', 10, function ($group, $modules) { foreach ($modules as $key) Acme::onModuleOn($group, $key); }); ``` #### Following modules being switched off actionmodule.deactivated `AdminModules` a list arrives Runs after modules are switched off. A payment or server module taken out of service can leave **live services** behind it. Parameters 2 $groupstringThe module type. $modulesarrayThe keys that were switched off. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.deactivated', 10, function ($group, $modules) { // Live services may still sit behind a module now switched off. foreach ($modules as $key) Acme::warnOrphans($group, $key); }); ``` #### Stopping a module being deleted gatemodule.delete `AdminModules` one module Runs before a module is deleted along with its files. Unlike switching on, here there is **one** module. Parameters 2 $typestringThe module type. $keystringThe module key. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:module.delete', 10, function ($type, $key) { // Do not let a module go while services still sit on it. if (Acme::hasLiveServices($type, $key)) return 'Live services still run on this module.'; return null; }); ``` #### Following a module being deleted actionmodule.deleted `AdminModules` after deletion Runs after a module is deleted. Its files are no longer on disk. Parameters 2 $typestringThe type of the deleted module. $keystringThe key of the deleted module. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.deleted', 10, function ($type, $key) { Acme::forgetModule($type, $key); }); ``` #### Stopping an add-on upload gatemodule.addon_install `AdminModules` the uploaded file Runs before an uploaded add-on package is opened. What you hold is the **raw file**, still in the temporary folder. Parameters 2 $filearrayThe uploaded file: its name, temporary path and size. The file name **comes from the user**: do not trust it as it stands. $activatedboolWhether it will be switched on right after the install. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:module.addon_install', 10, function ($file, $activated) { // The name comes from the user: look at the content, not the name. if (!Acme::signatureValid($file['tmp_name'] ?? '')) return 'The package signature did not verify.'; return null; }); ``` #### Stopping an add-on deletion gatemodule.addon_delete `AdminModules` before the write Runs before an add-on is removed. Parameters 1 $keystringThe key of the add-on to be removed. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:module.addon_delete', 10, function ($key) { if (Acme::isRequired($key)) return 'The installation needs this add-on to run.'; return null; }); ``` #### Changing the add-on list filtermodule.addons_list `AdminModules` passed by link Runs once the list on the add-on page is prepared. Parameters 1 $moduleListarrayby linkThe list in three groups: enabled, disabled and available to buy. Change it without breaking the shape: the template expects all three groups. Return 1 voidThe return is ignored; you write over the list. Listener PHP ```php Hook::add('filter:module.addons_list', 10, function (&$moduleList) { // Keep the shape of the three groups. $moduleList['premium'] = Acme::filterOffers($moduleList['premium'] ?? []); }); ``` #### Stopping an intervention in the queue gatemodule.queue_intervene `AdminModules` zero on a bulk action Runs before an administrator steps into the module queue by hand: retrying, deleting, clearing and running now. Parameters 2 $actionstringWhat kind of intervention it is. $idintThe id of the target record. On a **bulk action it arrives as zero**: a rule written to inspect one record then inspects nothing. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:module.queue_intervene', 10, function ($action, $id) { // On a bulk action the id is ZERO: build no single-record assumption. if ($id === 0 && $action === 'delete') return 'Bulk deletion is closed.'; return null; }); ``` ### Pitfalls > **The switching hooks receive a list** > > Even for a single module the parameter is an **array**. A check that compares it directly catches nothing; walk the list. > **A bulk queue action carries a zero** > > When several records are handled at once the record id arrives as **zero**. A rule written to inspect one record then quietly inspects nothing. ### Related Articles - Module Hooks - [System Event Hooks](https://dev.wisecp.com/en/system-event-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Module Settings and Data Hooks https://dev.wisecp.com/en/module-settings-hooks The nine hooks over module settings, the API record, tool data and imports. ### Overview Module settings are saved from four separate places: the module’s own settings, add-on settings, the settings on a page the module opened, and fraud check settings. Each raises its own event. Beside them sit three filters over data: tool data, the import archive and the transferable types. And one record fires after every outside call. ### Reference #### Following module settings being saved actionmodule.config_saved `AdminModules` after the write Runs after the settings of a module are saved. Parameters 2 $typestringThe module type. $keystringThe module key. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.config_saved', 10, function ($type, $key) { Acme::invalidateModuleCache($type, $key); }); ``` #### Following add-on settings being saved actionmodule.addon_settings_saved `AddonModule` carries secrets Runs after the settings of an add-on are saved. Parameters 2 $namestringThe key of the add-on. $configarrayThe whole configuration written to disk. It holds API keys and access settings: keep it out of your own records and your logs. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.addon_settings_saved', 10, function ($name, $config) { // Do not carry the configuration itself. Acme::notifyOps('add-on settings changed: ' . $name); }); ``` #### Following module area settings being saved actionmodule.area_settings_saved `AdminArea` only what was sent Runs after settings on the module’s own management page are saved. Parameters 3 $module_typestringThe module type. $module_namestringThe module name. $valuesarrayThe saved values. Only **what this call sent** is here, not the full settings. Do not read a missing key as "removed". Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.area_settings_saved', 10, function ($module_type, $module_name, $values) { // The array holds what was SENT, not the whole settings. Acme::auditChange($module_name, array_keys($values)); }); ``` #### Following fraud module settings actionmodule.fraud_settings_saved `FraudModule` carries secrets Runs after the settings of a fraud check module are saved. Parameters 2 $namestringThe module key. $configarrayThe configuration written: its status and settings. It holds a service key; do not carry it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.fraud_settings_saved', 10, function ($name, $config) { Acme::notifyOps('fraud check settings changed: ' . $name); }); ``` #### Following a module API record actionmodule.api_logged `ModuleBase` raw request and response Runs after a module talks to an outside service. It fires for **every** call, so this is a busy path. Parameters 4 $typestringThe module type. $modulestringThe module name. $actionstringThe operation recorded. $log_dataarrayThe whole record: the request sent, the response received and the request details. ? It holds **server passwords, API keys and customer data** in the clear. Pick out what you actually need before copying it anywhere. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:module.api_logged', 10, function ($type, $module, $action, $log_data) { // Do NOT copy the whole record: it carries secrets. Acme::metric($type . '.' . $module . '.' . $action); }); ``` #### Changing tool data filtermodule.tool_data `ServerModule` passed by link Runs after a server tool returns its data and before it reaches the screen: mailboxes, databases and their siblings. Parameters 3 $toolstringThe tool key. $actionstringThe tool operation; the default is the list view. $dataarrayby linkThe data returned by the module, already normalised. Return 1 voidThe return is ignored; you write over the data. The hook fires for every tool: check which one you are in first. Listener PHP ```php Hook::add('filter:module.tool_data', 10, function ($tool, $action, &$data) { // The hook fires for every tool. if ($tool !== 'databases') return; $data = Acme::hideSystemDatabases($data); }); ``` #### Stopping an import gatemodule.import_run `Imports` the reset mode Runs before a transfer from another system begins. One of the modes **wipes the existing data**, which makes a check here worth the most. Parameters 3 $platformstringThe source platform. $typestringThe type of data to transfer. $import_typestringThe mode: `clean` **wipes** what is there, `enrich` adds to it. Return 1 string|null**A non-empty text blocks the operation** and is shown as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:module.import_run', 10, function ($platform, $type, $import_type) { // 'clean' WIPES what is there: close it on live data. if ($import_type === 'clean' && Acme::hasLiveData($type)) return 'A wiping import cannot run over live data.'; return null; }); ``` #### Changing which archive is opened filtermodule.import_archive `AdminModules` passed by link Runs before an uploaded module archive is opened. Change the path and **a different file** is opened. Parameters 2 $theFilestringby linkThe full path of the archive in the temporary folder. What you write over it is what gets opened. $groupstringThe target module type. Return 1 voidThe return is ignored; you write over the path. If you put a file of your own here, make sure the path is under your control. Listener PHP ```php Hook::add('filter:module.import_archive', 10, function (&$theFile, $group) { // Changing the path opens ANOTHER file: hand over one you built. $theFile = Acme::repackage($theFile, $group); }); ``` #### Changing which data types can transfer filtermodule.import_data_types `Imports` passed by link Runs while the import wizard asks which data types it should offer. Parameters 2 $data_typesarrayby linkThe type list; each carries a name, a description and whether it is required. $platformstringThe active platform. Return 1 voidThe return is ignored; you write over the list. Dropping a type marked required can start the transfer with data missing. Listener PHP ```php Hook::add('filter:module.import_data_types', 10, function (&$data_types, $platform) { // Dropping a required type starts the transfer short. $data_types['acme-notes'] = [ 'name' => 'Acme notes', 'description' => 'Notes attached to customer records', 'required' => false, ]; }); ``` ### Pitfalls > **The API record carries raw secrets** > > The record holds the request sent to the outside service and the response as they were: **server passwords, API keys, customer data**. Copying that array into your own store spreads those secrets to a second place. Pick the field you need; do not carry the whole thing. > **A wiping import cannot be undone** > > One import mode **deletes** the data already there. The import gate is the only place you can keep that mode away from live data. ### Related Articles - Module Hooks - [System Event Hooks](https://dev.wisecp.com/en/system-event-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) # Hooks / Scheduled Tasks ## Cron Tick Hooks https://dev.wisecp.com/en/cron-tick-hooks The hooks over work nobody is watching: run starts, task registration, the queue, periodic rounds and worker management. ### Overview Scheduled work runs in a **tick**: the system wakes each minute, takes jobs off the queue, opens **helper workers** where needed, and finishes. One thing is true of every hook here: **nobody is watching**. An error you throw is shown to no one and output you print reaches no one; the only trace is in the records. ### Reference #### Registering a scheduled task of your own registercronjobs `CronJobQueue` registration is a side effect Runs while the task registry is gathered. This is where you **register** your own. Parameters 0 —It takes no parameters. You register by calling `CronJobQueue::register()`, not by returning. Return 1 voidThe return is ignored; registration happens as a **side effect**. Listener PHP ```php Hook::add('register:cronjobs', 10, function () { // You register with a call, not with a return. CronJobQueue::register('acme.sync', AcmeSyncHandler::class); }); ``` #### Stopping a tick from running gatecron.worker.run `cron.php` the run environment Runs before a tick starts. Stopping it means **no job is processed** in that tick. Parameters 2 $isCliboolWhether it runs from the command line. False means it was triggered over a request. $sapistringThe name of the interface running it. Read this where you want it to run in one environment only. Return 1 stringA non-empty string **blocks** the tick; the text comes back as the reason. No error is thrown and the tick is quietly skipped. Listener PHP ```php Hook::add('gate:cron.worker.run', 10, function ($isCli, $sapi) { // No task runs inside a maintenance window. if (Acme::maintenanceWindow()) return 'maintenance window'; return null; }); ``` #### Following a tick starting actioncron.tick.started `cron.php` it can be a helper Runs where a tick starts. Parameters 3 $workerIdstringThis tick's unique worker id. Several ticks can run at once; keep your records apart by this id. $isChildboolWhether this is a **helper worker**. True means it is not the main tick but a helper opened under load; both run this hook. $startTsintThe time the tick started. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.tick.started', 10, function ($workerId, $isChild, $startTs) { // Helper workers run this hook too: tell the two apart. if ($isChild) return; Ops::heartbeat($workerId, $startTs); }); ``` #### Following a tick finishing actioncron.tick.completed `cron.php` it can be partial Runs after a tick finished. Parameters 2 $payloadarrayThe tick summary: `status` (`ok` or `partial`), the worker id, how many jobs ran, the duration, the errors, plus scheduler and helper details. `partial` means the tick **did not finish** its work. $isChildboolWhether this was a helper worker. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.tick.completed', 10, function ($payload, $isChild) { // partial means the tick ran out of room; a run of them is a capacity problem. if (($payload['status'] ?? '') === 'partial') Ops::warn('cron-partial', (int) ($payload['processed'] ?? 0)); }); ``` #### Following a processed job actioncron.job.processed `CronJobQueue` once per job Runs after a queued job was processed. Parameters 2 $jobarrayThe processed queue row: id, type, payload, attempts. An attempt count above one means the job **failed before**. $workerIdstringThe id of the worker that ran it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.job.processed', 10, function ($job, $workerId) { // An attempt count above one means this job failed before. if ((int) ($job['attempts'] ?? 0) > 1) Ops::note('cron-retry', $job['type'] ?? '', (int) $job['attempts']); }); ``` #### Joining the daily work actioncron.day.run `cronjobs/Daily` your return is recorded Runs in the daily task round. Unlike the other hooks **your return is recorded**. Parameters 0 —It takes no parameters. Return 1 bool|arrayYour return is **gathered and shown in the panel** as success, duration and errors. A wrong return breaks nothing; it only makes your entry look bad. Listener PHP ```php Hook::add('action:cron.day.run', 10, function () { // Your return shows in the panel: true on success, a note otherwise. $done = Acme::nightlyReport(); return $done ? true : ['status' => false, 'message' => 'the report failed']; }); ``` #### Joining the hourly work actioncron.hour.run `cronjobs/HourlyExecute` your return is recorded Runs once an hour. Attach hourly work here instead of opening a task file: cache clearing, summaries, outside syncing. Parameters 0 —It takes no parameters. Return 1 bool|arrayYour return is **gathered and shown in the panel** as success, duration and errors. A wrong return breaks nothing; it only makes your entry look bad. Listener PHP ```php Hook::add('action:cron.hour.run', 10, function () { $n = Acme::syncPartnerCatalog(); return ['status' => true, 'message' => $n . ' records synced']; }); ``` #### Joining the per-minute work actioncron.minute.run `cronjobs/PerMinuteExecute` your return is recorded Runs every minute, the tightest of the rounds. It suits queue draining, outside polling and health checks. Parameters 0 —It takes no parameters. Return 1 bool|arrayYour return is gathered and shown in the panel. Time spent here is paid again every minute: if your work is long, hand it to the queue instead of finishing it here. Listener PHP ```php Hook::add('action:cron.minute.run', 10, function () { // Keep it short: this block runs every minute. $sent = Acme::drainOutbox(50); return ['status' => true, 'message' => $sent . ' messages sent']; }); ``` #### Joining the monthly work actioncron.month.run `cronjobs/MonthlyExecute` your return is recorded Runs once a month: month-end summaries, reconciliation, archiving. Parameters 0 —It takes no parameters. Return 1 bool|arrayYour return is gathered and shown in the panel. Because it runs once a month, a fault can sit unnoticed for weeks; put a clear note in your return. Listener PHP ```php Hook::add('action:cron.month.run', 10, function () { $rows = Acme::archiveLastMonth(); return ['status' => true, 'message' => $rows . ' rows archived']; }); ``` #### Changing how many helpers open filtercron.spawn.count `cron.php` server load Runs after it was decided how many helper workers to open under load. Parameters 1 $decisionarrayrefThe decision: `count` (how many to open), pending jobs, active workers, the threshold, the ceiling, the reason. The field that acts is **`count`**; the rest explain the decision. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:cron.spawn.count', 10, function (&$decision) { // Only count acts; open no helpers while the server is under load. if (Acme::loadHigh()) $decision['count'] = 0; }); ``` #### Changing the chart task list filtercron.dashboard_chart_tasks `automation/dashboard` passed by link Runs while the task picker of the activity chart is being prepared on the automation board. You can add a task type of your own or hide the internal ones. Parameters 1 $optionsarrayby linkWhat the picker holds: the key is the task name, the value is the title on screen. It is passed by link, so you make your change by writing over the array, **not by returning it**. Return 1 voidThe return is ignored. Sorting happens before you, so anything you add stays at the end of the list. Listener PHP ```php Hook::add('filter:cron.dashboard_chart_tasks', 10, function (&$options) { // Add a task of your own to the list. $options['acme-sync'] = 'Acme sync'; // Hide an internal one. unset($options['queue-cleanup']); }); ``` #### Stopping a run by hand gatecron.task_run_now `AdminAutomation` an operator triggers it Runs before an operator runs a task by hand. Parameters 2 $taskstringThe key of the task about to run. $user_idintThe id of the staff member acting. Return 1 stringA non-empty string **stops** the run; the text is shown to the operator. Triggered from the panel, this hook is one whose message **somebody actually reads**. Listener PHP ```php Hook::add('gate:cron.task_run_now', 10, function ($task, $user_id) { // Triggered from the panel: a person reads your message. if ($task === 'invoice.generate' && Acme::billingFrozen()) return 'Billing is frozen; this task cannot run now.'; return null; }); ``` #### Following the kill switch actioncron.kill_switch_toggled `AdminAutomation` everything stops Runs where all scheduled work is switched on or off. Parameters 2 $enabledboolThe new state. Switched off, **no task runs**: no invoices, no suspensions, no renewals. $user_idintThe staff member who acted. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.kill_switch_toggled', 10, function ($enabled, $user_id) { // Switching off stops every automation: forgotten, not even invoices go out. if (!$enabled) Ops::alert('cron-disabled', (int) $user_id); }); ``` ### Pitfalls > **An error thrown here reaches nobody** > > On scheduled work hooks **nobody is watching**. An error you throw is shown to neither customer nor operator and only lands in the records. To make a problem known, do it **yourself**: a notification, a record, an alert. > **Helper workers run the same hooks** > > Under load the system opens helper workers and **each one** runs the tick hooks. A notification on "tick started" sends **dozens** in a busy minute. Read the helper mark in the second parameter. > **One job can be processed more than once** > > A failed job is **tried again** and the processed hook runs on each attempt. A listener reacting without reading the attempt count produces a run of records or messages for one job. Check the counter **in your first line**. > **On the daily hook your return is recorded** > > Unlike other event hooks, the periodic ones **gather your return** and show it in the panel as success, duration and errors. Returning nothing leaves your entry blank. Return true on success and **an array with a note** on failure. ### Related Articles - Scheduled Task Hooks - [Service Status Hooks](https://dev.wisecp.com/en/service-status-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) ## Task Management Hooks https://dev.wisecp.com/en/task-management-hooks The eight hooks where an operator reaches into scheduled work by hand: running a task, switching one off, cleaning the queue and the kill switch. ### Overview Unlike the hooks in the previous article these fire **from the panel**: an operator is there and reads your message. Two of them weigh heavily. The kill switch turns **every automation** off, and the queue cleanup **deletes history** so you can no longer see what ran when. ### Reference #### Following a run by hand actioncron.task_run_now `AdminAutomation` the job was queued Runs after an operator triggered a task by hand. The task is **on the queue** at this point, not running yet. Parameters 2 $taskstringThe name of the task triggered. $job_idintThe id of the queued job. For the outcome, listen to the queue hook; this one says only that it **joined the queue**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.task_run_now', 10, function ($task, $job_id) { // The job JOINED the queue and has not run: watch job.processed for the outcome. Audit::manualRun($task, (int) $job_id); }); ``` #### Following a task switched on or off actioncron.task_status_changed `AdminAutomation` a single task Runs after a single task was switched on or off. Parameters 2 $taskstringThe name of the task whose status changed. $enabledboolThe new state. A task switched off **stops quietly** and may go unnoticed. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.task_status_changed', 10, function ($task, $enabled) { // A task switched off stops quietly: record the critical ones. if (!$enabled && Acme::criticalTask($task)) Ops::alert('task-off', $task); }); ``` #### Blocking the kill switch gatecron.kill_switch_disable `AdminAutomation` it stops everything Runs before all automation is switched off. This hook fires **on the off direction only**. Parameters 2 $enabledboolThe target state. On this hook it is **always false**: it fires when switching off, never on. $pending_countintHow many jobs are waiting. A high number means switching off **holds them all**; weigh that before deciding. Return 1 stringA non-empty string **stops** the action; the text is shown to the operator as the error. Listener PHP ```php Hook::add('gate:cron.kill_switch_disable', 10, function ($enabled, $pending_count) { // Switching off with many jobs waiting only grows the backlog. if ($pending_count > 500) return 'Too many jobs are waiting; let the queue drain first.'; return null; }); ``` #### Stopping a queue cleanup gatecron.cleanup_run `AdminAutomation` it deletes history Runs before the queue history is deleted. The retention days are **separate per status**. Parameters 3 $completed_daysintRetention days for completed jobs. $cancelled_daysintRetention days for cancelled jobs. $failed_daysintRetention days for failed jobs. Keeping this low deletes the **evidence** of a problem. Return 1 stringA non-empty string **stops** the action; the text is shown to the operator as the error. Listener PHP ```php Hook::add('gate:cron.cleanup_run', 10, function ($completed_days, $cancelled_days, $failed_days) { // Deleting failures early destroys the evidence of a problem. if ($failed_days < 30) return 'Failed records want keeping for 30 days.'; return null; }); ``` #### Following what a cleanup removed actioncron.queue_cleaned `AdminAutomation` a breakdown by status Runs after the queue cleanup finished. Parameters 2 $deletedintHow many records went in total. $breakdownarrayThe breakdown by status: completed, cancelled, failed. A high failed count means what went was **the trace of a problem**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.queue_cleaned', 10, function ($deleted, $breakdown) { // Many failed records removed means a problem's trace has gone. if ((int) ($breakdown['failed'] ?? 0) > 100) Ops::warn('cron-failures-purged', (int) $breakdown['failed']); }); ``` #### Following a job intervention actioncron.job_intervened `AdminAutomation` four separate actions Runs after an operator reached into a queued job by hand. Parameters 3 $actionstringThe kind: `retry`, `cancel`, `force_reclaim`, `delete`. The four differ greatly; do not react without reading which. $queuestringThe queue key: `cron`, `module`, `notification`. There are three queues and all pass this hook. $idintThe id of the job that was touched. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.job_intervened', 10, function ($action, $queue, $id) { // force_reclaim takes a stuck job back, which means it runs again. if ($action === 'force_reclaim') Ops::note('job-reclaimed', $queue, (int) $id); }); ``` #### Catching a return after downtime actioncron.restore.detected `cron.php` a long gap Runs where the system noticed the tasks had not run for a long while. Parameters 2 $restoreInfoarrayThe gap details: `gap_hours`, `gap_seconds`, `last_seen`. A long gap means the **renewals, suspensions and invoices** of that period are late. $workerIdstringThe id of the worker that noticed. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:cron.restore.detected', 10, function ($restoreInfo, $workerId) { // Renewals and suspensions are late across the gap: check the backlog. Ops::alert('cron-gap', (float) ($restoreInfo['gap_hours'] ?? 0)); }); ``` #### Changing the task list filtercron.task_overview `AdminAutomation` the panel cards Runs before the task overview is shown in the panel. Parameters 1 $tasksarrayrefThe task card rows. Adding your own here **shows it in the list** without running it: use the registration hook for that. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:cron.task_overview', 10, function (&$tasks) { // Adding to the list does NOT run a task; registration is register:cronjobs. $tasks = array_filter($tasks, fn ($t) => !Acme::hidden($t['key'] ?? '')); }); ``` ### Pitfalls > **Running by hand is not running** > > The manual trigger hook hands you a **queue id**: the job joined the queue and has not run. Waiting for an outcome here is pointless; what the job actually did is told by the **processed hook**. > **A cleanup cannot be undone** > > The cleanup also removes **failed** records, and those are the only trace of a problem. Shortening retention leaves tomorrow's "why did it not run?" **unanswerable**. Guard the failed retention at the gate. > **The intervention hook carries four different actions** > > Retrying, cancelling, reclaiming and deleting pass **one hook** and their outcomes are opposites. A listener written without reading the kind can take a cancelled job for a **restarted** one. There are also three queues; the second parameter says which you are in. > **Adding to the list does not run a task** > > The task list filter changes **what shows in the panel** and nothing more. Your own task does not run for being added there; it wants making known through the **registration hook**. Mixing the two leaves a card that sits in the panel and never runs. ### Related Articles - [Cron Tick Hooks](https://dev.wisecp.com/en/cron-tick-hooks) - Scheduled Task Hooks - [Hooks in the Management Panel](https://dev.wisecp.com/en/hooks-in-the-management-panel) # Hooks / Content and Notifications ## Notification Hooks https://dev.wisecp.com/en/notification-hooks The eight hooks behind every message reaching a customer: the dispatch gate, the recipient list, the variables and delivery. ### Overview A notification passes **two gates**. The first stops the dispatch itself: it reaches nobody. The second stops **one delivery**: the other recipients still get the message. Between them sit the recipient list and the variables. The variables filter runs **per recipient**, so one notification can reach each person with different content. ### Reference #### Stopping the whole dispatch gatenotification.dispatch `Notification::dispatch()` a different return contract Runs before the notification is built. Stopping it means the message reaches **nobody**. Parameters 3 $groupstringThe notification group: invoice, service, order, domain, tickets. $namestringThe notification key: `invoice-created`, `welcome` and the like. $contextarrayThe raw context: the record ids involved, extra parameters, manually added recipients, forced channels. Return 1 mixed**Any non-empty return** stops the dispatch — even `true`. Unlike the other gates there is **no text requirement** here; returning a value by accident cuts the notification quietly. Listener PHP ```php Hook::add('gate:notification.dispatch', 10, function ($group, $name, $context) { // MIND: ANY non-empty return stops the dispatch, even true. if ($name === 'invoice-reminder' && Acme::quietHours()) return true; return null; }); ``` #### Changing the recipient list filternotification.recipients `Notification` a row per channel Runs after the recipients were resolved, before the messages are built. Parameters 3 $recipientsarrayrefThe recipient rows: channel, account, address, name, cc, language. One person can appear on **several rows**: e-mail and SMS are separate. $groupstringThe notification group: invoice, service, order, domain, tickets. $namestringThe notification key: `invoice-created`, `welcome` and the like. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:notification.recipients', 10, function (&$recipients, $group, $name) { // One person has SEPARATE rows for e-mail and SMS: filter by channel. if ($name === 'invoice-created') $recipients = array_filter($recipients, fn ($r) => ($r['channel'] ?? '') !== 'sms'); }); ``` #### Changing the template variables filternotification.render_variables `Notification` runs per recipient Runs before the message text is produced, **separately for each recipient**. Parameters 4 $variablesarrayrefThe variable set for this recipient. The placeholders in the template fill from here. $groupstringThe notification group: invoice, service, order, domain, tickets. $namestringThe notification key: `invoice-created`, `welcome` and the like. $recipientarrayThe recipient in hand: channel, language, account, address. The language field matters: produce your text in theirs. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:notification.render_variables', 10, function (&$variables, $group, $name, $recipient) { // It runs PER RECIPIENT: produce your text in their language. $variables['acme_note'] = Acme::note($recipient['lang'] ?? 'en'); }); ``` #### Stopping a single delivery gatenotification.deliver `Notification` the message is ready Runs immediately before a built message goes out. Stopping it cuts **this delivery alone**. Parameters 1 $itemarrayThe item about to be delivered: channel, account, address, name, cc, subject, body, attachments, reason. The text and attachments are **ready**: this is where a last check belongs. Return 1 stringA non-empty string stops **this delivery**; the other recipients still get theirs. Listener PHP ```php Hook::add('gate:notification.deliver', 10, function ($item) { // It stops THIS delivery alone: the others carry on. if (Acme::bounced($item['recipient'] ?? '')) return 'the address is dead'; return null; }); ``` #### Following a dispatch actionnotification.dispatched `Notification` one array parameter Runs after the notification went out. Parameters 1 $payloadarrayA single array: group, name, account, variables, batch id. The batch id ties **every message** of one dispatch together. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:notification.dispatched', 10, function ($payload) { // The batch id ties every message of one dispatch together. Acme::trackBatch($payload['batch_id'] ?? '', $payload['name'] ?? ''); }); ``` #### Widening the template fields filternotification.template_merge_fields `AdminNotifications` the list shown to the operator Runs while the available fields are listed on the template editing screen. Parameters 1 $fieldsarrayrefThe field list shown to the operator. Adding one here **shows it in the list**; producing its value wants the variables filter too. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:notification.template_merge_fields', 10, function (&$fields) { // Adding to the list produces NO value: write the variables filter as well. $fields['acme_note'] = 'Acme note'; }); ``` #### Changing the invoice attachment name filternotification.invoice_pdf_name `Notification` the file name Runs while the invoice document is attached to a notification. Parameters 1 $namestringrefThe attachment's file name. The name on the customer's machine, so keep to **safe characters**. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:notification.invoice_pdf_name', 10, function (&$name) { $name = Acme::safeFileName($name); }); ``` #### Changing a template label filternotification.template_label `AdminNotifications` it shows in the panel Runs while a notification template's name in the panel is resolved. Parameters 1 $labelstringrefThe template's display name. Only the operator sees it; it does not touch the text reaching a customer. Return 1 voidThe value changes **by reference**; the return is not read. Listener PHP ```php Hook::add('filter:notification.template_label', 10, function (&$label) { $label = Acme::prefixLabel($label); }); ``` #### Changing the invoice lines filternotification.invoice_items `Notification::build_items` changes by returning Runs before the invoice lines reach the template. You can add lines, drop them, reorder them or put a field of your own on each one. Parameters 3 $resultarrayThe lines prepared for the template. This is what you change. $invoicearrayThe invoice itself. $itemsarrayThe raw lines as they came from the database. Use this to recover a field the prepared list dropped. Return 1 array|nullAn array you return **replaces the whole list**. The trap: with several listeners the last return wins and earlier additions vanish. To add, take the incoming list and build on it. Listener PHP ```php Hook::add('filter:notification.invoice_items', 10, function ($result, $invoice, $items) { // Keep the incoming list: building from scratch erases what others added. foreach ($result as $i => $line) $result[$i]['acme_note'] = Acme::noteFor($line['id'] ?? 0); return $result; }); ``` #### Changing the order lines filternotification.order_items `Notification::build_order_items` changes by returning The same job for order notifications. Lines arrive already split by quantity, with live add-ons loaded. Parameters 2 $itemsarrayThe lines prepared for the template. $orderarrayThe order itself, raw line data included. Return 1 array|nullAn array replaces the whole list; anything else is ignored. Listener PHP ```php Hook::add('filter:notification.order_items', 10, function ($items, $order) { // Hide internal lines from the customer. return array_values(array_filter($items, fn ($x) => ($x['name'] ?? '') !== 'internal')); }); ``` #### Masking the delivery record filternotification.log_entry `LogManager` passed by link personal data Runs right before an email or SMS record is written to the database. This is where you mask personal data, or keep no record at all. Parameters 1 $entryarrayby linkThe record about to be written. Shared fields: `channel` (mail or sms), `user_id`, `reason`, `content`, `data`, `private`. Email also carries `subject`. Return 1 voidThe return is ignored; you change the array in place. Set `$entry['abort'] = true` and **nothing is written**; the counter comes back zero. The message still goes out, only the trace is dropped. Listener PHP ```php Hook::add('filter:notification.log_entry', 10, function (&$entry) { // Never store the body of a password reset. if (($entry['reason'] ?? '') === 'password-reset') { $entry['content'] = '[masked]'; return; } // Open no record at all for campaign texts. if (($entry['channel'] ?? '') === 'sms' && ($entry['reason'] ?? '') === 'campaign') $entry['abort'] = true; }); ``` #### Giving the preview sample values filternotification.preview_variables `AdminNotifications` passed by link Runs while a template is previewed in the panel. The preview sends nothing and knows only the templates that ship with the core, so **fields of a template you added come out empty**. Supply sample values here. Parameters 2 $variablesarrayby linkWhat the template will receive, name against value. Add your own fields and leave the existing ones alone. $ctxarrayby linkWhich template is on screen: `group`, `template`, `lang`. Leave without touching anything when it is not yours. Return 1 voidThe return is ignored; you add values by writing into the array. Listener PHP ```php Hook::add('filter:notification.preview_variables', 10, function (&$variables, &$ctx) { // Care only about your own template. if (($ctx['template'] ?? '') !== 'acme-welcome') return; $variables = array_merge($variables, [ 'acme_plan' => 'Starter', 'acme_quota' => '10 GB', ]); }); ``` ### Pitfalls > **The dispatch gate reads any return as a block** > > The other gates want **a non-empty string**; this one reads **any non-empty value** as a block. Returning something by accident at the end of your closure — even `true` — means the notification **never goes**, with no trace anywhere. > **One person appears more than once** > > The recipient list carries a row **per channel**: a customer on both e-mail and SMS sits on two rows. A listener deduplicating by account id quietly **switches off a whole channel**. > **The variables filter runs once per recipient** > > On a notification reaching ten people this filter runs **ten times**. A query or a remote call inside it turns one notification into ten requests. Prepare the shared data **once** and produce only the per-recipient part inside. > **Listing a field produces no value** > > The template fields filter widens **the list shown to the operator** and nothing else. Add a field without writing the variables filter and the operator puts it in a template, where it reaches the customer **empty**. The two are written together. ### Related Articles - Knowledge Base and Notification Hooks - [Customer Account Hooks](https://dev.wisecp.com/en/customer-account-hooks) - [Invoice Lifecycle Hooks](https://dev.wisecp.com/en/invoice-lifecycle-hooks) ## Knowledge Base Management Hooks https://dev.wisecp.com/en/knowledge-base-management-hooks The eight hooks over saving and deleting articles and categories: a gate you can stop at each step, then an event that reports it. ### Overview The knowledge base is managed from two sides: the **articles** and the **categories** that hold them. Saving and deleting each carry a gate and an event: the gate can stop the write, the event reports it afterwards. Two distinctions make the work easier. In the save gates **a zero id means a new record**. The delete gates always receive a **list**, even when a single record goes. ### Reference #### Stopping an article being saved gateknowledgebase.article.save `AdminKnowledgebase` before the write Runs before an article is saved. New records and edits share this gate, and the id tells you which one you have. Parameters 2 $idintThe id of the article being edited; a **zero on a new record**. $categoryintThe target category; a zero means the article has none. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:knowledgebase.article.save', 10, function ($id, $category) { // A zero id means a new record: do not allow one without a category. if ($id === 0 && $category === 0) return 'A new article needs a category.'; return null; }); ``` #### Following an article being saved actionknowledgebase.article.saved `AdminKnowledgebase` after the write Runs after the article is saved. This is where you refresh your own search index. Parameters 2 $articlearrayThe freshly saved record: its id, category, status, privacy and rank. $isNewboolTrue when it was created, false when an existing one was updated. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.article.saved', 10, function ($article, $isNew) { Acme::reindex('kb', (int) ($article['id'] ?? 0)); }); ``` #### Stopping an article deletion gateknowledgebase.article.delete `AdminKnowledgebase` always a list Runs before articles are deleted. Even when one article goes, what you receive is a **list**. Parameters 1 $idarrayThe ids about to be deleted. A single deletion still arrives as a one-element array: do not treat it as a number. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:knowledgebase.article.delete', 10, function ($id) { // It is an array even for a single deletion. foreach ($id as $one) if (Acme::isPinned((int) $one)) return 'A pinned article cannot be deleted.'; return null; }); ``` #### Following an article deletion actionknowledgebase.article.deleted `AdminKnowledgebase` after deletion Runs after an article is deleted. Unlike the gate you get **one** article here: with several deleted, the hook fires once per article. Parameters 2 $iintThe id of the deleted article. $articlearrayThe snapshot from before deletion, title included. The record is gone: take the title from here if you need it. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.article.deleted', 10, function ($i, $article) { // The hook runs per article, so a bulk deletion calls it several times. Acme::dropFromIndex('kb', $i); }); ``` #### Stopping a category being saved gateknowledgebase.category.save `AdminKnowledgebase` before the write Runs before a category is saved. Parameters 2 $idintThe id of the category being edited; a zero on a new record. $parentintThe parent category; a **zero** puts it at the root. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:knowledgebase.category.save', 10, function ($id, $parent) { // Keep the root level fixed. if ($parent === 0 && $id === 0) return 'A new category needs a parent.'; return null; }); ``` #### Following a category being saved actionknowledgebase.category.saved `AdminKnowledgebase` after the write Runs after the category is saved. Parameters 2 $categoryarrayThe fresh category record: id, parent, type, status and rank. $isNewboolTrue when it was newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.category.saved', 10, function ($category, $isNew) { Acme::refreshMenu((int) ($category['id'] ?? 0)); }); ``` #### Stopping a category deletion gateknowledgebase.category.delete `AdminKnowledgebase` sub-branches included Runs before categories are deleted. The deletion **reaches down the branches**: sub-categories you do not see in the list go too. Parameters 1 $idarrayThe ids about to be deleted, an array even for one. Sub-categories are not listed here but **they are deleted too**: write your check to cover the branches below. Return 1 string|null**A non-empty text blocks the operation** and is shown to the administrator as the error. An empty return lets it carry on. Listener PHP ```php Hook::add('gate:knowledgebase.category.delete', 10, function ($id) { // The branches are not listed but they go as well: walk the tree yourself. foreach ($id as $one) if (Acme::branchHasPinned((int) $one)) return 'A pinned article sits in this branch.'; return null; }); ``` #### Following a category deletion actionknowledgebase.category.deleted `AdminKnowledgebase` per parent category Runs after a category is deleted. The hook fires for the **parent**; the branches that went with it do not each get their own call. Parameters 2 $iintThe id of the deleted parent category. $categoryarrayThe snapshot from before deletion, title included. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.category.deleted', 10, function ($i, $category) { // No separate call arrives for the branches: cover them in your cleanup. Acme::dropBranchFromIndex($i); }); ``` ### Pitfalls > **The delete gate always receives a list** > > Even for a single article the parameter is a **one-element array**. A check that expects a number and compares it directly quietly catches nothing. > **Category deletion goes down the branch, the hook does not** > > Deleting a category takes the whole branch beneath it. The delete event, though, fires only for the **parent**: no separate call arrives for the sub-categories. Write your cleanup to cover the whole tree. ### Related Articles - Knowledge Base and Notification Hooks - [Notification Hooks](https://dev.wisecp.com/en/notification-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) ## Knowledge Base Display Hooks https://dev.wisecp.com/en/knowledge-base-display-hooks The nine hooks over the knowledge base a visitor sees: the body, the tree, the scope, the popular box, search, and four observation points. ### Overview Everything a visitor sees passes through these hooks: the article body, the category tree, the popular box and the search results. All four are **passed by link**, so you make your change by writing over the incoming value rather than returning it. Beside them sit four observation hooks: an article read, a category opened, a vote and a search. They change nothing; they report that something happened. ### Reference #### Following an article being read actionknowledgebase.article.viewed `website/knowledgebase` on every opening Runs when an article is viewed. The visitor may well not be logged in. Parameters 2 $idintThe id of the article viewed. $articlearrayThe article data, with the view counter **already increased**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.article.viewed', 10, function ($id, $article) { // The visitor may not be logged in: build no assumption about identity. Acme::trackRead($id); }); ``` #### Following an article vote actionknowledgebase.article.voted `website/knowledgebase` one of two values Runs when a visitor marks an article useful or not. Parameters 2 $idintThe id of the article voted on. $typestringWhich way: `useful` or `useless`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.article.voted', 10, function ($id, $type) { // Collect the unhelpful votes for the writing team. if ($type === 'useless') Acme::flagForReview($id); }); ``` #### Following a category being opened actionknowledgebase.category.viewed `website/knowledgebase` in the chosen language Runs when a category page is viewed. Parameters 2 $catIdintThe id of the category viewed. $catarrayThe category record **in the chosen language**: title, link and search headings. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:knowledgebase.category.viewed', 10, function ($catId, $cat) { Acme::trackCategory($catId); }); ``` #### Following a search actionknowledgebase.searched `website/knowledgebase` after the filter Runs when a visitor searches. The result **may be empty**, and searches that find nothing are the best list of articles still to write. Parameters 3 $querystringThe cleaned search term; it never arrives empty. $resultsarrayThe final results, **after the filter ran**. An empty array means nothing was found. $langstringThe language the search ran in. Return 1 voidThe return is ignored. To change the results use the search results filter, not this hook. Listener PHP ```php Hook::add('action:knowledgebase.searched', 10, function ($query, $results, $lang) { // Searches that find nothing point at the article still to write. if (!$results) Acme::noteGap($query, $lang); }); ``` #### Changing the article body filterknowledgebase.article_content `website/knowledgebase` passed by link Runs before the article body reaches the screen. Resolve your own shortcodes here, and drop in values like a version number. Parameters 2 $contentstringby linkThe raw body of the article. What you write over it is the final body. $ctxarrayby linkContext: the article id, the active language and its category. Return 1 voidThe return is ignored; you write over the body. The body is raw markup and goes straight to the screen: clean any user data you put into it yourself. Listener PHP ```php Hook::add('filter:knowledgebase.article_content', 10, function (&$content, &$ctx) { // Resolve a shortcode of your own. $content = str_replace('[version]', Acme::currentVersion(), $content); }); ``` #### Limiting which categories are visible filterknowledgebase.category_scope `website/knowledgebase` empty means no limit Runs while article queries are built. This is how you narrow the knowledge base to an audience: one set for resellers, another for end customers. Parameters 2 $scopearrayby linkThe category ids the queries will be held to. It **always arrives empty**, and **leaving it empty means no limit**: touch nothing and the whole knowledge base shows. $ctxarrayby linkContext: which page and which language. It is information only; changing it affects nothing. Return 1 voidThe return is ignored; you write ids into the list. Listener PHP ```php Hook::add('filter:knowledgebase.category_scope', 10, function (&$scope, &$ctx) { // Leaving it empty means "no limit": fill it to narrow the view. if (!Acme::isReseller()) $scope = Acme::publicCategories(); }); ``` #### Changing the category tree filterknowledgebase.category_tree `website/knowledgebase` passed by link Runs once the category tree beside the content is prepared. Parameters 2 $treearrayby linkThe top-level nodes. What you write over them is the final tree. $ctxarrayby linkContext: which page and which language. Return 1 voidThe return is ignored; you write over the tree. Listener PHP ```php Hook::add('filter:knowledgebase.category_tree', 10, function (&$tree, &$ctx) { // Hide the internal branch from visitors. $tree = array_values(array_filter($tree, fn ($n) => ($n['id'] ?? 0) !== Acme::INTERNAL_CAT)); }); ``` #### Changing the popular article list filterknowledgebase.popular_articles `website/knowledgebase` passed by link Runs while the popular article box beside the content is filled. Parameters 2 $articlesarrayby linkThe articles to list: titles, links and view counts. $ctxarrayby linkContext: which page, which language and **how many were asked for**. Mind that limit when adding: the box does not stretch. Return 1 voidThe return is ignored; you write over the list. Listener PHP ```php Hook::add('filter:knowledgebase.popular_articles', 10, function (&$articles, &$ctx) { // The limit arrives in the context: the box does not stretch. array_unshift($articles, Acme::featuredArticle()); $articles = array_slice($articles, 0, (int) ($ctx['limit'] ?? 6)); }); ``` #### Changing the search results filterknowledgebase.search_results `website/knowledgebase` passed by link Runs before the search results reach the screen. Add results from a source of your own here, or change the order. Parameters 2 $resultsarrayby linkThe raw result rows: title, link, category name and view count. $ctxarrayby linkContext: the search text and the language. Return 1 voidThe return is ignored; you write over the list. Your change carries into the search event as well: that hook runs **after** you and sees the adjusted list. Listener PHP ```php Hook::add('filter:knowledgebase.search_results', 10, function (&$results, &$ctx) { // Add results from a source of your own. foreach (Acme::search($ctx['query'] ?? '', $ctx['lang'] ?? '') as $row) $results[] = $row; }); ``` ### Pitfalls > **An empty scope is no limit, not a tight one** > > The scope list **always arrives empty**, and leaving it that way means "show the whole knowledge base". Wanting to narrow access and leaving the list empty does the opposite. > **The body is raw markup** > > The article body goes straight to the screen and is not cleaned. If you embed a value from outside, escape it yourself. ### Related Articles - Knowledge Base and Notification Hooks - [Notification Hooks](https://dev.wisecp.com/en/notification-hooks) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) # Hooks / Platform ## Platform Infrastructure Hooks https://dev.wisecp.com/en/platform-infrastructure-hooks The eight hooks under the request: outgoing calls, accepted addresses, routing, template variables, API addresses and product types. ### Overview These hooks sit **beneath** a request: before an address resolves, before a template is shown, before a call leaves for the outside. All are hot paths, running on every request. Two deserve care. The **pre-match** stands ahead of the real pages of the install, so an unproven claim shadows them. And **template variables** is not passed by link: the array you return stands in for all of them. ### Reference #### Changing an outgoing request filterhttp.request `Utility::HttpRequest` passed by link Runs before the system sends a request outward. **Every** outgoing call passes here: server modules, registrars, payment gateways. Parameters 1 $optionsarrayby linkThe request settings: address, method, body, headers, timeouts, certificate check. Raising a timeout can slow the whole system: this hook sees every call, not one. Return 1 voidThe return is ignored; you write over the settings. Listener PHP ```php Hook::add('filter:http.request', 10, function (&$options) { // Touch only your own address: this hook sees EVERY outgoing call. if (!str_contains($options['url'] ?? '', 'api.acme.test')) return; $options['headers'][] = 'X-Acme-Tenant: ' . Acme::tenant(); }); ``` #### Widening the accepted addresses filterhttp.trusted_hosts `Kernel` add, do not reset Runs while the address of an incoming request is checked. The list arrives holding the address the licence is locked to; you **add** yours. Parameters 2 $hostsarrayby linkThe accepted addresses. **Do not reset** the list: drop the locked address and the install becomes unreachable at its own address. $contextarrayby linkInformation: the raw address the request came from and the one the licence is locked to. Return 1 voidThe return is ignored; you add to the list. Listener PHP ```php Hook::add('filter:http.trusted_hosts', 10, function (&$hosts, &$context) { // Add, do not reset: the locked address must stay in the list. $hosts[] = 'panel.acme.test'; }); ``` #### Registering an address of your own registerroutes `Router` through the object Runs while the address table is built. This is the **right** way to open a page of your own: register the address rather than grabbing it in the match hooks. Parameters 1 $routerobjectThe router. Being an object, calling a method on it is enough; nothing is registered by returning. Return 1 voidThe return is ignored. Registration happens as a side effect of the call. Listener PHP ```php Hook::add('register:routes', 10, function ($router) { // Registration happens through the call, not the return. $router->add('acme-status', 'acme/status', 'AcmeStatusController'); }); ``` #### Claiming an address ahead of the patterns filterrouting.prematch `Router` ahead of the patterns Runs as an address starts to resolve, **ahead of the registered patterns**. Answering here means stepping in front of the real pages of the install. Parameters 2 $urlstringThe address being resolved, with the language prefix already split off. $routesarrayEvery registered address definition, there to look at. Return 1 array|nullThe **first** listener returning an array with at least a controller key wins. ? The claim must be proven: answering "this might be mine" shadows the real pages of the install. Return `null` unless **your own record** stands behind it. For a permanent page the right place is the address registration, not this. Listener PHP ```php Hook::add('filter:routing.prematch', 10, function ($url, $routes) { // Claim with proof: return null unless you hold a record for it. if (!Acme::ownsSlug($url)) return null; return ['key' => 'acme-page', 'controller' => 'AcmePageController', 'params' => [$url]]; }); ``` #### Catching an address nothing matched filterrouting.match `Router` when no pattern held Runs when none of the registered patterns held. Unlike the pre-match, claiming here is safe: there is no real page left to shadow. Parameters 2 $urlstringThe address that did not resolve. $routesarrayThe registered address definitions. Return 1 array|nullThe first listener returning an array with a controller key wins. With no answer the address falls to the not-found flow. Listener PHP ```php Hook::add('filter:routing.match', 10, function ($url, $routes) { // No pattern held this address, so claiming it is safe. $page = Acme::findVanity($url); if (!$page) return null; return ['key' => 'acme-vanity', 'controller' => 'AcmeVanityController', 'params' => [$page]]; }); ``` #### Changing the variables a template receives filtertemplate.variables `View::render` return the whole array Runs before a template is shown. This is how you carry one value onto every page. Parameters 2 $template_pathstringThe full path of the template being drawn; use it to tell which page you are on. $dataarrayEvery variable about to reach the template. Return 1 array|null? It is **not passed by link**: the array you return **replaces every variable**, it is not merged. Return an array of your own without building on the incoming one and the whole page loses its data, leaving a blank screen. To add something, add to what arrived and return **all of it**. With several listeners the last return wins. Listener PHP ```php Hook::add('filter:template.variables', 10, function ($template_path, $data) { // ADD to what arrived and return all of it: this replaces, it does not merge. $data['acme_banner'] = Acme::banner(); return $data; }); ``` #### Opening API addresses of your own filterapi.routes `API Kernel` three separate tables Runs while the API address table is built. This is how a module opens its own endpoints without touching the core. Parameters 2 $routesarrayby linkThe address table. Each row carries the method, pattern, group, action and access settings. Add, change or remove freely. $audiencestringby linkWhich table: `admin`, `client` or `module`. The hook fires separately for all three: add without checking and your endpoint lands in every table. Return 1 voidThe return is ignored; you write over the table. Listener PHP ```php Hook::add('filter:api.routes', 10, function (&$routes, &$audience) { // The hook fires for all three tables: check the branch. if ($audience !== 'admin') return; $routes[] = ['GET', 'acme/status', 'Module:Addons/Acme', 'status']; }); ``` #### Introducing a new product type registerproduct_types `Products` returns definitions Runs while the product type list is built. Add your own type to the core list here. Parameters 0 —It takes no parameters. Return 1 array|nullYou return a **definition map**; returns are merged into the core list. Each definition carries a title, a description and an icon. Empty returns are ignored. Listener PHP ```php Hook::add('register:product_types', 10, function () { return ['acme-vps' => [ 'title' => 'Acme VPS', 'description' => 'A virtual server on Acme', 'icon' => 'bi bi-hdd-rack fs-5', ]]; }); ``` ### Pitfalls > **Template variables replace, they do not merge** > > This filter is not passed by link. The array you return **stands in for every variable**. Return one of your own without building on what arrived and the page loses its data, leaving a blank screen. The fix: add to the incoming array and return **all of it**. > **An unproven claim in the pre-match shadows real pages** > > The pre-match sits **ahead** of the registered patterns. Answering "this might be mine" makes the real pages of the install unreachable. Match only where your own record stands behind the address; for a permanent page the right place is the address registration. ### Related Articles - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## Language and Translation Hooks https://dev.wisecp.com/en/language-and-translation-hooks The seven hooks over text: resolving a translation, saving one, sweeping a pack, and adding, removing or configuring a language. ### Overview Text travels two ways: a key **resolves** and reaches the screen, or a translation **is saved** and reaches the disk. Each end carries a filter. Beside them sit the events of the pack itself: opening one, deleting one, changing its settings. A new pack is not born empty; it is copied from an existing language. ### Reference #### Changing a resolved text filteri18n.translation `Language::g` passed by link Runs after a translation key resolves to text. **Every** piece of text in the system passes here: it is a hot path, so keep it light. Parameters 3 $valuestringby linkThe resolved text. What you write over it is the result. $keystringThe key that was asked for. $langstringThe language used to resolve it. Return 1 voidThe return is ignored; you write over the text. Leave it alone and the original comes back. Listener PHP ```php Hook::add('filter:i18n.translation', 10, function (&$value, $key, $lang) { // Every text passes here: sift on the key first. if ($key !== 'website/home/title') return; $value = Acme::brandedTitle($lang); }); ``` #### Adjusting translations before they are saved filteri18n.translation_save `AdminLanguages` passed by link Runs before translations are written to disk. The array you hold **is what gets written**. Parameters 2 $valuesarrayby linkKey against value. A value may be plain text or an array carrying content and variables: be ready for both. $idstringThe target language. Return 1 voidThe return is ignored; you write over the array. Listener PHP ```php Hook::add('filter:i18n.translation_save', 10, function (&$values, $id) { // A value may be plain text or an array. foreach ($values as $k => $v) if (is_string($v)) $values[$k] = trim($v); }); ``` #### Following a translation being saved actioni18n.translation_saved `AdminLanguages` only what truly changed Runs after a translation key is saved. Parameters 2 $keystringThe full key that was saved. $savedarrayThe languages **actually written to disk**. Languages sent but unchanged are **absent** from this list: it does not answer "which languages were submitted". Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:i18n.translation_saved', 10, function ($key, $saved) { // The list holds what CHANGED, not what was submitted. foreach ($saved as $lang) Acme::invalidateCache($lang, $key); }); ``` #### Following a bulk replacement actioni18n.bulk_replaced `AdminLanguages` one language pack Runs after a word is replaced throughout one language pack. Parameters 4 $langstringThe **single** language the sweep ran on. $wordstringThe raw word searched for; it is not a pattern. $replacementstringThe text written in its place. It is **not read** as a back-reference: what it holds is written literally. $changedintHow many keys **actually changed**, not how many were selected. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:i18n.bulk_replaced', 10, function ($lang, $word, $replacement, $changed) { // The counter holds what changed, not what was selected. Acme::auditSweep($lang, $word, $changed); }); ``` #### Following a language being added actionlanguage.added `AdminLanguages` opened as a copy Runs after a new language pack is opened. The pack does not start empty: it is built as a **copy** of an existing one. Parameters 2 $keystringThe key of the new language. $copied_fromstringThe language the content was copied from. The new pack starts full of the source texts: it does not look "untranslated", it looks **wrong-languaged**. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:language.added', 10, function ($key, $copied_from) { // The pack starts full of the source texts: put it in the translation queue. Acme::queueTranslation($key, $copied_from); }); ``` #### Following a language being deleted actionlanguage.deleted `AdminLanguages` after deletion Runs after a language pack is deleted. Parameters 1 $keystringThe key of the deleted language. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:language.deleted', 10, function ($key) { Acme::dropTranslations($key); }); ``` #### Following language settings changing actionlanguage.settings_updated `AdminLanguages` after the write Runs after the settings of a language are saved. Parameters 2 $idstringThe key of the language updated. $packagearrayThe settings written: display name, rank, whether it reads right to left, and status. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:language.settings_updated', 10, function ($id, $package) { Acme::syncLocale($id, $package); }); ``` ### Pitfalls > **The translation filter is a hot path** > > **Every** piece of text passes through it, hundreds of times on a single page. Sift on the key before doing anything heavy, and leave straight away for calls you do not care about. > **The saved list is not the submitted list** > > The list carried by the translation save event holds **only the languages whose value actually changed**. Languages submitted unchanged are absent, so do not answer "which languages were sent" from it. ### Related Articles - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) ## System Event Hooks https://dev.wisecp.com/en/system-event-hooks The ten hooks over the installation itself: add-ons, announcements, imports, tasks, versions, the site map and certificates. ### Overview These hooks watch **the installation itself** rather than a customer: which add-on was installed, which announcement went out, which transfer finished, whether a new version appeared. Three of them face outward: the site map towards search engines, the certificate sweep towards your alerting, and the transfer page towards the parties looking at it. ### Reference #### Following an add-on being installed actionaddon.installed `AdminModules` after install Runs after an add-on package is installed. Being installed does not mean it is switched on. Parameters 2 $modulestringThe key of the installed add-on. $activatedboolWhether it was **switched on** during the install. A false means it sits on disk but does not run. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:addon.installed', 10, function ($module, $activated) { // Installed does not mean switched on. if ($activated) Acme::onEnabled($module); }); ``` #### Following an add-on status change actionaddon.status_changed `AdminModules` one of two values Runs when an add-on is switched on or off. Parameters 2 $keystringThe key of the add-on. $statusstringThe new state: `enable` or `disable`. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:addon.status_changed', 10, function ($key, $status) { if ($status === 'disable') Acme::pauseIntegration($key); }); ``` #### Following an add-on being deleted actionaddon.deleted `AdminModules` after deletion Runs after an add-on is removed. Parameters 1 $modulestringThe key of the removed add-on. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:addon.deleted', 10, function ($module) { Acme::cleanupIntegration($module); }); ``` #### Following an announcement being saved actionannouncement.saved `AdminAnnouncements` create and edit Runs after an announcement is saved. Parameters 3 $savedIdintThe id of the announcement. $dataarrayThe saved data: title, message, type, target country and language, product group, server, date range and status. $isNewboolTrue when newly added, false when updated. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:announcement.saved', 10, function ($savedId, $data, $isNew) { // Carry only a new announcement to the outside channel. if ($isNew) Acme::broadcast($data['title'] ?? '', $data['message'] ?? ''); }); ``` #### Following an import finishing actionimport.completed `Imports` per data type Runs when a transfer from another system finishes. It fires **per data type**: customers, products and invoices each finish separately. Parameters 3 $platformstringThe platform data came from. $typestringThe type of data transferred. $resultarrayWhat the transfer produced: progress, counters and status. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:import.completed', 10, function ($platform, $type, $result) { // It fires per type: this does not mean "everything is done". Acme::noteImport($platform, $type, $result); }); ``` #### Following a task being saved actiontask.saved `AdminTasks` create and edit Runs when a task inside the panel is saved. Parameters 2 $idintThe id of the task. $is_newboolTrue when it was newly created. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:task.saved', 10, function ($id, $is_new) { if ($is_new) Acme::mirrorTask($id); }); ``` #### Following a new version being spotted actionupdates.new_version_detected `Updates` notice only Runs when a new core version is spotted. **Nothing is upgraded**: this is only news. Parameters 2 $responsearrayWhat the new version carries: its number, type and change list. $current_versionstringThe version currently installed. Return 1 voidThe return is ignored. Listener PHP ```php Hook::add('action:updates.new_version_detected', 10, function ($response, $current_version) { // Nothing is upgraded here, it is only reported. Acme::notifyOps('new version: ' . ($response['version'] ?? '?')); }); ``` #### Adding addresses to the site map filtersitemap.links `Sitemap` per language Runs while the site map is produced. Announce your own pages to search engines here. Parameters 2 $linksarrayby linkThe list of full addresses. Add or remove freely. Empty entries and repeats are cleaned as the output is made; the order holds. $ctxarrayby linkContext: the language of this map and the address keys the **active theme does not serve**. The hook runs once per language: build your address for that language. Return 1 voidThe return is ignored; you add to the list. Listener PHP ```php Hook::add('filter:sitemap.links', 10, function (&$links, &$ctx) { // The hook runs once per language. foreach (Acme::publicPages($ctx['lang'] ?? '') as $url) $links[] = $url; }); ``` #### Reporting certificates about to expire filterssl.expiring_services `cronjobs` returns a contribution Runs while certificates nearing their end are collected. Add certificates the core does not know about here. Parameters 1 $maxDaysintThe warning window: how many days ahead to report. Return 1 array|nullYou return your own **contribution**: the services nearing their end, each with the days left. The core merges every return, so give your share rather than the whole list. Listener PHP ```php Hook::add('filter:ssl.expiring_services', 10, function ($maxDays) { // Return only YOUR share: the core merges them all. return Acme::expiringCerts($maxDays); }); ``` #### Enriching the transfer verification page filterlicense.transfer.verify_context `LicenseTransfer` passed by link Runs while the licence transfer verification page is shown. Add information of your own here. Parameters 1 $ctxarrayby linkThe page context: the transfer state and record, the service, and the parties handing over and receiving. Return 1 voidThe return is ignored; you write over the context. Listener PHP ```php Hook::add('filter:license.transfer.verify_context', 10, function (&$ctx) { $ctx['acme_note'] = Acme::transferNote((int) ($ctx['transfer']['id'] ?? 0)); }); ``` ### Pitfalls > **An import finishes per data type** > > The import event fires **separately for each type**: customers finish, then products, then invoices. Reading one call as "the whole import is done" starts work on half the data. > **The certificate hook wants a contribution, not a list** > > In the expiry sweep you return your own **share**. The core merges every return, so handing back the whole list duplicates records. ### Related Articles - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) # API ## The WISECP API https://dev.wisecp.com/en/the-wisecp-api What the API is, which surface answers which caller, and the first call you can make against an installation. ### Overview The API is a second way into the same installation. Everything it exposes is the work the panel already does: creating a client, opening an invoice, provisioning a service, reading a ticket. The difference is who asks. The panel is for a person at a screen; the API is for your code. It speaks JSON over HTTPS with real HTTP methods and real status codes. A call carries an API key, never a session cookie, so nothing about it depends on someone being logged in. It is not a mirror of the database. A resource is a business action with its rules attached. A call goes through the same guards the panel does: permissions, validation, hooks, activity records. ### Prerequisites - The address of an installation you are allowed to integrate with. - An API key. Which kind depends on the surface you need, and the next article covers both. - A client that can send a request header. Anything can call this API; the samples use cURL, JavaScript and PHP. ### Structure There are three surfaces under `/api/v1`, and the segment after the version decides which one answers. - **/api/v1/admin**: The operator's reach: every client, order, invoice, product and setting in the installation. Backs an internal tool, a migration or a reseller panel of your own. - **/api/v1/client**: One customer managing their own account. Every call is bounded to the account the key belongs to, so a key can never read a neighbour. - **/api/v1/**: Paths a module publishes for itself. The installation ships none of its own here; what answers is whatever the installed modules registered. The two credentialed surfaces are separate products, not one with a filter over it. A key issued for one is refused on the other, and the address is what tells them apart. ### Example The health check needs no key, so it is the honest first call. It proves the address is right and the API is reachable, before any credential is in play. ```bash curl 'https://panel.example.com/api/v1/admin/ping' # {"data":{"pong":true,"version":"v1","time":"2026-08-06 10:12:41"}} # with a key, this one reports who the key is and what it may do curl -H 'Authorization: Bearer wak_...' \ 'https://panel.example.com/api/v1/admin/whoami' ``` ### Pitfalls > **Nothing is pushed to you** > > There is no webhook delivery. An integration that needs to know when something happened has two options. It can ask again on a schedule, or run its own code inside the installation through a hook. Reacting to Events covers both. > **v1 is frozen, and that is a promise to you** > > New endpoints, new response fields and new optional inputs keep arriving in v1. Removing a field, renaming one or changing a type does not. Neither does turning an optional input into a required one. That work waits for a v2, and an endpoint on the way out carries a `Deprecation` header with at least six months of overlap. ### Related Articles - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [API Resources](https://dev.wisecp.com/en/api-resources) - [Client API Overview](https://dev.wisecp.com/en/client-api-overview) - [Reacting to Events](https://dev.wisecp.com/en/reacting-to-events) ## API Authentication and Permissions https://dev.wisecp.com/en/api-authentication-and-permissions How a key is issued, how it travels on a request, and how its permissions decide what it may reach. ### Overview Every credentialed call carries an API key and nothing else. There are no sessions and no cookies, so a call behaves the same from a server, a laptop or a browser tab. A key holds three things: the surface it belongs to, the list of permissions it was granted, and a request budget per minute. Those three are checked in that order on every request, and the first one that says no ends the call. The key itself is never stored. What the installation keeps is a hash of it plus the first few characters for display, so a lost key cannot be recovered from the panel. It can only be replaced. ### Prerequisites - Panel access to issue an admin key, or a customer account to issue a client key. - A place to store the key that is not your source tree. ### Walkthrough #### Issue a Key 1. For the admin surface, open **Settings → API Credentials** in the panel and create a credential. 2. For the client surface, the customer creates their own under **API Credentials** in their account, at `/api-credentials`. 3. Copy the key from the confirmation. It is shown once and never again. #### Grant Permissions 1. Tick the actions the key needs. Each checkbox is one `Group/Action` pair, which is the same name the endpoint documents. 2. Grant the narrowest set that does the job. A key that only reads invoices cannot be turned against your clients. 3. Optionally raise or lower the per-minute budget for that one key; empty means the installation default. #### Send It and Verify 1. Put the key in an `Authorization: Bearer` header, or in `X-Api-Key` if your client cannot set the first one. 2. Call `whoami` on the surface you issued for. It answers with the key's identity, its permissions and, on the client surface, the account behind it. 3. A `403` here means the key is real but belongs to the other surface. Check the address, not the key. ### Reference - **wak_ + 32 characters**: Reaches `/api/v1/admin`. Issued by staff, scoped by permission, not tied to any one customer. - **wck_ + 32 characters**: Reaches `/api/v1/client`. Bound to the account that created it; every query it makes is filtered by that account. - **Clients/GetClients**: One action. The safe default and the one the documentation names on every endpoint. - **Clients/***: Every action in that group, including ones added by a later version. Convenient, and wider than it looks. - *****: The whole surface. Reserve it for a key you fully control and can rotate quickly. The refusals are distinct on purpose, so a failing integration tells you which of the three checks stopped it. - **401 missing_token**: No key arrived. Usually a header the client dropped, not a permission problem. - **403 audience_mismatch**: A real key on the wrong surface. - **403 insufficient_scope**: The key is valid and the endpoint exists, but that action was not granted. The message names the missing permission. - **429 rate_limited**: The per-minute budget is spent. `Retry-After` says how long to wait. ### Example ```bash KEY=$(cat ~/.config/wisecp.key) curl -H "Authorization: Bearer $KEY" \ 'https://panel.example.com/api/v1/admin/whoami' # {"data":{"id":7,"type":"admin","name":"Billing sync","permissions":["Invoices/*","Clients/GetClients"]}} ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/whoami'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WISECP_API_KEY')], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); $permissions = $body['data']['permissions'] ?? []; ``` ### Pitfalls > **The key is shown once** > > Only a hash is kept, so there is no screen that reveals it later and no support request that can retrieve it. If it is lost, regenerate the credential and update whatever was using it. Treat a regeneration as a small outage and plan it. > **Ignoring a 429 costs more than obeying it** > > The budget is a fixed one-minute window: 120 requests for an admin key and 60 for a client key unless the installation says otherwise. A caller that keeps hammering far past its budget is locked out for a while rather than merely refused. Read `X-RateLimit-Remaining` and pause on `Retry-After`. > **Repeated bad credentials park your address** > > Failed authentication is counted per address, and enough failures in a short window earn a temporary block with a `too_many_auth_failures` answer. A retry loop around a wrong key will find this before you do, so fail loudly on `401` instead of retrying. ### Related Articles - [The WISECP API](https://dev.wisecp.com/en/the-wisecp-api) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [API Resources](https://dev.wisecp.com/en/api-resources) - [Client API Overview](https://dev.wisecp.com/en/client-api-overview) ## Request and Response Format https://dev.wisecp.com/en/request-and-response-format One envelope, one set of status codes and one pagination shape, on every endpoint of every surface. ### Overview The shape of a call does not change from resource to resource. If you can read one response you can read all of them, and error handling written once keeps working as your integration grows. Two rules carry most of it. A successful answer puts its payload under `data`, and a failed one puts a machine-readable code under `error`. The HTTP status always agrees with which of the two arrived. ### Reference #### The Request Send JSON with a `Content-Type: application/json` header. A form-encoded POST is accepted as well, which helps when a client cannot set the header. Filters, sorting and paging travel in the query string on every method. - **Authorization: Bearer**: The API key. `X-Api-Key` is the fallback for clients that cannot set this one. - **Content-Type**: Set it to `application/json` when the body is JSON. Without it the body is read as form fields. - **Idempotency-Key**: Optional, POST only, 8 to 191 characters. A repeat of the same key returns the stored answer instead of doing the work twice. - **X-Http-Method-Override**: For callers behind a proxy that allows only GET and POST. The value replaces the method. #### The Envelope Success carries `data`, plus `meta` when there is anything to say about the payload. Failure carries `error` with a stable `code`, a human message and, on validation failures, a `details` map naming the fields. ```json { "data": { "id": 42, "email": "ada@example.com" } } { "data": [ ], "meta": { "total": 318, "page": 2, "limit": 25, "next_page": 3 } } { "error": { "code": "validation_failed", "message": "Email is not valid.", "details": { "email": "invalid_format" } } } ``` Branch on the key, not on the text. `code` is part of the contract and stays put; `message` is written for a human reading a log and may be reworded. #### Status Codes - **200 · 201**: Done. Creation answers with 201 and returns the new record. - **401**: No key, or one the installation does not know. Fix the credential; a retry will not help. - **403**: The key is known but not allowed here: wrong surface, missing permission, or a write while demo mode is on. - **404**: No such endpoint, or no such record for this key. On the client surface a record owned by someone else answers the same way. - **409**: A request with the same `Idempotency-Key` is still running. Wait and retry. - **413**: The body is past the server upload limit. It never reached the resource, so nothing was saved. - **422**: The input was understood and refused. `details` names the fields. - **429**: Too many requests. Honour `Retry-After`. - **500**: Something failed on the far side. The detail is kept in the operator's log, not in your response. #### Pagination List endpoints take `page` and `limit` in the query string. `limit` defaults to 25 and is capped at 100, so asking for more silently gives you 100. Walk pages with `meta.next_page`, which is `0` on the last one. Offset paging is the v1 contract. Cursor paging exists only where the data is a timeline, such as ticket messages, and those endpoints document their own cursor fields. #### Response Headers Every credentialed answer reports the state of your budget, so you can pace yourself without guessing. - **X-RateLimit-Limit**: Requests allowed in the current one-minute window. - **X-RateLimit-Remaining**: What is left of it. - **X-RateLimit-Reset**: Unix time when the window turns over. - **Idempotency-Replayed**: Present when the answer came from storage rather than fresh work. ### Example ```bash curl -X POST 'https://panel.example.com/api/v1/admin/clients' \ -H 'Authorization: Bearer wak_...' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: signup-8f21c0' \ -d '{"name":"Ada","surname":"Lovelace","email":"ada@example.com"}' # 201 {"data":{"id":91,...}} # the same call again returns the same body, and does not create a second client # 201 Idempotency-Replayed: true ``` ### Pitfalls > **Totals live under meta, not at the top** > > A list answer keeps its count at `meta.total`. Reading `total` from the root gives you nothing and no error, which reads as an empty result set. The same goes for `page` and `limit`. > **Demo mode refuses every write** > > While an installation is in demo mode, POST, PUT, PATCH and DELETE come back as `403 demo_mode` before any resource is reached. Reads keep working, so a demo stays browsable through the API. An integration that fails only on writes is worth checking against this first. ### Related Articles - [The WISECP API](https://dev.wisecp.com/en/the-wisecp-api) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [API Resources](https://dev.wisecp.com/en/api-resources) ## API Resources https://dev.wisecp.com/en/api-resources How the endpoints are grouped, how a name becomes a permission, and how to find the one you need. ### Overview The API is organised by resource, not by table. A resource is an area of the product: clients, invoices, services, products, tickets. Inside it, an action is one thing you can do. That pair is the API's whole naming system. `Clients/GetClients` is the route's identity and the permission you tick when issuing a key. It is also the string in a `403` message and the name the documentation uses. Learn one of them and you know the rest. Actions read as verbs on purpose: `Get`, `Create`, `Update`, `Delete`, `Bulk`. The HTTP method agrees with the verb, so a name and a method never tell you different stories. ### Structure The admin surface groups its resources into families. You rarely need more than one family for a given integration, which is also the natural boundary for the permissions you grant. - **system · reference**: Health, key identity, and the lists every other call fills its fields from: currencies, countries, states, cities, languages, billing cycles, statuses. - **clients · admins · affiliates**: Customer records with their addresses, groups, documents and access, plus staff accounts and the affiliate programme. - **orders · invoices · financial · services**: The money path end to end: an order becomes an invoice, a paid invoice becomes a service, and a service renews. - **products · modules**: What you sell and what provisions it: products with their pricing, add-ons and requirements, plus the module registry behind them. - **tickets · knowledgebase · website**: Departments and ticket traffic, published articles, and the public site's pages and menus. - **settings · automation · languages · notifications · tools**: The installation's own machinery: configuration, scheduled work, language packs, notification templates and maintenance tools. The client surface carries a much shorter list, because a customer manages one account rather than an installation. It is covered in its own article. #### Finding an Endpoint Work down, not up. Pick the family, open its reference article in this section, and read the action list. Each entry gives the method, the path, the permission name and a working sample in four languages. If you know the object but not the family, the name usually gives it away. Anything about a customer's own record lives in `clients`. Anything with a price on it lives in `invoices` or `orders`, and anything running on a server lives in `services`. ### Example Code that already runs inside an installation does not need HTTP, a key or a network round trip. It can call the same resource in process, and gets back the same envelope. ```php // over HTTP: GET /api/v1/admin/clients?limit=5 // in process, from a module, a cron handler or a hook listener: $result = \WISECP\Api\Kernel::internal('Clients/GetClients', [], ['limit' => 5]); $clients = $result['data'] ?? []; $total = $result['meta']['total'] ?? 0; // the client surface needs to be told whose account it is acting on $me = \WISECP\Api\Kernel::internal('client:Account/GetMe', ['owner_id' => 42]); ``` ### Pitfalls > **A resource is not a table** > > Creating a client writes several tables, fires hooks and may send a notification. Reading one merges records that are stored apart. Expecting a column-for-field mapping will mislead you; read the action's field list instead of the schema. > **Installed modules can add groups of their own** > > A module may publish endpoints under a group named `Module:{Type}/{Name}`. They appear as their own permission checkboxes and behave like any other endpoint. They belong to that module and travel with it, so an integration that relies on one should say which module it needs. ### Related Articles - [The WISECP API](https://dev.wisecp.com/en/the-wisecp-api) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [Client API Overview](https://dev.wisecp.com/en/client-api-overview) ## Calling the API In Process https://dev.wisecp.com/en/calling-the-api-in-process Code that already runs inside an installation can call any endpoint without HTTP, a key or a network hop. ### Overview Every endpoint sample in this documentation carries a fourth tab: **PHP (Internal)**. That tab is not a shortcut for the same HTTP request. It is a second entry point into the same resource. It is meant for code living inside the installation: a module, a cron handler, a hook listener, a theme. The reason to use it is not speed alone. Calling your own installation over HTTP means issuing a key to yourself and spending that key's rate budget. It also depends on the web server being reachable from itself. The in-process call has none of those and returns the exact same envelope. The two are interchangeable at the point where you read the answer. It is a trusted context by definition. Authentication, permissions, the rate limiter, the abuse guard, idempotency and the request log belong to the HTTP layer. None of them run here. Whatever you pass is executed. ### Reference #### The Signature - **Kernel::internal($groupAction, $input = [], $query = []): array**: Static. Returns the same payload an HTTP call would. Success gives `['data' => …, 'meta' => …]` and failure gives `['error' => ['code', 'message', 'details'?]]`. It never throws for an API-level failure and never prints anything. - **$groupAction**: The endpoint's own name, `Group/Action` — the same string the documentation and the permission checkbox use. Prefix it with `client:` to reach the client surface. - **$input**: One flat array holding both the path parameters and the body. Keys whose name matches a placeholder in the route pattern are lifted out as path parameters, in pattern order. Everything left over becomes the request body. - **$query**: The query string, as an array. Filters, sorting, `page` and `limit` go here, not in `$input`. #### How $input Splits A route such as `services/{id}/addons` has one placeholder. Passing `['id' => 5, 'name' => 'Backup']` sends `5` as the path parameter and leaves `['name' => 'Backup']` as the body. You never build the URL yourself, which is why a route change does not break your call. #### The Client Surface An HTTP call to the client surface takes the account from the key. There is no key here, so the account has to be named: pass `owner_id` in `$input` and prefix the action with `client:`. It is removed from the input before dispatch, so it never reaches the resource as a body field. Omitting it is refused rather than guessed. The call returns `owner_required` and nothing runs. #### What Comes Back on Failure - **endpoint_not_found**: No endpoint answers that `Group/Action` on that surface. A typo lands here, and so does an admin action called with a `client:` prefix. - **owner_required**: A client call without `owner_id`. - **validation_failed · not_found · …**: Whatever the resource itself refuses, in the same shape and with the same code as over HTTP. - **server_error**: An unexpected failure. The detail goes to the error log, not into the returned message, unless the installation is in debug. ### Example ```php use WISECP\Api\Kernel; // admin surface, a list with query options $res = Kernel::internal('Clients/GetClients', [], ['page' => 1, 'limit' => 50, 'status' => 'active']); if (isset($res['error'])) { \Logger::warning('client sync failed: ' . $res['error']['code']); return; } foreach ($res['data'] as $client) { /* ... */ } $more = (int) ($res['meta']['next_page'] ?? 0); // path parameter + body in one array $upd = Kernel::internal('Clients/UpdateClient', ['id' => 42, 'company_name' => 'Acme Ltd']); // client surface: the account is named, not inferred $me = Kernel::internal('client:Account/GetMe', ['owner_id' => 42]); ``` ### Pitfalls > **No permission is checked here, so check your own** > > An HTTP caller is filtered by its key's permissions before a resource is reached. An in-process call is not filtered at all, because the caller is the installation. If your code runs on behalf of a person, decide what that person may do before you call. Never let a request value choose the action or the target account. > **Module endpoints cannot be reached this way** > > The name is split at its first slash, and a module group already contains one: `Module:Addons/AcmeSync`. The split produces the wrong pair and the call answers `endpoint_not_found`. Code inside the installation should call the module's own class or operation directly; the endpoint exists for outside callers. > **Nothing is written to the request log** > > The API log records HTTP traffic. An in-process call leaves no row there, so an integration that mixes both will show only half its activity to the operator. If a background job needs an audit trail, write one of your own. > **Demo mode does not stop it** > > The demo guard refuses writes at the HTTP layer. This path is below it, so a cron handler or a hook listener keeps writing on an installation in demo mode. That is deliberate: the installation's own machinery has to keep running. It is worth remembering when a demo shows data a demo should not have. ### Related Articles - [API Resources](https://dev.wisecp.com/en/api-resources) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [Reacting to Events](https://dev.wisecp.com/en/reacting-to-events) - [Client API Overview](https://dev.wisecp.com/en/client-api-overview) ## Reacting to Events https://dev.wisecp.com/en/reacting-to-events Nothing is pushed to your integration, so you either ask again or run your own code where the event happens. ### Overview There is no webhook delivery. That is a deliberate boundary. The installation never holds a queue of your endpoints and never retries against them. It also never leaks work to an address it cannot verify. Two approaches replace it, and they answer different questions. - **Ask again**: Call a list endpoint on a schedule and act on what changed. Needs nothing but a key, works from anywhere, and lags by however long your interval is. - **Be there when it happens**: Register a listener on a hook and your code runs inside the request that caused the event. Immediate and exact, and it requires code installed on the server. Choose by where your code can live. An integration outside the installation polls. A module, an add-on or a theme shipped with the installation listens. ### Prerequisites - For the listener route: somewhere to put PHP inside the installation, which in practice means a module of your own. - The exact name of the hook you want. Names are catalogued, and one that does not exist fails quietly. ### Walkthrough #### Pick the Hook 1. Find the moment you care about in the hook catalogue, in the Hooks section of this documentation. 2. Read its parameter list and its mechanism. Some hooks report what happened, others let you change a value, and a third kind can refuse an operation outright. 3. Note the argument count. Declaring more arguments than the hook sends drops your listener. #### Register a Listener 1. Put a `hooks.php` in your module and add the listener there. 2. Keep the body cheap. It runs inside somebody's request, and slow work there is felt by the person waiting. 3. Hand anything slow to a queue, a file or a scheduled task, and let that do the outbound call. #### Verify It Runs 1. Trigger the event once for real and confirm your side saw it. 2. If nothing happened, look in the error log before touching the code. One message reports a listener that ran and threw. Another reports a listener whose class or method name could not be resolved. 3. Neither message means the hook name itself is wrong, or the listener returned nothing on purpose. ### Example This listener reacts to a new order and reads it back through the API in the same process. It leaves the outbound call to a queue. ```php \Hook::add('action:order.created', 10, function (array $order) { $id = (int) ($order['id'] ?? 0); if (!$id) return; // the same resource the HTTP surface exposes, without a key or a round trip $full = \WISECP\Api\Kernel::internal('Orders/GetOrder', ['id' => $id]); if (isset($full['error'])) return; // queue it; do not call your own endpoint from inside someone's checkout \Acme\Sync::enqueue('order.created', $full['data']); }); ``` - **action:order.created**: Runs after the order row is written and before the confirmation notification. Receives the order payload merged with its new id. The return is ignored, so the early exits in the listener above only end your own code. - **action:invoice.status_changed**: The counterpart on the money side: fires whenever an invoice moves between states, including into paid. Receives the reloaded invoice, the new status, the previous one and the transition options. The return is ignored. ### Pitfalls > **A broken listener is skipped, not surfaced** > > The engine catches whatever a listener throws, writes it to the error log and moves to the next one. The page it happened on carries on as if nothing was registered. So "my code never runs" and "my code fails every time" look identical from the outside. The log is the only place they differ. > **A null argument shifts the ones after it** > > Arguments are bound by position and a null one is skipped, so the next value slides into its place. A listener that declared two arguments can receive the second value in the first slot with no warning at all. Read the catalogue entry for what is actually sent. > **Polling has a floor, and it is your rate limit** > > A one-second poll spends a key's whole minute budget in one minute. Pick an interval your integration can live with and filter server-side so each call stays small. Stop on a `429` instead of tightening the loop. ### Related Articles - [The WISECP API](https://dev.wisecp.com/en/the-wisecp-api) - [API Resources](https://dev.wisecp.com/en/api-resources) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Hook Domains](https://dev.wisecp.com/en/hook-domains) # API / Admin API ## Reference Data https://dev.wisecp.com/en/reference-lists The seven lookup endpoints turning raw numbers and codes into readable labels. ### Overview The WISECP API returns **raw values**: a status code, a country number, a currency number. An interface has to turn those into something a person reads, and the dictionary for that sits in these seven endpoints. The split is deliberate. The resource endpoints stay raw because a label shifts with language and time, while the dictionary stands apart because it rarely moves and can be **fetched once and kept**. Three endpoints form the address chain: country, state and city. The chain runs one way, and each step wants the number from the step before. ### Reference #### The Currencies get/api/v1/admin/reference/currencies `Reference/GetCurrencies` admin Returns the currencies defined, with their numbers. Response fields data[] — 4 idintThe currency number. The currency fields on other endpoints carry it. codestringThe three-letter code. namestringThe name shown. is_defaultboolWhether this is the system default. 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/reference/currencies' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/reference/currencies', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const byId = Object.fromEntries(data.map((c) => [c.id, c.code])); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/currencies'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The numbers are NOT ISO numerics: they belong to the installation and differ between them. $rows = Api::Reference()->GetCurrencies()['data']; $code = array_column($rows, 'code', 'id'); // [4 => 'USD'] ``` #### The Countries get/api/v1/admin/reference/countries `Reference/GetCountries` admin Returns the countries with a number, a code and a translated name. Query 1 langstringThe language the labels come back in. The panel's current language stands in when it is left out. Response fields data[] — 3 idintThe country number. The country fields on other endpoints carry it. codestringThe two-letter country code. namestringThe country name. It is translated into the language asked for. 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/reference/countries?lang=en' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/reference/countries?lang=en', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const name = Object.fromEntries(data.map((c) => [c.id, c.name])); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/countries?lang=en'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Other endpoints want the COUNTRY NUMBER and not the two-letter code; build the map here. $rows = Api::Reference()->GetCountries([], ['lang' => 'en'])['data']; $idOf = array_column($rows, 'id', 'code'); // ['US' => 840] ``` #### The States get/api/v1/admin/reference/states `Reference/GetStates` admin Returns the states of one country. Query 1 country_idintreqThe country number. Response fields data[] — 2 idintThe state number. namestringThe state name. Errors 2 country_required422No country number was given. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/reference/states?country_id=840' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/reference/states'); url.searchParams.set('country_id', countryId); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const { data } = await res.json(); if (! data.length) allowFreeText(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/states?country_id=' . $countryId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // AN EMPTY list is normal: not every country has states, and the address field is free text there. $states = Api::Reference()->GetStates([], ['country_id' => $countryId])['data']; $freeText = ! $states; ``` #### The Cities get/api/v1/admin/reference/cities `Reference/GetCities` admin Returns the cities of one state. Query 1 state_idintreqThe state number. Response fields data[] — 2 idintThe city number. namestringThe city name. Errors 2 state_required422No state number was given. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/reference/cities?state_id=6' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/reference/cities'); url.searchParams.set('state_id', stateId); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/cities?state_id=' . $stateId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The chain runs one way: a city wants the country FIRST and then the state number. $states = Api::Reference()->GetStates([], ['country_id' => $countryId])['data']; $cities = Api::Reference()->GetCities([], ['state_id' => $states[0]['id']])['data']; ``` #### The Languages get/api/v1/admin/reference/languages `Reference/GetLanguages` admin Returns the languages open to clients, in order. Response fields data[] — 2 codestringThe language key. This is what goes into the language parameter of other endpoints. namestringThe name shown for the language. 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/reference/languages' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/reference/languages', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); renderLanguagePicker(data); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/languages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The OPEN languages alone come back; the language management endpoint shows the closed ones too. $open = Api::Reference()->GetLanguages()['data']; $all = Api::Languages()->GetLanguages()['data']; ``` #### The Billing Cycles get/api/v1/admin/reference/cycles `Reference/GetCycles` admin Pairs the billing cycle codes with the labels people read. Query 1 langstringThe language the labels come back in. The panel's current language stands in when it is left out. Response fields data[] — 2 codestringThe cycle code. The order and service endpoints use it. labelstringThe translated label. 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/reference/cycles?lang=en' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/reference/cycles?lang=en', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const label = Object.fromEntries(data.map((c) => [c.code, c.label])); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/cycles?lang=en'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list is a LABEL source and not a gate: which cycles a product sells is a separate question. $labels = array_column(Api::Reference()->GetCycles()['data'], 'label', 'code'); $sold = Api::Products()->GetProduct(['id' => $pid])['data']['prices'] ?? []; ``` #### The Status Codes get/api/v1/admin/reference/statuses `Reference/GetStatuses` admin Pairs the status codes of one kind of record with their labels. Query 2 entitystringWhich kind you want: `client` or `product`. The client set stands in when it is left out. langstringThe language the labels come back in. The panel's current language stands in when it is left out. Response fields data — 2 entitystringThe kind asked for. statusesobject[] statuses[]The code and label pairs. valuestringThe status code. labelstringThe translated label. Errors 2 entity_invalid422An unknown kind. The answer's detail gives the list supported. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/reference/statuses?entity=client&lang=en' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/reference/statuses?entity=client', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const label = Object.fromEntries(data.statuses.map((s) => [s.value, s.label])); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/reference/statuses?entity=client'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // TWO kinds exist alone: invoice, order and ticket statuses are absent here and sit in their own articles. $out = Api::Reference()->GetStatuses([], ['entity' => 'client'])['data']; $map = array_column($out['statuses'], 'label', 'value'); ``` ### Pitfalls > **The numbers belong to the installation and are not universal** > > A currency number is the installation's own record, and one currency can carry different numbers on two installations. Resolve it from this list rather than writing it into your code. Country numbers follow the same rule: the **code** travels and the number does not. > **An empty state list is not an error** > > Not every country has states, and the state endpoint returns an **empty list** for one that does not. An address form should offer free text there rather than a picker. An interface reading the empty list as a failure blocks address entry for those countries outright. > **The status list covers two kinds alone** > > The status endpoint gives the client and product states. Invoice, order, service and ticket states are **absent**, and their code lists sit in their own articles. Asking for a kind that is unknown gives an error whose detail shows the list supported. > **The label language comes with the request and not with the key** > > With no language parameter the labels come back in the panel's current language. That language can be **hard to predict** for a background job or a report builder. Always name the language when you mean to keep the result. > **Fetch once, keep it, refresh rarely** > > These lists are not meant to be fetched on every request. Country and city data hardly ever move, while currencies and languages shift when a setting does. Fetching once at start-up and holding the result is quicker and spends none of your quota. ### Related Articles - [Language Packages](https://dev.wisecp.com/en/language-packages) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) ## Health and Key Check https://dev.wisecp.com/en/health-and-key-check The two endpoints saying the API is up and what your key can do. ### Overview These two endpoints answer an integration's first two questions: **is the API up** and **what can this key do**. The health check wants no credentials and can be called straight from a monitor or a version check. The key check wants a key while wanting **no scope**: every valid key may read its own permissions. Together they make a diagnostic pair. When the health check passes and the key check fails, the trouble is with the credentials rather than the server. ### Reference #### The Health Check get/api/v1/admin/ping `System/Ping` no key needed Returns that the API is up, along with the server time. Response fields data — 3 pongboolWhether the API is up. versionstringThe API version. timestringThe server's time. It comes in the server's own time zone. Errors — ——This endpoint is open to everyone and returns no error. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/ping' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/ping'); const { data } = await res.json(); const drift = Math.abs(Date.parse(data.time) - Date.now()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/ping'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint WANTS NO KEY: reaching it says nothing about whether your key works. $up = Api::System()->Ping()['data']['pong'] ?? false; ``` #### What the Key Is get/api/v1/admin/whoami `System/Whoami` no scope needed Returns which key you are using and what it may do. Response fields data — 4 idintThe key id. namestringThe name the key was given. permissionsstring[]The scopes the key carries. Group and action pairs. last_accessstringWhen it was last used. Errors 4 missing_token401The authorisation header was not sent. invalid_token401The key is not valid. ip_not_allowed403The request came from outside the addresses allowed. rate_limited429The request limit was passed. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/whoami' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/whoami', { headers: { Authorization: `Bearer ${apiKey}` }, }); if (res.status === 401) return promptForKey(); const { data } = await res.json(); const canReadInvoices = data.permissions.some((s) => s.startsWith('Invoices/')); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/whoami'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Reading the scopes UP FRONT beats learning them from a 403 on every call. $me = Api::System()->Whoami()['data']; $may = fn (string $s) => in_array($s, $me['permissions'], true) || in_array(explode('/', $s)[0] . '/*', $me['permissions'], true); ``` ### Pitfalls > **The health check does not verify your key** > > The health endpoint checks no credentials: it **answers successfully** even with a key revoked, expired or never sent. Resting a connection test on it alone reports a healthy system while the real calls fail. > **The server time is not your time** > > The time returned sits in the server's own zone and can differ from yours. The dates on the other endpoints share that zone, so comparing one against your own clock gives **results that do not line up**. Take this endpoint's time as the reference. > **The scope list can carry a wildcard** > > The permission list can hold a group wildcard or a star covering everything rather than the actions one by one. Looking for an action with a **plain comparison** gives a false negative on a key that carries a wildcard. Test the group wildcard as well. > **The quickest way to tell an auth error apart** > > When a call answers `403` the cause is either a missing scope or the wrong key. The key endpoint tells the two apart in one call: with it working the key is valid and the trouble lies **in the scope**. A restricted address and a passed request limit show here as well. ### Related Articles - [Reference Lists](https://dev.wisecp.com/en/reference-lists) - [API Credentials](https://dev.wisecp.com/en/api-credentials) # API / Admin API / Administrators ## Staff Accounts https://dev.wisecp.com/en/staff-accounts The six endpoints that open, update and remove the staff accounts using the panel. ### Overview Staff accounts are the people who **can sign into the panel**. They live in a record set apart from clients, yet their e-mail addresses **share one pool** with them. What an account can do rests on its **privilege group**, and which support tickets it sees on the **departments** it is assigned to. Both are handled at endpoints of their own. The installation's **founding account** is guarded: it cannot be removed and its privileges cannot change. Your own account is guarded in part as well, since you can neither lower your own privileges nor remove yourself. ### Reference #### Listing the Staff get/api/v1/admin/admins `Admins/GetStaff` admin Returns the accounts that can sign into the panel. Query 3 pageintWhich page. limitintRecords per page. Clamped between one and a hundred. searchstringSearches the name, e-mail, phone and id. Response fields data[] — 7 + meta — 4 idintThe staff id. full_namestringTheir name. emailstringTheir e-mail address. statusstringWhether the account is live. privilege_namestringThe name of the privilege group they belong to. departmentsstring[]The departments they are assigned to. is_rootboolWhether it is the installation's founding account. It cannot be removed and its privileges cannot change. totalintHow many there are. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/admins' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The founding account carries its own mark; leave it out when writing bulk work. $staff = Api::Admins()->GetStaff()['data']; $rest = array_filter($staff, fn ($s) => ! $s['is_root']); ``` #### Opening a Staff Account post/api/v1/admin/admins `Admins/CreateStaff` admin grants panel access Opens a new account able to sign into the panel. Body 15 full_namestringreqTheir name. emailstringreqTheir e-mail address. Unique across staff and clients alike. passwordstringreqThe account password. password_confirmationstringreqThe password again. privilegeintreqThe privilege group to join. This sets what the account can do in the panel. langstringreqThe panel language. display_namestringThe name clients see. Left empty, the real name shows. phonestringThe phone number. Long enough, it gets formatted and checked for being unique. departmentsint[]The departments to assign. Left out on an update, the current ones stay. signatureobject | stringThe reply signature per language. notesstringA note on the account. display_modestringThe panel's look preference. menu_statestringHow the menu opens. statusstringWhether the account is live. Live by default. avatarstringThe profile picture. Response fields 201 — data dataobjectThe account opened. Same shape as the detail endpoint. Errors 4 staff_save_failed422The e-mail or phone is taken, the privilege group is not valid, or the passwords differ. create_failed422The account could not be opened. blocked_by_gate422A hook refused the account. 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/admins' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"Ayse Yilmaz","email":"ayse@ornek.com","password":"G1zliParola","password_confirmation":"G1zliParola","privilege":2,"lang":"tr"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ full_name: 'John Doe', email: 'john@example.com', password: secret, password_confirmation: secret, privilege: 2, lang: 'en', departments: [1, 2], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'full_name' => 'John Doe', 'email' => 'john@example.com', 'password' => $secret, 'password_confirmation' => $secret, 'privilege' => 2, 'lang' => 'en', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The privilege group decides WHAT THE ACCOUNT CAN DO; do not default to the widest one. Api::Admins()->CreateStaff([ 'full_name' => 'John Doe', 'email' => $email, 'password' => $secret, 'password_confirmation' => $secret, 'privilege' => $limitedGroupId, 'lang' => 'en', ]); ``` #### Reading One Staff Member get/api/v1/admin/admins/{id} `Admins/GetStaffMember` admin Returns one staff account with all of its fields. Response fields data — 21 idintThe staff id. statusstringWhether the account is live. full_namestringTheir name. namestringTheir first name. surnamestringTheir last name. emailstringTheir e-mail address. phonestringTheir phone number. langstringThe panel language. privilege_idintThe privilege group they belong to. gsm_ccstringTheir mobile country code. gsmstringTheir mobile number. residence_addressstringTheir address. notesstringThe note on the account. display_modestringTheir look preference. menu_statestringHow their menu opens. signatureobject | stringTheir reply signature per language. has_2faboolWhether the second step of sign-in is on. authentication_methodsstring[]The names of the verification methods in use. Only the names, and never a secret. department_idsint[]The departments they are assigned to. avatar_urlstringThe address of their profile picture. is_rootboolWhether it is the founding account. Errors 2 not_found404No such staff member. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/admins/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The password NEVER comes back, and on the verification side only method NAMES show. $s = Api::Admins()->GetStaffMember(['id' => $id])['data']; ``` #### Updating a Staff Member patch/api/v1/admin/admins/{id} `Admins/UpdateStaff` admin some fields are guarded Changes the staff account fields you send. Body 15 full_namestringTheir name. emailstringTheir e-mail address. passwordstringA new password. Send it together with its repeat. password_confirmationstringThe new password again. privilegeintThe privilege group. Ignored on the founding account and on your own. langstringThe panel language. display_namestringThe name clients see. Left empty, the real name shows. phonestringThe phone number. Long enough, it gets formatted and checked for being unique. departmentsint[]The departments to assign. Left out on an update, the current ones stay. signatureobject | stringThe reply signature per language. notesstringA note on the account. display_modestringThe panel's look preference. menu_statestringHow the menu opens. statusstringWhether the account is live. Live by default. avatarstringThe profile picture. Response fields data — 21 dataobjectThe account as it now stands. Same shape as the detail endpoint. Errors 3 not_found404No such staff member. staff_save_failed422The e-mail or phone is taken, the privilege group is not valid, or the passwords differ. 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/admins/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"Ayse Kaya","departments":[1,3]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ full_name: 'Jane Doe', departments: [1, 3] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['full_name' => 'Jane Doe', 'departments' => [1, 3]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A department list REPLACES what was there; leave the field out to keep the current ones. Api::Admins()->UpdateStaff(['id' => $id, 'full_name' => 'Jane Doe']); ``` #### Removing a Staff Member delete/api/v1/admin/admins/{id} `Admins/DeleteStaff` admin Removes a staff account. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the account removed. Errors 6 not_found404No such staff member. root_protected422The founding account cannot go. self_protected422You cannot remove your own account. delete_failed422The account could not be removed. blocked_by_gate422A hook refused the delete. 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/admins/6' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/' . $id); 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 // For someone who left, switch the account OFF rather than delete: past work keeps its owner. Api::Admins()->UpdateStaff(['id' => $id, 'status' => 'passive']); ``` #### Turning Off the Second Step post/api/v1/admin/admins/{id}/disable-authentication `Admins/DisableStaffAuthentication` admin the panel asks for a password Takes away a staff member's second sign-in step. Body 1 methodstringreqThe verification method to turn off. Its name comes from the list on the staff detail. Response fields data — 2 disabledboolWhether it was turned off. methodstringThe method turned off. Errors 2 not_found404No such staff member. 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/admins/5/disable-authentication' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"method":"GoogleAuthenticator"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/${id}/disable-authentication`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ method: 'GoogleAuthenticator' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/' . $id . '/disable-authentication'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['method' => 'GoogleAuthenticator']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // In the panel this asks for the ADMIN PASSWORD; over the API the key's scope is the only gate. $s = Api::Admins()->GetStaffMember(['id' => $id])['data']; foreach ($s['authentication_methods'] as $m) Api::Admins()->DisableStaffAuthentication(['id' => $id, 'method' => $m]); ``` ### Pitfalls > **The e-mail pool is shared with clients** > > A staff account's e-mail has to be unique not only among staff but **among clients too**. The same address cannot carry both a client and a staff account. On an installation that also registers its own team as clients, this surfaces as an unexpected clash error. > **You cannot lower your own privileges** > > The update call quietly ignores the privilege and status fields **on the founding account and on your own**. You cannot lock yourself out by accident, yet neither can you assume the value you sent was applied. Read the privilege group in the response and weigh it against what you expected. > **The department list replaces what was there** > > Sending a department list on an update **replaces** what was assigned. Sending only the one you meant to add drops the rest. To keep the current assignments leave the field out entirely, and to add one, read the detail first and merge. > **Turning off the second step is easier here** > > Turning off a staff member's second sign-in step asks for the **administrator password** in the panel, while here the key's scope is the only gate. A key carrying it can strip an account's extra protection outright. Weigh this scope on its own when handing keys out. > **Switch a departed member off rather than delete** > > Deleting a staff member **takes the account away**, while the work they did, the replies they wrote and the notes they left keep pointing at them in the records. Switching the account off stops the sign-in and leaves the history readable. The founding account and your own cannot be removed at all. ### Related Articles - [Staff Privilege Groups](https://dev.wisecp.com/en/staff-privilege-groups) - [Staff Departments](https://dev.wisecp.com/en/staff-departments) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) ## Staff Privilege Groups https://dev.wisecp.com/en/staff-privilege-groups The six endpoints that set what staff can do in the panel. ### Overview A privilege group decides what a staff member **can see and can change** in the panel. Every staff member belongs to one group, and what that group carries is what they carry. The full list of privileges that can be granted comes from an **endpoint of its own**. The catalogue follows the installation: modules installed add their own, and a few depend on the country. The installation's **founding group** is guarded: it cannot be removed and cannot lose the right to manage privileges. That keeps anyone from leaving themselves unable to grant anything. ### Reference #### Reading the Privilege Catalogue get/api/v1/admin/admins/privilege-keys `Admins/GetPrivilegeKeys` admin Returns every privilege a group can carry, gathered into groups. Response fields data[] — 4 + meta groupstringThe group key. group_labelstringThe group's readable name. In the panel's current language. singleboolWhether the group holds one privilege alone. When true the group name and the privilege name say the same thing. permissionsobject[]The privileges in the group: each with its key and readable name. totalintHow many groups there are. It comes back under meta. 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/admins/privilege-keys' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins/privilege-keys', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const keys = data.flatMap((g) => g.permissions.map((p) => p.key)); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/privilege-keys'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The catalogue follows the INSTALLATION: modules add privileges, and some depend on country. $groups = Api::Admins()->GetPrivilegeKeys()['data']; ``` #### Listing the Groups get/api/v1/admin/admins/privileges `Admins/GetPrivileges` admin Returns the privilege groups defined and who sits in them. Query 3 pageintWhich page. limitintRecords per page. searchstringSearches the group name. Response fields data[] — 5 + meta — 4 idintThe group id. namestringThe group name. permission_countintHow many privileges it carries. appointeesobject[]The staff in this group: their ids and names. is_rootboolWhether it is the founding group. It cannot be removed. totalintHow many there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. 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/admins/privileges' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins/privileges', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/privileges'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list gives the COUNT rather than the privilege NAMES; read the detail for those. $groups = Api::Admins()->GetPrivileges()['data']; ``` #### Creating a Group post/api/v1/admin/admins/privileges `Admins/CreatePrivilege` admin Defines a new privilege group. Body 2 namestringreqThe group name. permissionsstring[]The privilege keys to grant. Valid keys come from the catalogue endpoint. Response fields 201 — data — 4 dataobjectThe group created. Same shape as the detail endpoint. Errors 5 name_required422The group name is empty. permissions_exceed_holder422The group would grant permissions the account behind your key does not hold. A key owned by the root privilege group is not capped. create_failed422The group could not be created. blocked_by_gate422A hook refused the save. 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/admins/privileges' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Destek Ekibi","permissions":["TICKETS"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins/privileges', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Support Team', permissions: ['ADMIN_CONFIGURE', 'TICKETS'], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/privileges'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Support Team', 'permissions' => ['ADMIN_CONFIGURE', 'TICKETS'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A group with no privileges is valid: it gets created while its members see nothing. Api::Admins()->CreatePrivilege([ 'name' => 'Support Team', 'permissions' => ['TICKETS'], ]); ``` #### Reading One Group get/api/v1/admin/admins/privileges/{pid} `Admins/GetPrivilege` admin Returns one privilege group with the privileges it carries. Response fields data — 4 idintThe group id. namestringThe group name. permissionsstring[]The privilege keys in the group. is_rootboolWhether it is the founding group. Errors 2 not_found404No such privilege group. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/admins/privileges/1' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/privileges/${pid}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/privileges/' . $pid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read this BEFORE adding a privilege: the write call REPLACES the list rather than adding. $g = Api::Admins()->GetPrivilege(['pid' => $pid])['data']; ``` #### Updating a Group patch/api/v1/admin/admins/privileges/{pid} `Admins/UpdatePrivilege` admin the list replaces Changes a group's name and the privileges it carries. Body 2 namestringThe group name. permissionsstring[]The complete set of privileges. Your list replaces what was there. Response fields data — 4 dataobjectThe group as it now stands. Same shape as the detail endpoint. Errors 7 not_found404No such privilege group. name_required422The group name is empty. root_needs_privileges422The founding group cannot lose the privilege-management right. permissions_exceed_holder422The group would grant permissions the account behind your key does not hold. A key owned by the root privilege group is not capped. save_failed422The group could not be saved. blocked_by_gate422A hook refused the save. 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/admins/privileges/3' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"permissions":["TICKETS","SERVICES"]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/privileges/${pid}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ permissions: ['ADMIN_CONFIGURE', 'TICKETS', 'SERVICES'], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/privileges/' . $pid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['permissions' => ['TICKETS', 'SERVICES']]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To add one privilege, read the CURRENT list and append; otherwise the rest drop away. $g = Api::Admins()->GetPrivilege(['pid' => $pid])['data']; $g['permissions'][] = 'SERVICES'; Api::Admins()->UpdatePrivilege(['pid' => $pid, 'permissions' => $g['permissions']]); ``` #### Removing a Group delete/api/v1/admin/admins/privileges/{pid} `Admins/DeletePrivilege` admin Removes a privilege group. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the group removed. Errors 4 not_found404No such privilege group. root_protected422The founding group cannot go. delete_failed422The group could not be removed. 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/admins/privileges/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/privileges/${pid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/privileges/' . $pid); 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 // Move the STAFF INSIDE first: accounts bound to the group can be left without footing. $g = current(array_filter( Api::Admins()->GetPrivileges()['data'], fn ($x) => $x['id'] === $pid, )); if (! $g['appointees']) Api::Admins()->DeletePrivilege(['pid' => $pid]); ``` ### Pitfalls > **The privilege list is replaced, not added to** > > The privilege list you send on an update **replaces** what was there. Sending only the new privilege to widen a group strips every other one from it, and the staff inside suddenly see next to nothing in the panel. Read the detail first and append to it. > **The catalogue follows the installation** > > The list of privilege keys is not fixed: modules installed add their own, and a few depend on the **installation's country**. A script with the keys written into it tries to grant something that does not exist on another installation. Read the catalogue each time. > **The founding group cannot lose privilege management** > > Taking the privilege-management right away from the founding group is **refused**. The lock is deliberate: were that right lost, no account could grant anything again and the installation would be left unmanageable. The group cannot be removed either. > **Empty a group before removing it** > > Removing a privilege group can leave the staff bound to it **without footing**. The listing gives the people in each group by name, so look there and move the accounts to another group first. Removing an empty group is safe. > **The listing does not name the privileges** > > The group listing gives the **number of privileges** alone and not which ones. Two groups can carry the same count and do entirely different things. To see what a group really allows, read it from the detail endpoint. ### Related Articles - [Staff Accounts](https://dev.wisecp.com/en/staff-accounts) - [Staff Departments](https://dev.wisecp.com/en/staff-departments) - [API Credentials](https://dev.wisecp.com/en/api-credentials) ## Staff Departments https://dev.wisecp.com/en/staff-departments The six endpoints that set up support departments and assign their staff. ### Overview Departments decide **which team a support ticket lands with**. A client picks a department when opening one, and the staff assigned there are the people who can take it on. A department's name and description are kept **per language**. Creating one wants a name in every live language on the installation, while an update changes the ones you send alone. The icon comes in two forms: a **typeface icon** or an uploaded **image**. A separate endpoint removes the image, and the type falls back to the typeface once it goes. ### Reference #### Listing the Departments get/api/v1/admin/admins/departments `Admins/GetDepartments` admin Returns the support departments and who handles them. Query 3 pageintWhich page. limitintRecords per page. searchstringSearches the department name. Response fields data[] — 7 + meta — 4 idintThe department id. namestringIts name. In the panel's current language. descriptionstringWhat it is for. iconstringIts icon. icon_typestringWhether the icon comes from a typeface or an image. icon_urlstringThe image icon's address. Filled for an image icon alone. appointee_idsint[]The ids of the staff handling it. totalintHow many there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. 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/admins/departments' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins/departments', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const orphan = data.filter((d) => d.appointee_ids.length === 0); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/departments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A ticket landing in a department with no staff can be assigned to NOBODY; look for empty lists. $deps = Api::Admins()->GetDepartments()['data']; $orphan = array_filter($deps, fn ($d) => ! $d['appointee_ids']); ``` #### Creating a Department post/api/v1/admin/admins/departments `Admins/CreateDepartment` admin every language needed Opens a new support department. Body 7 namesobjectreqThe department name per language. A name is needed in every live language on the installation. descriptionsobjectThe description per language. rankintWhere it sits in the listing. appointeesint[]The staff handling it. These are the people a ticket can be assigned to. icon_typestringWhether the icon comes from a typeface or an image. A typeface by default. iconstringThe typeface icon's class. icon_imagestringThe icon image to upload. Response fields 201 — data — 7 dataobjectThe department created. Same shape as the detail endpoint. Errors 3 name_required422The name is missing in one of the live languages. create_failed422The department could not be created. 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/admins/departments' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"names":{"tr":"Genel","en":"General"},"appointees":[1,5]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/admins/departments', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ names: { en: 'General', tr: 'Genel' }, descriptions: { en: 'General questions' }, appointees: [1, 5], icon_type: 'font', icon: 'fa-solid fa-globe', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/departments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'names' => ['en' => 'General', 'tr' => 'Genel'], 'appointees' => [1, 5], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Creating wants a name in EVERY live language; an update touches the ones you send alone. Api::Admins()->CreateDepartment([ 'names' => ['en' => 'General', 'tr' => 'Genel'], 'appointees' => [1, 5], ]); ``` #### Reading One Department get/api/v1/admin/admins/departments/{did} `Admins/GetDepartment` admin Returns one department with all of its languages. Response fields data — 7 idintThe department id. rankintWhere it sits in the listing. iconstringIts icon. icon_typestringWhether the icon comes from a typeface or an image. icon_urlstringThe image icon's address. appointee_idsint[]The ids of the staff handling it. translationsobjectThe name and description per language. Every language comes together. Errors 2 not_found404No such department. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/admins/departments/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/departments/${did}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/departments/' . $did); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list gives ONE language and the detail gives them all; translation work starts here. $d = Api::Admins()->GetDepartment(['did' => $did])['data']; $en = $d['translations']['en']['name'] ?? ''; ``` #### Updating a Department patch/api/v1/admin/admins/departments/{did} `Admins/UpdateDepartment` admin Changes the department fields and languages you send. Body 7 namesobjectThe department name per language. Only the languages you send change. descriptionsobjectThe description per language. rankintWhere it sits in the listing. appointeesint[]The staff handling it. These are the people a ticket can be assigned to. icon_typestringWhether the icon comes from a typeface or an image. A typeface by default. iconstringThe typeface icon's class. icon_imagestringThe icon image to upload. Response fields data — 7 dataobjectThe department as it now stands. Same shape as the detail endpoint. Errors 3 not_found404No such department. name_required422A name is empty in one of the languages you sent. 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/admins/departments/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"names":{"tr":"Genel Destek"},"appointees":[1,5,8]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/departments/${did}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ names: { en: 'General Support' }, appointees: [1, 5, 8], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/departments/' . $did); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'names' => ['en' => 'General Support'], 'appointees' => [1, 5, 8], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The staff list REPLACES what was there: read the current one before adding to it. $d = Api::Admins()->GetDepartment(['did' => $did])['data']; $d['appointee_ids'][] = $newStaffId; Api::Admins()->UpdateDepartment([ 'did' => $did, 'appointees' => $d['appointee_ids'], ]); ``` #### Removing a Department delete/api/v1/admin/admins/departments/{did} `Admins/DeleteDepartment` admin Removes a department. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the department removed. Errors 3 not_found404No such department. blocked_by_gate422A hook refused the delete. 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/admins/departments/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/departments/${did}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/departments/' . $did); 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 // Move the tickets sitting there to another department BEFORE removing this one. Api::Tickets()->UpdateTicket(['id' => $ticketId, 'department_id' => $otherDid]); Api::Admins()->DeleteDepartment(['did' => $did]); ``` #### Removing the Icon delete/api/v1/admin/admins/departments/{did}/icon `Admins/DeleteDepartmentIcon` admin Removes a department's image icon. Response fields data — 7 dataobjectThe department as it now stands. The icon type falls back to the typeface. Errors 2 not_found404No such department. 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/admins/departments/4/icon' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/admins/departments/${did}/icon`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/admins/departments/' . $did . '/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 // The removal drops the file and turns the type back to a typeface; the icon can stay empty. Api::Admins()->DeleteDepartmentIcon(['did' => $did]); Api::Admins()->UpdateDepartment(['did' => $did, 'icon' => 'fa-solid fa-globe']); ``` ### Pitfalls > **A department with no staff orphans its tickets** > > When no staff are assigned to a department, a ticket landing there **can be assigned to nobody** and the picker on the ticket comes back empty. The department still shows and clients still choose it, so the gap surfaces only once a ticket arrives. Sweep the listing for empty staff lists. > **The staff list replaces what was there** > > The staff list you send on an update **replaces** what was assigned. Sending only the person you meant to add takes the others out of the department and changes what tickets they see. Read the detail first and merge the list. > **Creating wants every language, updating does not** > > Creating a department wants a name in **every live language** on the installation, and the call is refused when one is missing. An update touches only the languages you send and keeps the rest. Reading the two behaviours as one surfaces as an unexpected error at creation. > **Move the tickets before removing** > > Removing a department **takes the footing away** from the tickets bound to it. The tickets do not vanish, yet they point at a department that no longer exists and grow harder to find in the listings. Move them to another department first. > **Removing the icon puts nothing in its place** > > The endpoint that removes an image icon drops the file and turns the type back to a typeface, while **putting nothing in its place**. With the typeface field empty the department shows with no icon at all. Writing a typeface icon after the removal is usually the step you want. ### Related Articles - [Staff Accounts](https://dev.wisecp.com/en/staff-accounts) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) - [Staff Privilege Groups](https://dev.wisecp.com/en/staff-privilege-groups) # API / Admin API / Affiliates ## Affiliate Partners https://dev.wisecp.com/en/affiliate-partners The four endpoints that see, search, switch and remove the partners in the program. ### Overview The affiliate program is where a client earns commission on the customers their own link brings in. This article manages the **people** in that program: who is there, who is on and who goes. Every record carries **two numbers**. `id` belongs to the partner record and `owner_id` to the client behind it. The assignment endpoints speak the client number, so mixing the two makes a quiet wrong assignment. The search endpoint sits here because it feeds the assignment screens: an interface that does not know the number types a name first, then hands the number it gets to the assignment. ### Reference #### Listing the Partners get/api/v1/admin/affiliates `Affiliates/GetAffiliates` admin Returns the partners in the program with their earnings and referral totals. Query 4 pageintWhich page. limitintRecords per page. 100 at the most. searchstringSearches the name, company, e-mail, partner id and client id. statusstringThe status filter: `active` or `inactive`. Response fields data[] — 13 + meta — 4 idintThe partner record id. owner_idintThe client behind the partner. This is the number assignments use. user_idintThe same client id. A second name coming from the query join. full_namestringThe client's name. company_namestringThe company name. emailstringThe e-mail address. disabledintWhether it is off. 0 means on and 1 means off. balancestringThe earnings waiting to be paid. Text with four decimals. currencyintThe currency id of the earnings. referralsintHow many clients they referred. hitsintHow many clicks their link took. total_paidstringWhat has been paid out so far. It counts completed requests alone. datestringWhen they joined the program. totalintHow many partners there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Zero on the last one. 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/affiliates?status=active&limit=50' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates?status=active', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const owing = data.filter((a) => Number(a.balance) > 0); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates?status=active'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // id belongs to the PARTNER and owner_id to the CLIENT behind it; assignments speak owner_id. $rows = Api::Affiliates()->GetAffiliates([], ['status' => 'active'])['data']; $byClient = array_column($rows, 'balance', 'owner_id'); ``` #### Searching Partners, Clients and Services get/api/v1/admin/affiliates/select `Affiliates/SelectForAffiliate` admin 10 results at most The search behind the assignment screens; you type a name instead of an id. Query 2 typestringWhat to look for: `affiliate`, `client` or `service`. It looks for a partner by default. qstringThe search text. It is taken under the name `search` as well. Response fields data — 2 typestringThe kind searched. The same one you sent. resultsobject[] results[]What was found. idintThe id of the record found. textstringThe label to show. A service gets its id and name, a person their name and company. 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/affiliates/select?type=client&q=jane' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/affiliates/select'); url.searchParams.set('type', 'client'); url.searchParams.set('q', term); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $qs = http_build_query(['type' => 'client', 'q' => $term]); $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/select?' . $qs); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // It returns 10 results at most: this is a search box and not a listing. $hits = Api::Affiliates()->SelectForAffiliate([], ['type' => 'client', 'q' => $term])['data']; $exact = count($hits['results']) === 1 ? $hits['results'][0]['id'] : 0; ``` #### Turning a Partner On and Off put/api/v1/admin/affiliates/{aid}/status `Affiliates/SetAffiliateStatus` admin Turns a partner on or takes them out of the program. Body 1 enabledboolreqWhether the partner is on. It becomes the opposite-named field on the record. Response fields data — 2 idintThe partner id. enabledboolWhere it now stands. Errors 2 not_found404No such partner. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/affiliates/7/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/affiliates/${aid}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/' . $aid . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Turning a partner off DOES NOT wipe the earnings: the balance stays and payouts can still be asked for. Api::Affiliates()->SetAffiliateStatus(['aid' => $aid, 'enabled' => false]); ``` #### Removing a Partner delete/api/v1/admin/affiliates/{aid} `Affiliates/DeleteAffiliate` admin cannot be undone Removes a partner along with every record tied to them. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the partner removed. Errors 2 not_found404No such partner. 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/affiliates/7' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/affiliates/${aid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/' . $aid); 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 // The panel asks for an admin password here while the API needs the scope alone. Consider turning them off first. Api::Affiliates()->SetAffiliateStatus(['aid' => $aid, 'enabled' => false]); // Api::Affiliates()->DeleteAffiliate(['aid' => $aid]); // the earnings history goes as well ``` ### Pitfalls > **Two numbers: the partner id and the client id** > > In the listing `id` is the partner record and `owner_id` the client behind it. The link kept on the client record also holds the **client number** rather than the partner one. Keep the two apart, since the assignment and status endpoints want different numbers. > **Being off is spoken with two opposite words** > > The listing gives a `disabled` field where **0 means on**. The status endpoint takes `enabled` where **true means on**. They are opposite names for one thing, and copying one into the other flips the state. > **Removing takes the earnings history too** > > Removing a partner also takes the commission entries, the payout requests, the clicks, the history and the referral records, while the linked clients lose their link. The panel asks for an admin password at this step and the API settles for the scope. Turn a partner off rather than remove them when you mean to part ways. > **The search endpoint gives ten results at most** > > The search endpoint is a search box and not a listing: it returns ten results at most no matter how many match, and there is no paging. Resting bulk work on it drops everything past the tenth without a word. Use the listing endpoint for the full set. > **The paid total counts completed requests alone** > > The `total_paid` field is the sum of completed payout requests. A waiting or in-flight request does not enter that number, and it is not taken out of `balance` either. Do not answer "what do we owe" by adding the two figures. ### Related Articles - [Affiliate Assignments](https://dev.wisecp.com/en/affiliate-assignments) - [Affiliate Payout Requests](https://dev.wisecp.com/en/affiliate-payout-requests) - [Affiliate Program Settings](https://dev.wisecp.com/en/affiliate-program-settings) ## Affiliate Program Settings https://dev.wisecp.com/en/affiliate-program-settings The two endpoints holding the commission rate, period, delay and payout floor. ### Overview The program settings decide **how much** commission is given, **when** and **for how long**. Two endpoints read and write one structure. Three settings work together: the rate sets the size of the commission, the period says whether it comes once or on every renewal, and the delay says when the earnings count as payable. The cookie duration answers a separate question: how many days after a click on the link a visitor can become a customer and still be credited to that partner. ### Reference #### Reading the Program Settings get/api/v1/admin/affiliates/config `Affiliates/GetAffiliateConfig` admin Returns every setting of the affiliate program. Response fields data — 11 enabledboolWhether the program is on. Service assignment is refused while it is off. view_without_membershipboolWhether the program page opens to someone with no account. show_commission_ratesboolWhether commission rates show on the product page. commission_periodstringWhether commission comes once or on every renewal: `onetime` or `lifetime`. commission_delayintThe days waited before commission counts as usable. ratefloatThe default commission rate. A product can carry its own. min_paymentobject min_paymentThe least that can be asked for in a payout. amountfloatThe least amount. currency_idintThe currency id of that amount. cookie_durationintHow many days a referral is remembered. redirectstringWhere a click on the link lands. The home page when empty. payment_gatewaysobject[]The payout methods a partner can pick. Each element is an object holding a name per language. contentobjectThe program page text per language. 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/affiliates/config' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/config', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const rate = data.rate; const floor = data.min_payment.amount; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/config'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read here BEFORE saving: the save writes a default over any field you leave out. $cfg = Api::Affiliates()->GetAffiliateConfig()['data']; $cfg['rate'] = 15; ``` #### Saving the Program Settings put/api/v1/admin/affiliates/config `Affiliates/SaveAffiliateConfig` admin a full write Writes the whole set of settings and returns where they now stand. Body 11 enabledboolWhether the program is on. Service assignment is refused while it is off. view_without_membershipboolWhether the program page opens to someone with no account. show_commission_ratesboolWhether commission rates show on the product page. commission_periodstringWhether commission comes once or on every renewal: `onetime` or `lifetime`. commission_delayintThe days waited before commission counts as usable. ratefloatThe default commission rate. A product can carry its own. min_paymentobject min_paymentThe least that can be asked for in a payout. amountfloatThe least amount. currency_idintThe currency id of that amount. cookie_durationintHow many days a referral is remembered. redirectstringWhere a click on the link lands. The home page when empty. payment_gatewaysobject[]The payout methods a partner can pick. Each element is an object holding a name per language. contentobjectThe program page text per language. Response fields data — 11 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/affiliates/config' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"commission_period":"lifetime","commission_delay":30,"rate":10,"cookie_duration":30,"min_payment":{"amount":50,"currency_id":840}}' ``` ```javascript const read = await fetch('https://panel.example.com/api/v1/admin/affiliates/config', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data: cfg } = await read.json(); const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/config', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ...cfg, rate: 15 }), }); ``` ```php $cfg = $current; // GET /affiliates/config yaniti $cfg['rate'] = 15; $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/config'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($cfg), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A FULL WRITE: every field you leave out returns to its default (texts and payout methods GO EMPTY). $cfg = Api::Affiliates()->GetAffiliateConfig()['data']; $cfg['rate'] = 15; Api::Affiliates()->SaveAffiliateConfig($cfg); ``` ### Pitfalls > **The save is a full write and not a patch** > > The save endpoint writes **every field** in the body. A field you leave out is not kept and returns to its default: numbers to zero, texts to empty, the payout methods and program texts to an empty list. To change the rate alone, read first and write over the object that comes back. > **The payout methods are a list and not a language map** > > The program text is an object with a key per language. The payout methods are a **list** whose every element holds a name per language of its own. Writing them the same way empties the method list, and partners see no option when asking for a payout. > **The commission period is not checked** > > The period field is cleaned up alone and **not checked** against the values that are known. A misspelled value saves without a word, no error comes back, and the period does not behave as you expect. Read the value back after saving. > **Turning the program off does not wipe existing earnings** > > While the program is off a new service assignment is refused, and existing balances and waiting payout requests stay where they are. Turning it off stops new commission from being made rather than freezing what exists. ### Related Articles - [Affiliate Partners](https://dev.wisecp.com/en/affiliate-partners) - [Affiliate Assignments](https://dev.wisecp.com/en/affiliate-assignments) - [Affiliate Payout Requests](https://dev.wisecp.com/en/affiliate-payout-requests) ## Affiliate Payout Requests https://dev.wisecp.com/en/affiliate-payout-requests The three endpoints that see and settle the payout requests partners open. ### Overview A partner cannot take the commission they earn straight away; they open a **payout request** and the team approves it. This article covers the three endpoints that see and settle those requests. A request sits in one of five states: waiting, in process, completed, refused or cancelled. Only **completed** is the moment the money truly leaves, and it comes off the partner's balance. What happens to the balance matters here: an approval takes the money down, a removal gives it back, and a refusal **leaves the balance alone**. Reading the three as one shows the partner a wrong figure. ### Reference #### Listing the Payout Requests get/api/v1/admin/affiliates/withdrawals `Affiliates/GetWithdrawals` admin Returns the payout requests the partners opened. Query 4 pageintWhich page. limitintRecords per page. 100 at the most. searchstringSearches the name, company, e-mail, payout method and request id. statusstringThe status filter: `awaiting`, `process`, `completed`, `rejected` or `cancelled`. Response fields data[] — 15 + meta — 4 idintThe request id. affiliate_idintThe partner record that opened it. owner_idintThe client behind the partner. user_idintThe same client id. A second name coming from the query join. full_namestringThe client's name. company_namestringThe company name. emailstringThe e-mail address. amountstringThe amount asked for. Text with four decimals. currencyintThe currency id of the amount. gatewaystringThe payout method the partner picked. gateway_infostringThe detail for that method. An account number or e-mail. statusstringWhere the request stands. status_msgstringThe note written on the status. ctimestringWhen the request was opened. updated_atstringWhen it last changed. totalintHow many requests there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Zero on the last one. 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/affiliates/withdrawals?status=awaiting' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/withdrawals?status=awaiting', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); const owed = data.reduce((s, w) => s + Number(w.amount), 0); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/withdrawals?status=awaiting'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The amounts sit in EACH REQUEST's own currency; convert before adding them up. $rows = Api::Affiliates()->GetWithdrawals([], ['status' => 'awaiting'])['data']; $byCurrency = []; foreach ($rows as $w) $byCurrency[$w['currency']] = ($byCurrency[$w['currency']] ?? 0) + (float) $w['amount']; ``` #### Handling Requests in Bulk post/api/v1/admin/affiliates/withdrawals/bulk `Affiliates/BulkWithdrawals` admin touches the balance Approves, refuses or removes several payout requests. Body 2 actionstringreqWhat to do: `approve`, `reject` or `delete`. idsint[]reqThe request ids to work on. An id that is not found gets skipped without a word. Response fields data — 2 actionstringThe job applied. processedint[]The ids you sent. Not the ones that truly changed. Errors 3 action_invalid422The job name is none of the three values. ids_required422No request id was given. 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/affiliates/withdrawals/bulk' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"action":"approve","ids":[3,5]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/withdrawals/bulk', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'approve', ids: [3, 5] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/withdrawals/bulk'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['action' => 'approve', 'ids' => [3, 5]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // APPROVE takes it off the balance and REJECT DOES NOT give it back; delete is the one job that refunds. Api::Affiliates()->BulkWithdrawals(['action' => 'approve', 'ids' => [3, 5]]); // approved by mistake: delete rather than reject Api::Affiliates()->BulkWithdrawals(['action' => 'delete', 'ids' => [3]]); ``` #### Removing One Request delete/api/v1/admin/affiliates/withdrawals/{wid} `Affiliates/DeleteWithdrawal` admin Removes one payout request and gives the amount back where needed. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the request removed. Errors 2 not_found404No such request. 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/affiliates/withdrawals/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/affiliates/withdrawals/${wid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/withdrawals/' . $wid); 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 // The one difference from a bulk delete: a missing id gives 404 here and is skipped in bulk. Api::Affiliates()->DeleteWithdrawal(['wid' => $wid]); ``` ### Pitfalls > **A refusal does not give the balance back** > > An approval takes the amount off the partner's balance. A refusal **writes the status alone** and does not put the amount back, so refusing a request you approved leaves the money short on that balance for good. The way back from a wrong approval is **removal** and not refusal. > **Approve, refuse, approve again takes it twice** > > An approval takes from the balance only while the request is **not already completed**. Refusing an approved request and approving it again drops the state out of completed, so it takes a second time and the partner loses twice for one payout. Check the balance by hand after a round like that. > **The list that comes back does not say what was handled** > > The `processed` field is the same set of **ids you sent**. A request that is not found gets skipped without a word, while its id still shows in that list. Read the statuses back from the listing endpoint to see that bulk work landed. > **The balance never goes below zero** > > Every job that takes from the balance stops at zero. Approving a request larger than the balance leaves it at zero and the **difference disappears** with no error. Compare the request amount against the partner's balance before approving. > **Two ways to remove, one difference** > > Removing one request and removing many do the same job, and both give the amount back on a completed request. The one difference is a missing id: the single endpoint answers `404` while the bulk one skips quietly. The single endpoint is safer for an id you are unsure of. ### Related Articles - [Affiliate Partners](https://dev.wisecp.com/en/affiliate-partners) - [Affiliate Assignments](https://dev.wisecp.com/en/affiliate-assignments) - [Affiliate Program Settings](https://dev.wisecp.com/en/affiliate-program-settings) ## Affiliate Assignments https://dev.wisecp.com/en/affiliate-assignments The six endpoints that link, list and unlink clients and services against a partner. ### Overview A partner earns in two ways. A **client link** says "they brought this customer in", while a **service link** writes commission on one particular sale. The two live in different places. A client link is a field on the client record, and a service link opens a commission record of its own. That is why the two removal endpoints speak different numbers. Linking a service looks for five conditions. The program has to be on and the service type suitable. The product must stay open to commission, the partner switched on, and the service free of another partner. ### Reference #### Listing the Linked Clients get/api/v1/admin/affiliates/assigned-clients `Affiliates/GetAssignedClients` admin Returns the clients linked to a partner. Query 3 pageintWhich page. limitintRecords per page. 100 at the most. searchstringSearches the client and partner name along with the company. Response fields data[] — 8 + meta — 4 idintThe client id. full_namestringThe client's name. company_namestringThe client's company. emailstringThe client's e-mail. aff_idintThe **client** number of the partner they are linked to. Not the partner record id. aff_namestringThe partner's name. aff_companystringThe partner's company. aff_user_idintThe partner's client id. totalintHow many linked clients there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Zero on the last one. 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/affiliates/assigned-clients?search=jane' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/assigned-clients', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const perPartner = Object.groupBy(data, (c) => c.aff_user_id); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/assigned-clients'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // aff_id is the partner's CLIENT number; do not try to match it with the partner record id. $rows = Api::Affiliates()->GetAssignedClients()['data']; $mine = array_filter($rows, fn ($c) => (int) $c['aff_user_id'] === $partnerClientId); ``` #### Linking a Client to a Partner post/api/v1/admin/affiliates/assigned-clients `Affiliates/AssignClient` admin Marks a client as a partner's referral. Body 2 affiliate_idintreqThe partner record id. client_idintreqThe id of the client to link. Response fields data — 3 assignedboolWhether the link was made. affiliate_idintThe partner id. client_idintThe client id. Errors 4 missing_params422The partner or client id is missing. assign_self422A client cannot be linked to their own partnership. not_found404No such partner or client. 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/affiliates/assigned-clients' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"affiliate_id":7,"client_id":88}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/assigned-clients', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ affiliate_id: 7, client_id: 88 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/assigned-clients'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['affiliate_id' => 7, 'client_id' => 88]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A client tied to another partner is taken over SILENTLY; the first partner loses the referral. $rows = Api::Affiliates()->GetAssignedClients()['data']; $owned = array_column($rows, 'aff_user_id', 'id'); if (! isset($owned[$clientId])) Api::Affiliates()->AssignClient(['affiliate_id' => $aid, 'client_id' => $clientId]); ``` #### Removing a Client Link delete/api/v1/admin/affiliates/assigned-clients `Affiliates/UnassignClients` admin Takes the partner link off one client or more. Body 1 idsint[]reqThe **client** ids. Not assignment ids. Response fields data — 1 unassignedint[]The client ids you sent. Errors 2 ids_required422No client id was given. 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/affiliates/assigned-clients' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[88,90]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/assigned-clients', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [88, 90] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/assigned-clients'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [88, 90]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is NO check: the link goes off every client you send, and unlinked ones fill the answer too. Api::Affiliates()->UnassignClients(['ids' => [88, 90]]); ``` #### Listing the Linked Services get/api/v1/admin/affiliates/assigned-services `Affiliates/GetAssignedServices` admin Returns the service records earning partners their commission. Query 4 pageintWhich page. limitintRecords per page. 100 at the most. searchstringSearches the client, partner, service name and record id. statusstringThe record status filter. Response fields data[] — 15 + meta — 4 idintThe commission record id. The removal endpoint wants this number. affiliate_idintThe partner record id. service_idintThe service id. service_namestringThe service name. user_idintThe id of the client owning the service. full_namestringThe client's name. company_namestringThe client's company. amountstringThe service amount. What the commission is worked out from. currencyintThe currency id of the amount. commissionstringThe commission written to the partner. statusstringWhere the record stands. A completed one already went to the balance. aff_namestringThe partner's name. aff_companystringThe partner's company. aff_user_idintThe partner's client id. ctimestringWhen the record was opened. totalintHow many records there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Zero on the last one. 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/affiliates/assigned-services?limit=50' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/assigned-services', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const earned = data.reduce((s, t) => s + Number(t.commission), 0); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/assigned-services'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The removal endpoint wants the RECORD id from here rather than the SERVICE id. $rows = Api::Affiliates()->GetAssignedServices()['data']; $recordId = array_column($rows, 'id', 'service_id')[$serviceId] ?? 0; ``` #### Linking a Service to a Partner post/api/v1/admin/affiliates/assigned-services `Affiliates/AssignService` admin five conditions Links a service to a partner and opens the commission record. Body 2 affiliate_idintreqThe partner record id. service_idintreqThe id of the service to link. Response fields data — 3 assignedboolWhether the link was made. affiliate_idintThe partner id. service_idintThe service id. Errors 8 missing_params422The partner or service id is missing. system_disabled422The affiliate program is off. type_unsupported422The service type does not take commission. Hosting, server, software and special types pass. product_disabled422The affiliate program is off for this product. affiliate_disabled422The partner is off. already_assigned422The service is linked to another partner. not_found404No such partner or service. 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/affiliates/assigned-services' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"affiliate_id":7,"service_id":305}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/affiliates/assigned-services', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ affiliate_id: 7, service_id: 305 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/assigned-services'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['affiliate_id' => 7, 'service_id' => 305]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Five conditions are checked at once; the two missed most are an off program and a service already linked. try { Api::Affiliates()->AssignService(['affiliate_id' => $aid, 'service_id' => $sid]); } catch (\Throwable $e) { $skipped[$sid] = $e->getMessage(); } ``` #### Removing a Service Link delete/api/v1/admin/affiliates/assigned-services/{tid} `Affiliates/UnassignService` admin touches the balance Removes the commission record and takes the commission back where needed. Response fields data — 2 unassignedboolWhether the removal ran. idintThe id of the record removed. Errors 2 not_found404No such commission record. 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/affiliates/assigned-services/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/affiliates/assigned-services/${tid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/affiliates/assigned-services/' . $tid); 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 // On a completed record the commission comes OFF the balance; the OPPOSITE way to a payout removal. Api::Affiliates()->UnassignService(['tid' => $recordId]); ``` ### Pitfalls > **The two removal endpoints want two different numbers** > > The endpoint removing a client link wants **client ids** in the body. The one removing a service link wants the **commission record id** in the address. Handing the second a service id either answers not found or removes the wrong record. Take the record id from the listing endpoint. > **Removing a client link checks nothing** > > This endpoint takes the link off every client id you send. It never looks at whether the id was linked to a partner, nor whether that client exists. All of them count as **removed** in the answer. A wrong list quietly cuts other clients' links. > **A link silently takes over an existing one** > > When a client is already linked to another partner the new link raises no error. It **writes over** the old one, and the first partner loses that referral. On the service side the same case is refused with `already_assigned`. Check the existing link from the listing endpoint before linking a client. > **Removing a commission record takes from the balance** > > Removing a completed commission record **takes** the amount off the partner's balance. Remember that removing a payout request **adds** to it: the two removals work in opposite directions. The balance never goes below zero, so a difference that cannot be taken disappears without a word. > **The link field holds a client id and not a partner id** > > The `aff_id` in the listing is the **client behind the partner** and not the partner record id. The linking endpoint, meanwhile, wants the partner record id in the body. Passing a number read from one listing straight into the other assigns to a different partner. > **The service type and product setting form a quiet gate** > > Commission is written on hosting, server, software and special services alone, and other types such as a domain get `type_unsupported`. The product's own setting can close commission as well. Gather those two cases apart during bulk linking work. ### Related Articles - [Affiliate Partners](https://dev.wisecp.com/en/affiliate-partners) - [Affiliate Payout Requests](https://dev.wisecp.com/en/affiliate-payout-requests) - [Affiliate Program Settings](https://dev.wisecp.com/en/affiliate-program-settings) # API / Admin API / Automation ## Automation Jobs https://dev.wisecp.com/en/automation-jobs The ten endpoints that watch the jobs in three queues and handle them one by one or in bulk. ### Overview The automation carries **three queues**: the scheduled tasks, the module calls and the notices. In each, job rows wait, run, complete or fail, and these ten endpoints work with those rows. The dashboard gives everything in **one call**: the worker's health, the queue counts, the task list, the recent jobs and the last twenty-four hours as a chart. Reach for it when writing monitoring rather than making five calls. Four endpoints touch one job (retry, cancel, clear the lock, delete), and **three reach the whole queue**. Those last three move hundreds of jobs with one call and cannot be undone. ### Reference #### Reading the Dashboard get/api/v1/admin/automation/dashboard `Automation/GetAutomationDashboard` admin Returns the whole state of the automation in one call. Query 3 chart_rangestringThe span the chart covers. chart_stagestringLimits the chart to one stage. chart_taskstringNarrows the chart to a single task. Response fields data — 5 statusobjectThe worker as it stands: its health, its last run, how long ago that was, how long to the next, which task is next and how many jobs wait. statsobjectThe queue and system counts: per queue the waiting, running, completed today and failed; across the system the open jobs, the waiting jobs, the longest wait and the gap between runs. tasksarrayThe task list: its name, its shown name, its frequency, whether it is off, how many jobs wait and run, how many completed and failed today, and its last error. jobsobjectThe last fifty job rows, the total job count and the state of the restore suspicion. chartarrayThe last twenty-four hours broken down by type into successes and failures. 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/automation/dashboard' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/dashboard', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); if (data.status.status !== 'ok') console.warn('worker', data.status.status); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/dashboard'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // ONE call is enough for monitoring; the dashboard carries what five endpoints would. $d = Api::Automation()->GetAutomationDashboard()['data']; $healthy = $d['status']['status'] === 'ok'; ``` #### Listing the Jobs get/api/v1/admin/automation/jobs `Automation/GetAutomationJobs` admin cursor paging Returns the queue feed, newest first. Query 2 limitintHow many rows to return. Clamped between ten and two hundred. before_idintFetches rows older than this id. This is how you page backwards. Response fields data — 4 jobsarrayThe job rows: id, type, title, status, label, time, attempt count and estimated start. totalintHow many rows the queue holds. It comes back on the first feed alone. restore_activeboolWhether a restore is suspected. It comes back on the first feed alone. has_moreboolWhether older rows remain. It comes back on a cursor call alone. 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/automation/jobs?limit=50' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/automation/jobs'); url.searchParams.set('limit', '50'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); // Daha eskisi: url.searchParams.set('before_id', data.jobs.at(-1).id) ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs?' . http_build_query(['limit' => 50])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The total and the restore mark come on the FIRST call alone; a cursor call carries neither. $first = Api::Automation()->GetAutomationJobs()['data']; $older = Api::Automation()->GetAutomationJobs([], [ 'before_id' => end($first['jobs'])['id'], ]); ``` #### Reading One Job get/api/v1/admin/automation/jobs/{queue}/{id} `Automation/GetAutomationJob` admin Returns a job's whole row with its payload and logs. Path 2 queuestringWhich queue: `cron`, `module`, `notification`. idintThe job id. Response fields data idintThe job id. typestringThe job type. statusstringThe job status. attemptsintHow many times it was tried. created_atstringWhen it joined the queue. completed_atstringWhen it finished. payloadobjectThe data given to the job. It arrives decoded. process_logsobjectThe logs kept while it ran. result_payloadobjectWhat the job left behind. result_summaryobjectA readable summary of the result. It comes back on the scheduled-task queue. Errors 4 invalid_queue422The queue key is not recognised. invalid_id422The job id is not valid. not_found404No such job. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/automation/jobs/cron/9001' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/jobs/cron/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The fields differ BY QUEUE; read past the common ones without assuming they are there. $job = Api::Automation()->GetAutomationJob(['queue' => 'cron', 'id' => $id])['data']; $logs = $job['process_logs'] ?? []; ``` #### Retrying a Job post/api/v1/admin/automation/jobs/{queue}/{id}/retry `Automation/RetryAutomationJob` admin Puts a job back among the waiting. Body — ——No body is needed. The queue and the id in the path name the job; send an empty body. Response fields data — 3 retriedboolWhether the call ran. queuestringThe job's queue. idintThe job id. Errors 3 invalid_queue422The queue key is not recognised. invalid_id422The job id is not valid. 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/automation/jobs/cron/9001/retry' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/jobs/cron/${id}/retry`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/' . $id . '/retry'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The attempt counter is CLEARED: a job that fails again looks as though it started afresh. Api::Automation()->RetryAutomationJob(['queue' => 'cron', 'id' => $id]); ``` #### Cancelling a Job post/api/v1/admin/automation/jobs/{queue}/{id}/cancel `Automation/CancelAutomationJob` admin Marks a job cancelled. Body — ——No body is needed. The queue and the id in the path name the job; send an empty body. Response fields data — 3 cancelledboolWhether the call ran. queuestringThe job's queue. idintThe job id. Errors 3 invalid_queue422The queue key is not recognised. invalid_id422The job id is not valid. 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/automation/jobs/cron/9001/cancel' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/jobs/cron/${id}/cancel`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/' . $id . '/cancel'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Cancelling does not stop a job ALREADY RUNNING; it changes the queue row's status. Api::Automation()->CancelAutomationJob(['queue' => 'cron', 'id' => $id]); ``` #### Clearing a Stuck Lock post/api/v1/admin/automation/jobs/{queue}/{id}/reclaim `Automation/ReclaimAutomationJob` admin risks a double run Clears the lock a crashed worker left on a job. Body — ——No body is needed. The queue and the id in the path name the job; send an empty body. Response fields data — 3 reclaimedboolWhether the call ran. queuestringThe job's queue. idintThe job id. Errors 3 invalid_queue422The queue key is not recognised. invalid_id422The job id is not valid. 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/automation/jobs/cron/9001/reclaim' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/jobs/cron/${id}/reclaim`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/' . $id . '/reclaim'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // If the worker IS STILL ALIVE the job runs twice; check the longest wait first. $d = Api::Automation()->GetAutomationDashboard()['data']; if ($d['status']['status'] === 'dead') Api::Automation()->ReclaimAutomationJob(['queue' => 'cron', 'id' => $id]); ``` #### Deleting a Job delete/api/v1/admin/automation/jobs/{queue}/{id} `Automation/DeleteAutomationJob` admin Removes a job row from the queue. Response fields data — 3 deletedboolWhether the delete ran. queuestringThe job's queue. idintThe id of the job removed. Errors 3 invalid_queue422The queue key is not recognised. invalid_id422The job id is not valid. 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/automation/jobs/cron/9001' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/jobs/cron/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/' . $id); 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 // Cancel rather than delete: the record stays and the job is never picked up again. Api::Automation()->CancelAutomationJob(['queue' => 'cron', 'id' => $id]); ``` #### Deleting Many Jobs post/api/v1/admin/automation/jobs/{queue}/bulk-delete `Automation/BulkDeleteAutomationJobs` admin Removes the job ids you give in one call. Body 1 idsint[]reqThe job ids to remove. Zero and below are dropped, and repeats are folded. Response fields data — 3 deletedboolWhether the call ran. queuestringThe queue the jobs were in. countintHow many were removed. Errors 3 invalid_queue422The queue key is not recognised. ids_required422No job id was given. 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/automation/jobs/cron/bulk-delete' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[9001,9002,9003]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/jobs/cron/bulk-delete', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [9001, 9002, 9003] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/bulk-delete'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [9001, 9002, 9003]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The count can come back BELOW what you sent: ids from another queue are not removed. $n = Api::Automation()->BulkDeleteAutomationJobs([ 'queue' => 'cron', 'ids' => $ids, ])['data']['count']; ``` #### Retrying Everything That Failed post/api/v1/admin/automation/jobs/{queue}/retry-all-failed `Automation/RetryAllFailedAutomationJobs` admin the whole queue Puts every failed job in a queue back among the waiting. Body — ——No body is needed. The queue comes from the path and the set cannot be narrowed: every failed job in it is taken. Send an empty body. Response fields data — 2 retriedboolWhether the call ran. queuestringThe queue worked on. How many jobs it touched is not returned. Errors 2 invalid_queue422The queue key is not recognised. 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/automation/jobs/cron/retry-all-failed' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/jobs/cron/retry-all-failed', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/retry-all-failed'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Hundreds that failed FOR ONE REASON all run again at once and fail again. // Read the reason from one job's detail first, then make this call. Api::Automation()->RetryAllFailedAutomationJobs(['queue' => 'cron']); ``` #### Cancelling Everything Waiting post/api/v1/admin/automation/jobs/{queue}/cancel-all-pending `Automation/CancelAllPendingAutomationJobs` admin the whole queue Cancels every job waiting in a queue. Body — ——No body is needed. The queue comes from the path and the set cannot be narrowed: every pending job in it is cancelled. Send an empty body. Response fields data — 2 cancelledboolWhether the call ran. queuestringThe queue worked on. How many were cancelled is not returned. Errors 2 invalid_queue422The queue key is not recognised. 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/automation/jobs/cron/cancel-all-pending' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/jobs/cron/cancel-all-pending', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/jobs/cron/cancel-all-pending'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The waiting jobs are INVOICES, NOTICES and provider calls; they all drop at once. Api::Automation()->CancelAllPendingAutomationJobs(['queue' => 'cron']); ``` ### Pitfalls > **The queue-wide endpoints cannot be undone** > > Retrying everything failed and cancelling everything waiting move **hundreds of jobs in one call**, and neither says which. Cancelling the waiting drops invoices not yet issued, notices not yet sent and provider calls not yet made, all at once. Look at the feed first to see what is there. > **Clearing a lock can run a job twice** > > The reclaim endpoint **assumes the worker died**. If it is still alive the job gets picked up a second time and the same invoice can be issued twice. Read the worker health on the dashboard before using it; on a live worker a job that looks stuck is usually only slow. > **Cancelling does not stop a running job** > > The cancel endpoint **changes the queue row's status** and does not kill a running process. Cancelling a job in flight does not cut it short; when it ends it writes its own result. When something truly has to stop, switching the cron off is the only way. > **A retry clears the attempt counter** > > Retrying a job **clears its attempt counter**. A job that failed three times looks as though it is running for the first time, and failing again starts the count at one. A check that sifts troubled jobs by their attempt count will miss the difference. > **A bulk delete is bound to its queue** > > A bulk delete looks **only in the queue named in the path**, and ids from another are passed over quietly. A count below what you sent is no error; those ids live elsewhere. When removing a mixed list, group the ids by queue and make separate calls. ### Related Articles - [Scheduled Tasks](https://dev.wisecp.com/en/scheduled-tasks) - [Cron Settings](https://dev.wisecp.com/en/cron-settings) - [Queue Maintenance](https://dev.wisecp.com/en/queue-maintenance) ## Scheduled Tasks https://dev.wisecp.com/en/scheduled-tasks The four endpoints that list scheduled tasks, switch them and run them by hand. ### Overview A scheduled task is the **definition** of work an installation does by itself: issuing invoices, suspending services, sending notices. These four endpoints list the tasks, show their jobs, switch them on and off and run them by hand. A task and a job are **not the same**. A task is a rule, and every run of it leaves one or more jobs in the queue. The counts in the task list describe that rule's jobs. Some tasks run in **two stages**: one that works out what to do, and one that carries out what was found. The counts and the filters respect that split. ### Reference #### Listing the Tasks get/api/v1/admin/automation/tasks `Automation/GetAutomationTasks` admin Returns every scheduled task on the installation with its state. Response fields data[] — 14 taskstringThe task key. The other endpoints take this value. display_namestringThe name it shows under. descriptionstringWhat the task does. frequencystringHow often it runs: `minute`, `hour`, `day`, `month`, `unknown`. disabledboolWhether the task was switched off. registeredboolWhether a handler exists to carry the task out. When false the task sits in the list unable to run. last_run_atstring | nullWhen it last ran. next_run_atstring | nullWhen it next runs. pendingintHow many jobs wait. processingintHow many are running. today_completedintHow many completed today. On a two-stage task only the execute stage counts. today_failedintHow many failed. Counted across the period the records are kept. last_errorstring | nullThe last error message. last_error_atstring | nullWhen that error happened. 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/automation/tasks' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/tasks', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const broken = data.filter((t) => ! t.registered); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // SWITCHED OFF and NO HANDLER are different things: the second points at a gap in setup. $tasks = Api::Automation()->GetAutomationTasks()['data']; $broken = array_filter($tasks, fn ($t) => ! $t['registered']); ``` #### Listing a Task's Jobs get/api/v1/admin/automation/tasks/{task}/jobs `Automation/GetAutomationTaskJobs` admin cursor paging Returns the queue jobs belonging to one task. Query 5 status_filterstringFilters by status. stage_filterstringFilters by stage. On a two-stage task the discovery and the execution are separate. qstringSearches the rows freely. after_idintFetches rows after this id. limitintHow many rows to return. Clamped between one and a hundred. Response fields data — 3 rowsarrayThe job rows. has_moreboolWhether more rows remain. next_afterintThe id to hand to the next page. Errors 2 task_required422No task key was given. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/automation/tasks/invoice-create/jobs?limit=50' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL(`https://panel.example.com/api/v1/admin/automation/tasks/${task}/jobs`); url.searchParams.set('limit', '50'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); // Sonraki sayfa: url.searchParams.set('after_id', data.next_after) ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/tasks/' . $task . '/jobs?' . http_build_query(['limit' => 50])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint walks FORWARD while the general feed walks back. Do not mix the two. $page = Api::Automation()->GetAutomationTaskJobs(['task' => $task], ['limit' => 50])['data']; $next = Api::Automation()->GetAutomationTaskJobs(['task' => $task], [ 'after_id' => $page['next_after'], ]); ``` #### Switching a Task On and Off put/api/v1/admin/automation/tasks/{task}/status `Automation/UpdateAutomationTaskStatus` admin Decides whether a task runs at all. Body 1 enabledintreqSwitches the task on or off. Response fields data — 2 taskstringThe task key. enabledboolHow the task now stands. Errors 4 enabled_required422Neither on nor off was given. task_required422No task key was given. not_found404No such task. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/automation/tasks/invoice-create/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":0}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/tasks/${task}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: 0 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/tasks/' . $task . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => 0]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Switching off does not clear the QUEUE: what waits is picked up once it comes back on. Api::Automation()->UpdateAutomationTaskStatus(['task' => $task, 'enabled' => 0]); ``` #### Running a Task Now post/api/v1/admin/automation/tasks/{task}/run `Automation/RunAutomationTask` admin it gets queued Queues a task without waiting for its time. Body — ——No body is needed, send an empty one. The task key in the path says which one to run. Response fields data — 3 taskstringThe task key. dispatchedboolWhether it went into the queue. It does not mean the task finished. job_idintThe id of the job opened. Errors 4 task_required422No task key was given. no_handler422The task has no handler. blocked_by_gate422A hook refused the run. 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/automation/tasks/invoice-create/run' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/automation/tasks/${task}/run`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); console.log(data.job_id); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/tasks/' . $task . '/run'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The call QUEUES the task rather than running it; follow the result by the job id. $jobId = Api::Automation()->RunAutomationTask(['task' => $task])['data']['job_id']; $job = Api::Automation()->GetAutomationJob(['queue' => 'cron', 'id' => $jobId])['data']; ``` ### Pitfalls > **Switched off and missing a handler are different** > > A task can be **switched off**, or it can have **no handler at all**. The second is not a choice but a gap in the setup: the task sits in the list, never runs, and the run-now call errors. Read the two fields apart when writing monitoring. > **Running now does not run now** > > The run endpoint **puts the task in the queue**, and the scheduled worker does the work. By the time the response arrives the job may not have started, so follow the job id that comes back to see the outcome. With a dead worker the job is never picked up and waits quietly. > **The two listings walk opposite ways** > > The task-jobs endpoint pages **forward**: it fetches what comes after the id you give. The general job feed goes **backward**. Code using both in one loop and mixing up the cursor fields either never advances or returns the same page. > **Switching off does not empty the queue** > > Switching a task off stops it **leaving new jobs** and does not clear what already waits. Switch it back on and those older jobs get picked up too, running at a moment you did not expect. For a task going off for a long while, consider cancelling what waits as well. > **Today's counts look at different windows** > > In the task list the completed count covers **today** while the failed count covers the **whole period** the records are kept. Putting the two side by side to work out a success rate gives a wrong answer. For counts over one window, count the task's jobs yourself with the status filter. ### Related Articles - [Automation Jobs](https://dev.wisecp.com/en/automation-jobs) - [Cron Settings](https://dev.wisecp.com/en/cron-settings) - [Adding a Scheduled Job](https://dev.wisecp.com/en/adding-a-scheduled-task) ## Cron Settings https://dev.wisecp.com/en/cron-settings The four endpoints for the automation's configuration, its main switch and its cron secret. ### Overview These four endpoints configure the **automation itself**: how long records are kept, which hours heavy work runs in, whether the automation is on at all, and the secret guarding the cron address. The main switch is an **emergency stop**. With it off no task runs: no invoice is issued, no service renewed, no notice sent. Who switched it off and when stays recorded in the settings. The secret is the cron address's **password** and cannot be read; the settings say only whether one is set. Renewing hands it back once and makes the old address invalid. ### Reference #### Reading the Settings get/api/v1/admin/automation/settings `Automation/GetAutomationSettings` admin Returns the automation's configuration and the state of its main switch. Response fields data — 8 cron_enabledboolWhether the automation's main switch is on. disabled_atstring | nullWhen it was switched off. disabled_bystring | nullWho switched it off. time_windowobjectThe window heavy work runs in: its start and its end. retentionobjectHow long records are kept: separate day counts for completed, cancelled and failed jobs. secret_setboolWhether the cron secret is set. The secret itself never comes back. restoreobjectThe restore suspicion: when it arose and how many hours of silence it rests on. tasksobjectThe task definitions as they stand. 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/automation/settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The secret NEVER comes back: only a mark saying whether one is set. $s = Api::Automation()->GetAutomationSettings()['data']; $ready = $s['cron_enabled'] && $s['secret_set']; ``` #### Writing the Settings put/api/v1/admin/automation/settings `Automation/UpdateAutomationSettings` admin Writes how long records are kept and the window work runs in. Body 4 completed_daysintHow many days completed jobs are kept. One day at the least. cancelled_daysintHow many days cancelled jobs are kept. failed_daysintHow many days failed jobs are kept. time_windowobjectThe window heavy work runs in: a start and an end time. It has to span at least an hour past the start of the day. Response fields data — 8 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 3 invalid_window422The window is too short. config_write_failed422The settings file could not be written. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/automation/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"completed_days":30,"failed_days":30,"time_window":{"start":"00:00","end":"06:00"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ completed_days: 30, cancelled_days: 7, failed_days: 30, time_window: { start: '00:00', end: '06:00' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'completed_days' => 30, 'failed_days' => 30, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // SHORTENING the retention does not clear the past at once; the clean-up is its own call. Api::Automation()->UpdateAutomationSettings(['completed_days' => 7]); ``` #### Stopping the Automation put/api/v1/admin/automation/cron-enabled `Automation/ToggleAutomationCron` admin stops everything Switches the automation's main switch on or off. Body 1 enabledintreqSwitches the automation on, or stops it outright. Response fields data — 2 cron_enabledboolHow the switch now stands. changedboolWhether anything moved. False means it already stood that way. Errors 3 enabled_required422Neither on nor off was given. blocked_by_gate422A hook refused the switch-off. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/automation/cron-enabled' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":0}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/cron-enabled', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: 0 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/cron-enabled'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => 0]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Switching off stops INVOICING, RENEWALS and notices alike; keep it for maintenance. Api::Automation()->ToggleAutomationCron(['enabled' => 0]); // ... maintenance ... Api::Automation()->ToggleAutomationCron(['enabled' => 1]); ``` #### Renewing the Secret post/api/v1/admin/automation/cron-secret/regenerate `Automation/RegenerateAutomationCronSecret` admin breaks the old address Builds a new secret for the cron address and returns it. Body — ——No body is needed; send an empty one. This endpoint takes no parameters, so the new secret cannot be chosen. Response fields data — 1 secretstringThe new secret. It cannot be read again, so write it into the scheduled call on the server at once. Errors 2 config_write_failed422The settings file could not be written. 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/automation/cron-secret/regenerate' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/cron-secret/regenerate', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); // data.secret yalnizca burada gorunur ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/cron-secret/regenerate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Renewing makes the OLD cron address invalid: nothing runs until the scheduled call on // the server is updated. $secret = Api::Automation()->RegenerateAutomationCronSecret()['data']['secret']; ``` ### Pitfalls > **The main switch stops everything** > > Switching the automation off stops not one task but **all of them**: no invoice, no renewal, no suspension, no notice. While it stays off the jobs pile up in the queue, and they all begin running at once when it comes back. When switching off for maintenance, keep the window short. > **The new secret shows once** > > The renew call returns the secret **in that response alone**, and the settings endpoint never gives it again. Miss it and you cannot update the scheduled call on the server, leaving another renewal as the only way out. A client that logs its responses logs the secret too. > **Renewing breaks the old address** > > The secret is part of the cron address. The moment it changes, the scheduled call on the server **keeps using the old one** and gets refused, so the automation stops until the address is updated. The outage is quiet: nothing shows as an error, the jobs merely begin piling up. > **The window holds heavy work back** > > The window keeps heavy tasks to **certain hours alone**. A narrow one can leave the night's work unfinished by morning and the queue a little longer each day. When narrowing it, watch the longest wait on the dashboard; a growing figure says the window is too small. > **Shortening the retention does not clear the past** > > Lowering the retention days changes only **what the next clean-up measures by**; the older rows stay until it runs. To free space, run the clean-up rather than changing the setting and waiting, and look at the preview first to see what would go. ### Related Articles - [Queue Maintenance](https://dev.wisecp.com/en/queue-maintenance) - [Automation Jobs](https://dev.wisecp.com/en/automation-jobs) - [Scheduled Tasks](https://dev.wisecp.com/en/scheduled-tasks) ## Queue Maintenance https://dev.wisecp.com/en/queue-maintenance The five endpoints that clear the queues, measure them and handle the restore warning. ### Overview Queues grow over time. These five endpoints deal with **maintenance**: clearing old jobs, seeing how much room the tables take, and handling the warning that appears when the database is restored. The clean-up comes in two steps. The preview **counts and removes nothing**, while the run takes away for good the jobs that outlived the retention. The measure is the retention days in the automation settings. The restore warning arises when the automation notices it has been **silent for a long while**. That usually means the database came back from an older backup, and the queue may hold work that will run again or that is long past. ### Reference #### Previewing the Clean-Up get/api/v1/admin/automation/cleanup/preview `Automation/PreviewAutomationCleanup` admin it removes nothing Shows how many jobs the retention rules would remove. Response fields data — 4 completedintHow many completed jobs would go. cancelledintHow many cancelled jobs would go. failedintHow many failed jobs would go. retentionobjectThe retention days the counts rest on. 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/automation/cleanup/preview' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/cleanup/preview', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const willGo = data.completed + data.cancelled + data.failed; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/cleanup/preview'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint COUNTS rather than removes; always look here before a clean-up. $plan = Api::Automation()->PreviewAutomationCleanup()['data']; if ($plan['completed'] + $plan['cancelled'] + $plan['failed'] > 0) Api::Automation()->RunAutomationCleanup(); ``` #### Running the Clean-Up post/api/v1/admin/automation/cleanup/run `Automation/RunAutomationCleanup` admin a permanent delete Removes for good the jobs that outlived the retention. Body — ——No body is needed, send an empty one. The retention comes from the automation settings, not from the call, so there is no field to widen or narrow the sweep with. Response fields data — 2 deletedintHow many jobs were removed. breakdownobjectThe breakdown by status: completed, cancelled and failed. Errors 2 blocked_by_gate422A hook refused the clean-up. 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/automation/cleanup/run' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/cleanup/run', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); console.log(data.deleted, data.breakdown); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/cleanup/run'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // What goes never returns: the error messages on failed jobs go along with them. $r = Api::Automation()->RunAutomationCleanup()['data']; ``` #### Reading the Table Sizes get/api/v1/admin/automation/table-stats `Automation/GetAutomationTableStats` admin Returns the row count and the average data size of the three queue tables. Response fields data — 3 cronjob_queueobjectThe scheduled-task queue: its row count and average data size. module_queueobjectThe module queue: its row count and average data size. notification_queueobjectThe notice queue: its row count and average data size. 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/automation/table-stats' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/table-stats', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/table-stats'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Growth is the row count TIMES the average size rather than the count; read both. $t = Api::Automation()->GetAutomationTableStats()['data']; $approx = $t['cronjob_queue']['rows'] * $t['cronjob_queue']['avg_payload']; ``` #### Dismissing the Restore Warning post/api/v1/admin/automation/restore/dismiss `Automation/DismissAutomationRestore` admin Takes away the restore-suspicion warning. Body — ——No body is needed, send an empty one. The endpoint takes nothing: it clears the suspicion marks kept in the settings. Response fields data — 1 dismissedboolWhether the warning was taken away. Errors 2 config_write_failed422The settings file could not be written. 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/automation/restore/dismiss' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/restore/dismiss', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/restore/dismiss'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Dismissing does not remove the REASON; find out where the silence came from first. $s = Api::Automation()->GetAutomationSettings()['data']; if ($s['restore']['suspected_at'] !== null) Api::Automation()->DismissAutomationRestore(); ``` #### Starting a Restore Reconciliation post/api/v1/admin/automation/restore/trigger `Automation/TriggerAutomationRestore` admin the panel asks for a password Marks the queue for reconciliation after a restore. Body — ——No body is needed, send an empty one. The endpoint takes nothing: the suspicion time it writes is the moment of the call. Response fields data — 1 triggeredboolWhether the mark was set. Errors 2 config_write_failed422The settings file could not be written. 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/automation/restore/trigger' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/automation/restore/trigger', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/automation/restore/trigger'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // In the panel this asks for the ADMIN PASSWORD; over the API the key's scope is the only gate. Api::Automation()->TriggerAutomationRestore(); ``` ### Pitfalls > **Count first, remove after** > > The clean-up run **cannot be undone** and does not say in advance how many records will go. The preview exists for exactly that: it counts by the same measure and touches nothing. After changing the retention days, running the clean-up without looking can take far more than you expected. > **A removed job takes its error with it** > > The clean-up removes failed jobs too, and their **error messages go with them**. Running it while looking into a problem can take away the very evidence you need. Keeping the retention on failed jobs longer than on completed ones is worth doing for that reason. > **The row count alone does not tell the size** > > The table figures give both the row count and the **average data size**. A queue with few rows carrying large data can take more room than a busy one carrying little. When watching growth, multiply the two; counting rows alone misleads. > **Dismissing the warning leaves the cause** > > Dismissing the restore warning only **clears the mark** and changes nothing about why the queue fell silent. When the warning really follows a restore, work long past sits in the queue and runs all at once when things resume. Look at the dates on the recent jobs before dismissing it. > **The panel's password gate is not here** > > Starting a restore reconciliation asks for the **administrator password** in the panel, while here the key's scope is the only gate. A key carrying that scope can do outright what the panel guards with a second check. Weigh that when handing keys out. ### Related Articles - [Cron Settings](https://dev.wisecp.com/en/cron-settings) - [Automation Jobs](https://dev.wisecp.com/en/automation-jobs) - [Scheduled Tasks](https://dev.wisecp.com/en/scheduled-tasks) # API / Admin API / Clients ## Client Addresses https://dev.wisecp.com/en/client-addresses The five endpoints that read, add, update and delete an address in a client's address book. ### Overview A client can hold several addresses, and an invoice is issued to one of them. The address book is separate from the client record. One address is the default. Making a new address the default clears the flag on the previous one; there is never more than one default. > **Notification preferences** > > A notification preference is stored as a bitmask: one number carrying several choices. ### Reference #### Listing Addresses get/api/v1/admin/clients/{id}/addresses `Clients/GetClientAddresses` admin Returns the client's whole address book. The default one is flagged with `is_default`. Response fields data[] — 17 idintAddress id. full_namestringFull name. labelstringAddress label, for example `Office`. typestring`individual` or `corporate`. emailstringEmail address. phonestringPhone; digits only are stored. identitystringIdentity or tax number. companyobject 3 fieldsTax details on a corporate address. namestringTrading name. tax_numberstringTax number. tax_officestringTax office. country_idintCountry id. Resolve with `reference/countries`. statestringState or region. citystringCity. addressstringStreet address. zipcodestringPostcode. At most 20 characters. is_defaultboolWhether this is the default address. email_notificationsintEmail notification preference (bitmask). sms_notificationsintSMS notification preference (bitmask). statusstring`active` or `passive`. Errors 2 not_found404No such client or address. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/addresses' \ -H "Authorization: Bearer $API_KEY" \ -H 'Accept: application/json' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/addresses', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/addresses'); 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::Clients()->GetClientAddresses(['id' => 42]); foreach ($response['data'] as $address) { if ($address['is_default']) { $invoiceAddress = $address; } } ``` #### Adding an Address post/api/v1/admin/clients/{id}/addresses `Clients/CreateClientAddress` admin Adds a record to the address book and returns it in the same schema as the list. Body 16 fields, 5 required full_namestringrequiredFull name. emailstringrequiredValidated for format. country_idintrequiredCountry id. List: `reference/countries`. statestringrequiredState or region. citystringrequiredCity. typestring`individual` or `corporate`. Defaults to `individual`. labelstringAddress label. phonestringPhone; digits only are stored. identitystringIdentity or tax number. companyobject 3 fieldsTax details on a corporate address. namestringTrading name. tax_numberstringTax number. tax_officestringTax office. addressstringStreet address. zipcodestringPostcode. At most 20 characters. is_defaultboolMakes this the default address. email_notificationsintEmail notification preference. Defaults to all on. sms_notificationsintSMS notification preference. Defaults to all on. overwrite_invoicesboolApplies this address to the client's open invoices as well. Response fields data dataobjectThe address created, returned with `201`. Same shape as the list schema. Errors 4 not_found404No such client or address. email_invalid422Email format is not valid. address_add_failed500The record could not be created. 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/clients/42/addresses' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"John Doe","email":"john@example.com","country_id":840,"state":"California","city":"San Francisco","address":"123 Market Street","zipcode":"94105","is_default":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/addresses', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({"full_name":"John Doe","email":"john@example.com","country_id":840,"state":"California","city":"San Francisco","address":"123 Market Street","zipcode":"94105","is_default":true}), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/addresses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'full_name' => 'John Doe', 'email' => 'john@example.com', 'country_id' => 840, 'state' => 'California', 'city' => 'San Francisco', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientAddress([ 'id' => 42, 'full_name' => 'John Doe', 'email' => 'john@example.com', 'country_id' => 840, 'state' => 'California', 'city' => 'San Francisco', 'is_default' => true, ]); $addressId = $response['data']['id'] ?? 0; ``` Response 201 422 ```json { "data": { "id": 13, "full_name": "John Doe", "type": "individual", "country_id": 840, "city": "San Francisco", "zipcode": "94105", "is_default": true, "status": "active" } } ``` ```json { "error": { "code": "email_invalid", "message": "A valid email is required." } } ``` #### Address Detail get/api/v1/admin/clients/{id}/addresses/{addr_id} `Clients/GetClientAddress` admin Returns one address. The schema is the same as in the list. Response fields data dataobjectThe address itself. Same shape as the list schema. Errors 2 not_found404No such client or address. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/addresses/13' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/addresses/13', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/addresses/13'); 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::Clients()->GetClientAddress(['id' => 42, 'addr_id' => 13]); ``` #### Updating an Address put/api/v1/admin/clients/{id}/addresses/{addr_id} `Clients/UpdateClientAddress` admin full replace Updates the address. The body takes **the same fields as create**, and the required ones stay required. Body the same 16 fields as create full_namestringrequiredFull name. emailstringrequiredValidated for format. country_idintrequiredCountry id. statestringrequiredState or region. citystringrequiredCity. overwrite_invoicesboolApplies the change to the client's open invoices as well. Response fields data dataobjectThe address after the update. Same shape as the list schema. Errors 7 not_found404No such client or address. full_name_required422`full_name` is required. email_invalid422A valid email is required. country_required422`country_id` is required. state_required422`state` is required. city_required422`city` is required. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/addresses/13' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"John Doe","email":"john@example.com","country_id":840,"state":"California","city":"San Francisco","address":"123 Market Street","zipcode":"94105","is_default":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/addresses/13', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({"full_name":"John Doe","email":"john@example.com","country_id":840,"state":"California","city":"San Francisco","address":"123 Market Street","zipcode":"94105","is_default":true}), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/addresses/13'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($address), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The required fields are sent again: this is a full update, not a partial one. $response = Api::Clients()->UpdateClientAddress([ 'id' => 42, 'addr_id' => 13, 'full_name' => 'John Doe', 'email' => 'john@example.com', 'country_id' => 840, 'state' => 'California', 'city' => 'San Francisco', ]); ``` #### Deleting an Address delete/api/v1/admin/clients/{id}/addresses/{addr_id} `Clients/DeleteClientAddress` admin Deletes the address and returns the id that was removed. Response fields data deletedboolWhether the delete succeeded. idintId of the deleted address. Errors 1 not_found404No such client or address. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/42/addresses/13' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/addresses/13', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/addresses/13'); 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::Clients()->DeleteClientAddress(['id' => 42, 'addr_id' => 13]); ``` ### Pitfalls > **There is only one default** > > Flagging an address with `is_default` clears the flag on the previous one. You do not have to update the old address; two defaults cannot exist. > **Open invoices do not change by themselves** > > Updating an address does not reach invoices that were already issued. Send `overwrite_invoices` if you want it to. > **The update is not partial** > > The endpoint is `PUT`. Leave a required field out and the request is refused; sending only the changed field is not enough. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) ## Client Notes https://dev.wisecp.com/en/client-internal-notes The four endpoints that read, add, update and delete the internal notes attached to a client. ### Overview A note is an **internal** line attached to the client record. The client never sees it on any screen; only operators working in the panel read it. A note can be pinned. Pinned notes sit at the top of the list and the rest run newest to oldest. ### Reference #### Listing Notes get/api/v1/admin/clients/{id}/notes `Clients/GetClientNotes` admin Returns the client's notes. The order is fixed: pinned first, then newest to oldest. Response fields data[] — 6 idstringNote id. A string, not a number. contentstringThe note text. pinnedboolWhether the note is pinned. added_byintId of the admin who added it. added_by_namestringDisplay name of whoever added it. created_atstringCreation time. Errors 2 not_found404No such client or note. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/notes' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notes', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notes'); 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::Clients()->GetClientNotes(['id' => 42]); foreach ($response['data'] as $note) { if ($note['pinned']) { $highlight[] = $note['content']; } } ``` #### Adding a Note post/api/v1/admin/clients/{id}/notes `Clients/CreateClientNote` admin Adds a note to the client. The owner of the calling key is recorded as the author. Body 2 contentstringrequiredThe note text. pinnedboolPins the note to the top of the list. Defaults to `false`. Response fields data dataobjectThe note created, returned with `201`. Same shape as the list schema. Errors 4 not_found404No such client. content_required422The content is empty. note_add_failed500The note could not be created. 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/clients/42/notes' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"content":"Clean payment history, priority client","pinned":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notes', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({"content":"Clean payment history, priority client","pinned":true}), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notes'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'content' => 'Clean payment history, priority client', 'pinned' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientNote([ 'id' => 42, 'content' => 'Clean payment history, priority client', 'pinned' => true, ]); ``` #### Updating a Note patch/api/v1/admin/clients/{id}/notes/{note_id} `Clients/UpdateClientNote` admin pinning lives here too Changes the note text or its pinned state. You have to send at least one field. Body at least one contentstringThe new text. If sent, it cannot be empty. pinnedboolPins or unpins the note. Response fields data dataobjectThe note after the update. Same shape as the list schema. Errors 4 not_found404No such client or note. content_required422The content was sent empty. note_update_failed500The note could not be updated. 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/clients/42/notes/a1b2c3' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"pinned":false}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notes/a1b2c3', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ pinned: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notes/a1b2c3'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['pinned' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Unpinning goes through this endpoint too; there is no separate action. $response = Api::Clients()->UpdateClientNote([ 'id' => 42, 'note_id' => 'a1b2c3', 'pinned' => false, ]); ``` #### Deleting a Note delete/api/v1/admin/clients/{id}/notes/{note_id} `Clients/DeleteClientNote` admin Deletes the note. Response fields data deletedboolWhether the delete succeeded. idstringId of the deleted note. Errors 3 not_found404No such client or note. note_delete_failed500The note could not be deleted. 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/clients/42/notes/a1b2c3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notes/a1b2c3', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notes/a1b2c3'); 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::Clients()->DeleteClientNote([ 'id' => 42, 'note_id' => 'a1b2c3', ]); ``` ### Pitfalls > **A note id is not a number** > > Unlike the other resources, `id` here is a string (`a1b2c3`). Code that casts it to a number will not find the record. > **There is no separate pin endpoint** > > In the panel pinning looks like its own action; in the API `pinned` is a field like any other and goes through the update endpoint. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) ## Client Credit https://dev.wisecp.com/en/client-credit The five endpoints that read a client's prepaid balance, adjust it and wire it to automatic payment. ### Overview Credit is the prepaid balance sitting on a client's account. These endpoints manage the **manual** adjustments to it; money taken by a payment provider does not come through here. Every record carries a direction: `up` raises the balance, `down` lowers it. The amount is always positive. ### Reference #### Listing Credit Records get/api/v1/admin/clients/{id}/credits `Clients/GetClientCredits` admin Returns the ledger of manual adjustments made to the client's balance. Response fields data[] — 7 idintId of the credit record. typestring`up` raises the balance, `down` lowers it. amountnumberAmount. Rounded to two decimals and always positive; the direction is carried by `type`. currency_idintCurrency id of the record. descriptionstringDescription. added_byintId of the admin who added it. created_atstringCreation time. Errors 2 not_found404No such client or credit record. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/credits' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/credits', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/credits'); 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::Clients()->GetClientCredits(['id' => 42]); $added = 0.0; foreach ($response['data'] as $entry) { if ($entry['type'] === 'up') { $added += (float) $entry['amount']; } } ``` #### Adding Credit post/api/v1/admin/clients/{id}/credits `Clients/CreateClientCredit` admin moves the balance Writes a manual adjustment to the balance and returns the balance after it. Body 3 typestringrequired`up` or `down`. amountnumberrequiredMust be greater than zero. Read in the client's balance currency. descriptionstringDescription. It shows in the ledger. Response fields data — 2 creditobjectThe record that was created, in the same schema as the list. new_balancenumberThe balance after the adjustment. Errors 4 not_found404No such client or credit record. type_invalid422`type` is neither `up` nor `down`. amount_invalid422The amount is zero or negative. 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/clients/42/credits' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"up","amount":50,"description":"Manual top-up"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/credits', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({"type":"up","amount":50,"description":"Manual top-up"}), }); const body = await res.json(); console.log(body.data.new_balance); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/credits'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'up', 'amount' => 50, 'description' => 'Manual top-up', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientCredit([ 'id' => 42, 'type' => 'up', 'amount' => 50, 'description' => 'Manual top-up', ]); $balance = $response['data']['new_balance'] ?? null; ``` Response 201 422 ```json { "data": { "credit": { "id": 10, "type": "up", "amount": 50.00, "currency_id": 1, "description": "Manual top-up", "added_by": 1, "created_at": "2026-06-21 12:00:00" }, "new_balance": 150.00 } } ``` ```json { "error": { "code": "amount_invalid", "message": "Amount must be greater than zero." } } ``` #### Updating a Credit Record patch/api/v1/admin/clients/{id}/credits/{log_id} `Clients/UpdateClientCredit` admin Corrects an existing record. If the amount or the direction changes, the balance is recalculated. Body at least one typestring`up` or `down`. amountnumberAn amount greater than zero. descriptionstringDescription. Response fields data — 2 creditobjectThe updated record, in the same schema as the list. new_balancenumberThe recalculated balance. Errors 4 not_found404No such client or credit record. type_invalid422`type` is neither `up` nor `down`. amount_invalid422The amount is zero or negative. 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/clients/42/credits/10' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"amount":75}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/credits/10', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 75 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/credits/10'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['amount' => 75]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateClientCredit([ 'id' => 42, 'log_id' => 10, 'amount' => 75, ]); ``` #### Deleting a Credit Record delete/api/v1/admin/clients/{id}/credits/{log_id} `Clients/DeleteClientCredit` admin reverses the balance Deletes the record and reverses its effect on the balance. Response fields data — 3 deletedboolWhether the delete succeeded. idintThe deleted credit record's ID. new_balancenumberThe balance after the reversal. Errors 2 not_found404No such client or credit record. 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/clients/42/credits/10' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/credits/10', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/credits/10'); 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::Clients()->DeleteClientCredit([ 'id' => 42, 'log_id' => 10, ]); ``` #### Paying Automatically from Credit put/api/v1/admin/clients/{id}/credit-autopay `Clients/SetClientCreditAutopay` admin Sets whether invoices are paid from the balance without asking. Body 1 enabledboolrequiredWhen on, a due invoice is taken from the balance. Response fields data enabledboolThe auto-pay state after the call. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/credit-autopay' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/credit-autopay', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/credit-autopay'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SetClientCreditAutopay([ 'id' => 42, 'enabled' => true, ]); ``` ### Pitfalls > **Deleting reverses the balance** > > Deleting a record does not only remove the line; the amount comes back out of the balance. Closing a wrong adjustment with a second record in the opposite direction leaves a clearer trail. > **The amount is read in the client's currency** > > The number you send is in the client's balance currency; the request carries no currency. An integration working across currencies has to convert on its own side. > **Autopay does not reach the past** > > Turning it on does not pay the invoices already waiting; it applies to the ones that fall due afterwards. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) ## Blacklist and Duplicates https://dev.wisecp.com/en/blacklist-and-duplicates The four endpoints that blacklist a client, take them off the list, and find accounts that might be the same person. ### Overview These endpoints do two jobs together: blacklisting a client, and finding other accounts that **might be the same person**. They belong to one decision — someone who gets blacklisted usually comes back with a second account. Blacklisting is more than a flag: you choose which restrictions come with it. Switch none on and the client is only marked. ### Reference #### Blacklist Status get/api/v1/admin/clients/{id}/blacklist `Clients/GetClientBlacklist` admin Returns whether the client is blacklisted, why, and which restrictions are on. Response fields data — 6 blacklistedboolWhether the client is blacklisted. reasonstringThe reason. Values: `payment_fraud` · `chargeback` · `abuse` · `spam` · `tos_violation` · `false_info` · `other` notesstringA free-text note. restrictionsobject 4 fieldsThe restrictions that are on. block_new_ordersboolBlocks new orders. block_renewalsboolBlocks renewals. block_ticketsboolBlocks opening tickets. suspend_servicesboolSuspends the services. blacklisted_bystringName of the admin who blacklisted. blacklisted_atstringWhen the client was blacklisted. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/blacklist' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/blacklist', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/blacklist'); 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::Clients()->GetClientBlacklist(['id' => 42]); if ($response['data']['blacklisted'] ?? false) { $reason = $response['data']['reason']; } ``` #### Blacklisting a Client post/api/v1/admin/clients/{id}/blacklist `Clients/CreateClientBlacklist` admin can suspend services Blacklists the client and applies the restrictions you choose. Body 3 reasonstringrequiredThe reason. Values: `payment_fraud` · `chargeback` · `abuse` · `spam` · `tos_violation` · `false_info` · `other` notesstringA free-text note explaining the decision. restrictionsobject 4 fieldsWhich restrictions to switch on. Send none and none are applied; the client is only flagged. block_new_ordersboolBlocks new orders. block_renewalsboolBlocks renewals. block_ticketsboolBlocks opening tickets. suspend_servicesboolSuspends the services. Response fields data dataobjectThe blacklist record, returned with `201`. Same shape as the status endpoint above. Errors 4 not_found404No such client. reason_invalid422The reason is not one of the allowed values. blocked_by_gate422A hook on `gate:user.blacklist_add` vetoed the operation. 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/clients/42/blacklist' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"reason":"chargeback","notes":"Two chargebacks","restrictions":{"block_new_orders":true,"suspend_services":true}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/blacklist', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({"reason":"chargeback","notes":"Two chargebacks","restrictions":{"block_new_orders":true,"suspend_services":true}}), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/blacklist'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'reason' => 'chargeback', 'notes' => 'Two chargebacks', 'restrictions' => [ 'block_new_orders' => true, 'suspend_services' => true, ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientBlacklist([ 'id' => 42, 'reason' => 'chargeback', 'notes' => 'Two chargebacks', 'restrictions' => [ 'block_new_orders' => true, 'suspend_services' => true, ], ]); ``` #### Removing from the Blacklist delete/api/v1/admin/clients/{id}/blacklist `Clients/DeleteClientBlacklist` admin Removes the blacklist record. Bringing suspended services back is a separate choice. Body 1 reactivate_servicesboolReactivates the services that this blacklisting suspended. Leave it out and they stay suspended. Response fields data removedboolWhether the removal succeeded. Errors 3 not_found404No such client. not_blacklisted422The client is not on the blacklist. 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/clients/42/blacklist' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"reactivate_services":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/blacklist', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ reactivate_services: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/blacklist'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['reactivate_services' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->DeleteClientBlacklist([ 'id' => 42, 'reactivate_services' => true, ]); ``` #### Duplicate Account Scan get/api/v1/admin/clients/{id}/duplicates `Clients/GetClientDuplicates` admin IP · name · company Returns other accounts that share the client's IP, name or company name. Response fields data — 2 currentobjectA summary of the client under review: `id`, `full_name`, `company_name`, `ip`, `created_at`. matchesobject[] 7 fieldsThe matching accounts. idintId of the matching client. full_namestringFull name. company_namestringCompany name. ipstringThe IP recorded at sign-up. created_atstring | nullSign-up date. days_apartintDays between the two sign-up dates. An absolute value. match_typesstring[]What matched: `ip`, `name`, `company`. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/duplicates' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/duplicates', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); const sameIp = body.data.matches.filter((m) => m.match_types.includes('ip')); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/duplicates'); 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::Clients()->GetClientDuplicates(['id' => 42]); foreach ($response['data']['matches'] as $match) { // Same IP a few days apart is a stronger signal than a name match. if (in_array('ip', $match['match_types'], true) && $match['days_apart'] < 7) { $suspects[] = $match['id']; } } ``` Response 200 ```json { "data": { "current": { "id": 42, "full_name": "John Doe", "company_name": "", "ip": "203.0.113.10", "created_at": "2026-01-01 10:00:00" }, "matches": [ { "id": 57, "full_name": "J. Doe", "company_name": "", "ip": "203.0.113.10", "created_at": "2026-01-03 09:20:00", "days_apart": 2, "match_types": ["ip", "name"] } ] } } ``` ### Pitfalls > **Removing does not bring services back** > > Removing the blacklist record does not reactivate suspended services on its own. Send `reactivate_services` if you want them back; otherwise the client is off the list while the services stay suspended. > **A match is not a verdict** > > The same IP can be a home or an office, and the same name can be a coincidence. Read `match_types` together with `days_apart`: two accounts opened from one IP days apart is a far stronger signal than two that only share a name. > **The scan reads the sign-up IP** > > The address compared is the IP from **sign-up**, not the last sign-in. On a long-lived account that address can be years old. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) ## Client Security https://dev.wisecp.com/en/client-security-endpoints The five endpoints that manage the password, verification, two-step sign-in and the security rules set on a client. ### Overview These five endpoints carry the security decisions an **operator** applies to a client account: setting the password, marking a field verified, turning on two-step verification, tightening sign-in and payment rules, and limiting support access. None of them ask the client anything. They all apply directly, which is why a key carrying these endpoints is worth keeping narrow. ### Reference #### Changing the Password put/api/v1/admin/clients/{id}/password `Clients/SetClientPassword` admin no old password asked Sets the client's password directly. The current one is not asked for. Body 2 passwordstringrequiredThe new password. At least `options/password-length` characters; defaults to 6. password_confirmationstringIf sent, it must match `password` exactly. Leave it out and no check is made. Response fields data — 1 changedboolWhether the password was changed. Errors 5 not_found404No such client. password_required422The password is empty. password_too_short422The password is below the minimum length. The limit comes back in `error.details.min`. password_mismatch422The confirmation does not match the password. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/password' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"password":"Str0ngP@ssw0rd","password_confirmation":"Str0ngP@ssw0rd"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/password', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password: 'Str0ngP@ssw0rd', password_confirmation: 'Str0ngP@ssw0rd', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/password'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'password' => $newPassword, 'password_confirmation' => $newPassword, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SetClientPassword([ 'id' => 42, 'password' => $newPassword, ]); ``` #### Verifying Email and Phone post/api/v1/admin/clients/{id}/verify `Clients/VerifyClient` admin Marks a field as verified. No code is sent to the client; the flag is set directly. Body 1 typestringrequired`email` or `phone`. Response fields data — 2 verifiedboolWhether the verification was applied. typestringThe field that was verified: `email` or `phone`. Errors 3 not_found404No such client. type_invalid422`type` is neither of the two values. 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/clients/42/verify' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"email"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/verify', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'email' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/verify'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['type' => 'email']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // You set the flag here after running your own verification flow. $response = Api::Clients()->VerifyClient([ 'id' => 42, 'type' => 'email', ]); ``` #### Two-Step Verification put/api/v1/admin/clients/{id}/2fa `Clients/SetClient2fa` admin Turns the client's two-step verification on or off. Body 2 enabledboolrequiredTurns it on or off. methodstringName of the Authentication module to use, for example `GoogleAuthenticator`. Required only when enabling; not needed to disable. Response fields data — 2 enabledboolThe two-step state as it now stands. methodstringThe module that was selected. Comes back only when enabling. Errors 3 not_found404No such client. method_required422`method` was missing on an enable request. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/2fa' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"method":"GoogleAuthenticator"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/2fa', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, method: 'GoogleAuthenticator' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/2fa'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'enabled' => true, 'method' => 'GoogleAuthenticator', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // You do not need to send a method when turning it off. $response = Api::Clients()->SetClient2fa([ 'id' => 42, 'enabled' => false, ]); ``` #### Security Settings patch/api/v1/admin/clients/{id}/security-settings `Clients/UpdateClientSecuritySettings` admin Changes the security rules that apply to this client. Only the fields you send change. Body 6 block_proxyboolBlocks sign-in from behind a proxy. allow_proxyboolExempts this client from the proxy check. require_birthdayboolMakes the date of birth required. require_adult_ageboolRequires an over-18 check. force_document_filtersint[]Ids of the document verification filters to enforce. List: `clients/document-filters`. blocked_gatewaysstring[]Keys of the payment methods closed to this client. Response fields data — 6 block_proxyboolWhether sign-in from behind a proxy is blocked. The whole state comes back, not only the part you sent. allow_proxyboolWhether the client is exempt from the proxy check. require_birthdayboolWhether the date of birth is required. require_adult_ageboolWhether the over-18 check is on. force_document_filtersint[]Ids of the enforced document verification filters. blocked_gatewaysstring[]Keys of the payment methods closed to this client. Errors 2 not_found404No such client. 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/clients/42/security-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"block_proxy":true,"blocked_gateways":["PayPal"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/security-settings', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ block_proxy: true, blocked_gateways: ['PayPal'] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/security-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'block_proxy' => true, 'blocked_gateways' => ['PayPal'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateClientSecuritySettings([ 'id' => 42, 'block_proxy' => true, ]); ``` #### Support Settings patch/api/v1/admin/clients/{id}/support-settings `Clients/UpdateClientSupportSettings` admin Restricts or closes the client's ability to open support tickets. Body 2 ticket_restrictedboolRestricts opening tickets. ticket_blockedboolBlocks opening tickets outright. Response fields data — 2 ticket_restrictedboolWhether opening tickets is restricted. The whole state comes back, not only the part you sent. ticket_blockedboolWhether opening tickets is blocked. Errors 2 not_found404No such client. 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/clients/42/support-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ticket_restricted":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/support-settings', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ticket_restricted: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/support-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ticket_restricted' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateClientSupportSettings([ 'id' => 42, 'ticket_restricted' => true, ]); ``` ### Pitfalls > **Changing the password asks for no old one** > > The endpoint is an operator tool: it writes a new password without knowing the current one. The flow where a client changes their own password is a different one and lives in the Client API. > **Verifying sends no code** > > `verify` only sets the flag; no email or SMS reaches the client. Call it **after** your own verification flow has run, not instead of it. > **The two proxy fields are opposites** > > `block_proxy` blocks and `allow_proxy` exempts. Sending both as on leaves a contradictory state; send only the one you mean. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) ## Signing In as a Client https://dev.wisecp.com/en/client-sign-in Hand a signed-in session to a client from your own system, without ever holding their password. ### Overview Two endpoints cover the moment a client moves from your system into theirs. - **POST /clients/validate**: Checks whether an email and password belong to a live client account. Your front end collects the credentials; this tells you which client is behind them. - **POST /clients/sso**: Issues a one-time sign-in ticket and the URL that spends it. Send the client there and they arrive already signed in. Use the second one when you already know who the visitor is: your own portal, a control panel, a desk tool. Use the first only when you are the one asking for the password. ### Prerequisites - **Scopes**: `Clients/ValidateClient` and `Clients/CreateClientSsoToken`. Grant only the one you use. - **Account state**: The client must be an active member account. Inactive or blocked accounts are refused when the ticket is issued. - **HTTPS**: The ticket travels in a URL. Serve the redirect over HTTPS so it is not readable in transit. ### Structure The ticket is a random secret, not a package of data. Everything the sign-in needs — the expiry and the landing page — is stored on the account, so nothing the caller receives can be edited into a different destination or a longer window. - **Auth::createSsoToken()**: Issues the ticket, stores it encrypted on the account and returns the URL. - **Auth::verifySsoToken()**: Resolves the ticket and consumes it before any session exists. - **Auth::ssoLogin()**: Runs the normal sign-in gate, then opens the session. ### Step by Step #### Sending a Client In 1. Your system decides which client this visitor is. Call `POST /clients/sso` with that client's id. You get back a `token`, a `url` and an `expires_at`. 2. Redirect the visitor to `url` straight away. Do not show it as a link for later — the ticket is valid for 60 seconds. 3. The visitor lands signed in, on their dashboard or on the page you named in `destination`. The ticket is spent; the same URL will not work twice. #### Choosing Where They Land 1. Leave `destination` out and the client lands on their dashboard. 2. Send a route key with its parameters to land deeper: `"destination": "services", "destination_values": [128]`. 3. An absolute URL works too, as long as it belongs to this installation. Anything pointing elsewhere is dropped and the client lands on the dashboard instead. ### Reference #### Ticket Request - **client_id**: Required. The client to issue the ticket for. `user_id` is accepted as its former name. - **destination**: Optional. A route key or an absolute URL inside this installation. - **destination_values**: Optional. Route parameters, when `destination` is a route key. #### How the Ticket Behaves - **Single use**: Spent the moment the URL is opened. A replayed link lands on the sign-in form with an explanatory message. - **Sixty seconds**: Issue it and follow it in one motion. - **One per client**: A new ticket silently retires that client's previous one. - **Sign-in gate**: Account status, country blocking and module vetoes are checked when the ticket is spent, as for a password sign-in. Full field tables, error codes and response shapes are in the endpoint reference for `clients`. ### Example Issue a ticket and redirect cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/admin/clients/sso' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"client_id":64,"destination":"services","destination_values":[128]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/sso', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: 64, destination: 'services', destination_values: [128], }), }); const body = await res.json(); // Redirect from the server that holds the key, not from the visitor's browser: // the ticket is a credential and should not pass through untrusted code. res.ok && console.log(body.data.url); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['client_id' => 64]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); // Send the visitor there now; the ticket expires in 60 seconds. header('Location: ' . $body['data']['url']); exit; ``` ```php $response = Api::Clients()->CreateClientSsoToken([ 'client_id' => 64, 'destination' => 'services', 'destination_values' => [128], ]); // No key, no HTTP round trip — same resource, called in process. Utility::redirect($response['data']['url']); ``` ### Pitfalls > **A ticket is a credential.** Whoever opens the URL becomes that client. Do not log it, email it or put it in a page a third party can read. > **Do not mint tickets in advance.** They last 60 seconds and a client holds only one at a time, so issuing a batch leaves you with a single working ticket. > **Validate does not sign anyone in.** It answers whether a password matches. If you want a session, issue a ticket afterwards. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Client Security Endpoints](https://dev.wisecp.com/en/client-security-endpoints) ## Client Endpoints https://dev.wisecp.com/en/client-endpoints The seven endpoints of the client resource: read, create, update, delete, plus credential checks and sign-in. Each one lists the fields you send, the fields you get back and a working sample. ### Overview The client resource is the API side of the panel's **Clients** screen. It applies the same business rules. The password length and the email uniqueness you meet here are the ones the panel enforces, because the endpoint reuses the handler the panel calls. All seven endpoints belong to the `admin` audience and expect the key to carry the matching scope. Scope names are written on each endpoint's identity line below. > **The same endpoints are callable from inside WISECP** > > Writing a module or an addon? You do not have to go out over HTTP. `Api::Clients()->GetClients()` runs the same endpoint in process and returns **the same envelope**. The first tab of every sample shows that call. ### Reference #### Listing Clients get/api/v1/admin/clients `Clients/GetClients` admin model `users::list` Lists clients with filtering and pagination. Page size caps at 100; a larger value is quietly reduced. Query parameters 5 searchstringSearches name and email. statusstring`active` or `blocked`. Full list: `reference/statuses?entity=client`. group_idintClient group id. List: `clients/groups`. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — one client idintClient id. full_namestringFull name. company_namestringCompany name; empty for an individual. emailstringEmail address. phonestringPhone; digits only are stored. statusstring`active` or `blocked`. groupobject 2 fieldsThe group this client belongs to. idintGroup id. namestringDisplay name. languagestringLanguage code. country_codestringISO country code, for example `US`. currency_codestringCurrency code, for example `USD`. email_verifiedboolWhether the email is verified. phone_verifiedboolWhether the phone is verified. active_servicesintNumber of services in use. created_atstringCreation time. last_login_atstringLast sign-in time. Pagination meta metaobject 4 fieldsIdentical on every list endpoint. totalintTotal record count. pageintCurrent page. limitintPage size. next_pageintNext page, or `0`. 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/clients' \ -H "Authorization: Bearer $API_KEY" \ -H 'Accept: application/json' \ -d status=active \ -d limit=25 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients'); url.searchParams.set('status', 'active'); url.searchParams.set('limit', '25'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); for (const c of body.data) console.log(c.id, c.full_name); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients?' . http_build_query([ 'status' => 'active', 'limit' => 25, ]); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Accept: application/json', ], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); foreach ($body['data'] as $client) { echo $client['id'], ' ', $client['full_name'], PHP_EOL; } ``` ```php // From inside WISECP: no HTTP, same envelope. $response = Api::Clients()->GetClients([], [ 'status' => 'active', 'limit' => 25, ]); if (isset($response['error'])) { Logger::error($response['error']['message']); return; } foreach ($response['data'] as $client) { echo $client['id'], ' ', $client['full_name'], PHP_EOL; } ``` Response 200 403 ```json { "data": [ { "id": 42, "full_name": "John Doe", "company_name": "", "email": "john@example.com", "phone": "5550100", "status": "active", "group": { "id": 1, "name": "Standard" }, "email_verified": true, "phone_verified": false, "active_services": 3, "created_at": "2026-01-01 10:00:00", "last_login_at": "2026-06-20 09:00:00" } ], "meta": { "total": 128, "page": 1, "limit": 25, "next_page": 2 } } ``` ```json { "error": { "code": "insufficient_scope", "message": "API key lacks the required scope." } } ``` #### Client Detail get/api/v1/admin/clients/{id} `Clients/GetClient` admin model `users::get` Returns one client's full profile. Fields the list does not carry arrive here: first and last name separately, the balance, and the country and currency ids. Path parameter 1 idintrequiredClient id. Response fields 15 idintClient id. full_namestringFull name. namestringFirst name. The list carries only `full_name`. surnamestringLast name. statusstring`active`, `blocked` or `cancelled`. The list never returns `cancelled`. country_idintCountry id. Resolve with `reference/countries`. currency_idintCurrency id. group_idintClient group id. Where the list returns a `group` object, this returns the id only. balancefloatAccount balance. company_namestringCompany name. emailstringEmail address. phonestringPhone. languagestringLanguage code. created_atstringCreation time. last_login_atstringLast sign-in time. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42' \ -H "Authorization: Bearer $API_KEY" \ -H 'Accept: application/json' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42'); 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::Clients()->GetClient(['id' => 42]); if (isset($response['error'])) { // not_found or insufficient_scope return false; } $client = $response['data']; echo $client['name'], ' ', $client['surname'], PHP_EOL; ``` #### Creating a Client post/api/v1/admin/clients `Clients/CreateClient` admin same handler as the panel Creates a client and returns the new record in the detail schema. Business rules run in the panel's own handler, so a setting such as password length applies here too. Body 15 fields, 3 required full_namestringrequiredFull name. emailstringrequiredValidated for format and unique across every client in the installation. passwordstringrequiredAt least `options/password-length` characters; defaults to 6. typestring`individual` or `corporate`. Defaults to `individual`. phonestringPhone. Digits are kept, formatting is dropped. languagestringLanguage code. Defaults to `general/local`. group_idintClient group. List: `clients/groups`. country_codestringISO country code, for example `US`. Resolve with `reference/countries`. currency_codestringCurrency code, for example `USD`. companyobject 3 fieldsTax details for a corporate client. namestringTrading name. tax_numberstringTax number. tax_officestringTax office. identitystringIdentity or tax number. marketing_notificationsboolTurns marketing notifications on. verify_emailboolMarks the email as verified. verify_phoneboolMarks the phone as verified. send_welcome_emailboolSends the welcome email. Response fields data — 15 dataobjectThe client that was created, returned with `201`. Same shape as the detail endpoint. Errors 6 full_name_required422Full name was empty. email_invalid422Email format is not valid. email_exists422That email is already registered. password_required422Password was empty. create_failed500The record could not be created. 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/clients' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"John Doe","email":"john@example.com","password":"Str0ngP@ssw0rd"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ full_name: 'John Doe', email: 'john@example.com', password: 'Str0ngP@ssw0rd', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'full_name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'Str0ngP@ssw0rd', ]), ]); $created = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClient([ 'full_name' => 'John Doe', 'email' => 'john@example.com', 'password' => 'Str0ngP@ssw0rd', 'type' => 'individual', 'country_code' => 'US', 'currency_code' => 'USD', ]); if (isset($response['error'])) { // email_exists is the one you will meet most often. throw new Exception($response['error']['message']); } $clientId = $response['data']['id']; ``` Response 201 422 ```json { "data": { "id": 43, "full_name": "John Doe", "name": "John", "surname": "Doe", "email": "john@example.com", "status": "active", "country_id": 840, "currency_id": 2, "group_id": 0, "balance": 0.00 } } ``` ```json { "error": { "code": "email_exists", "message": "A client with this email already exists." } } ``` #### Updating a Client patch/api/v1/admin/clients/{id} `Clients/UpdateClient` admin partial update Changes only the fields you send and leaves the rest alone. Every body field is optional. Body 17 fields, all optional full_namestringFull name. If sent, it cannot be empty. emailstringValid and unique email. statusstring`active`, `blocked` or `cancelled`. phonestringMobile phone. Sending it empty clears it. landline_phonestringLandline. Sending it empty clears it. typestring`individual` or `corporate`. identitystringIdentity or tax number. birthdaystringDate of birth. An empty or invalid value clears the field. languagestringLanguage code. group_idintClient group. currency_codestringCurrency code, for example `USD`. companyobject 3 fieldsCorporate details. namestringTrading name. tax_numberstringTax number. tax_officestringTax office. billing flagsbool 5 fields`true` sets the flag, `false` clears it, leaving the field out changes nothing. tax_exemptionboolExempts the client from tax. never_suspendboolNever suspends the services. never_cancelboolNever cancels the services. separate_invoicesboolInvoices each service separately. never_late_feeboolApplies no late fee. Response fields data — 15 dataobjectThe client as it now stands. Same shape as the detail endpoint. Errors 6 not_found404No such client. full_name_required422Full name was sent empty. email_invalid422Email format is not valid. email_exists422That email is already in use. phone_invalid422The phone number is not valid. 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/clients/42' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"blocked","never_suspend":false}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'blocked', never_suspend: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 'blocked', 'never_suspend' => false, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Only two fields are sent; the rest of the profile stays as it was. $response = Api::Clients()->UpdateClient([ 'id' => 42, 'status' => 'blocked', 'never_suspend' => false, ]); ``` #### Deleting a Client delete/api/v1/admin/clients/{id} `Clients/DeleteClient` admin cannot be undone Deletes the client and returns the id that was removed. Response fields data deletedboolWhether the delete succeeded. idintId of the deleted client. Errors 2 not_found404No such client. 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/clients/42' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42'); 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::Clients()->DeleteClient(['id' => 42]); if (($response['data']['deleted'] ?? false) === true) { // The record is gone; clear your own data that pointed at it. } ``` #### Validating Client Credentials post/api/v1/admin/clients/validate `Clients/ValidateClient` admin writes nothing Answers one question: do this email and password belong to a live member account? Nothing is created or changed. Use it when your own front end holds the credentials and needs the client id behind them. > **The stored password never comes back** > > It is a bcrypt digest wrapped in the installation's own encryption. Returning it would let any holder of an admin key verify guesses offline. The response carries the client id and nothing else. Body 2 fields, both required emailstringrequiredThe account's email address. passwordstringrequiredThe password to check. Response fields data — 2 validboolAlways `true`. A mismatch comes back as an error, not as `valid: false`. user_idintThe client the credentials belong to. Errors 3 credentials_required422`email` or `password` is missing. invalid_credentials422No live member account matches this pair. 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/clients/validate' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"email":"john@example.com","password":"correct horse battery staple"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/validate', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'john@example.com', password }), }); if (res.ok) { const body = await res.json(); console.log(body.data.user_id); // the client behind the credentials } ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/validate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'email' => 'john@example.com', 'password' => $password, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->ValidateClient([ 'email' => 'john@example.com', 'password' => $password, ]); // A wrong pair arrives as an error, so reaching this line already means they matched. $clientId = $response['data']['user_id'] ?? 0; ``` #### Signing In as a Client post/api/v1/admin/clients/sso `Clients/CreateClientSsoToken` admin single use, 60 seconds Issues a one-time sign-in ticket for a client and returns the URL that spends it. Send the client to that URL and they arrive already signed in. Your integration never handles their password. Intended for a system that already knows who the visitor is: your own portal, a control panel, a desk tool. `login_as_client` is not exposed over the API because it swaps the PHP session in place; this endpoint is its token-based equivalent. > **How the ticket behaves** > > **Single use** — it is spent the moment the URL is opened. A replayed link lands on the sign-in form with an explanatory message. **Valid for 60 seconds**, meant to be issued and followed in one motion. **One per client**: a new ticket silently retires the previous one. **Confined to this installation**, so a `destination` pointing anywhere else is dropped and the client lands on their dashboard. **The login gate still applies.** Account status, country blocking and module vetoes are evaluated when the ticket is spent, exactly as for a password login. The ticket proves *who*, not *whether they may sign in right now*. Body 3 fields, 1 required client_idintrequiredClient to issue the ticket for. `user_id` is still accepted as its former name. destinationstringWhere the client lands after signing in: an absolute URL on this installation, or a route key such as `services`. Defaults to their dashboard. destination_valuesarrayRoute parameters, when `destination` is a route key. Response fields data — 3 tokenstringThe ticket, in the form `{client_id}-{secret}`. urlstringSign-in URL carrying the ticket; send the client here. expires_atstringExpiry, ISO 8601 with offset. Errors 5 client_id_required422`client_id` is missing. client_not_found404No client with this id. client_not_active422The account is inactive, blocked or otherwise cannot sign in. sso_ticket_failed422The key could not be issued. 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/clients/sso' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"client_id":42,"destination":"services","destination_values":[128]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/sso', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: 42, destination: 'services', destination_values: [128] }), }); const body = await res.json(); window.location = body.data.url; // spend it now; it lasts 60 seconds ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/sso'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'client_id' => 42, 'destination' => 'services', 'destination_values' => [128], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientSsoToken(['client_id' => 42]); $link = $response['data']['url']; ``` ### Pitfalls > **List and detail do not share a schema** > > The list returns the group as an object (`group`). The detail returns the id alone (`group_id`). The name is one field in the list and two in the detail. A mapper written against the list quietly produces empty fields when it meets the detail. > **On update an empty value means delete** > > Sending an empty phone or birthday **clears** the field. To keep a field, leave it out of the body entirely; that is what a partial update is for. > **A phone is not stored the way you write it** > > Formatting is dropped and only digits remain. Send `+1 555 010 0000` and you read back `15550100000`; any code that compares the two has to account for it. ### Related Articles - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [API Resource Map](https://dev.wisecp.com/en/api-resources) ## Stored Cards https://dev.wisecp.com/en/stored-cards The three endpoints that read a client's stored cards, pick the default and delete one. ### Overview A stored card is a card held at the client's payment provider. WISECP does **not** keep the card number; it holds a token from the provider and enough to recognise the card — the last four digits, the brand, the expiry. That is why there is no endpoint here that *adds* a card. A card is stored on the provider's own screen while the client pays; the API only reads what exists, moves the default and deletes. ### Reference #### Listing Cards get/api/v1/admin/clients/{id}/cards `Clients/GetClientCards` admin Returns the client's stored cards. The card number never comes back; only the last four digits show. Response fields data[] — 6 idintId of the stored card. last4stringThe last four digits of the card number. brandstringCard brand, for example `visa` or `mastercard`. modulestringThe payment module holding the card, for example `Stripe`. is_defaultboolWhether this is the default card. expirystringExpiry date, in `12/27` form. Errors 2 not_found404No such client or card. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/cards' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/cards', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); const primary = body.data.find((card) => card.is_default); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/cards'); 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::Clients()->GetClientCards(['id' => 42]); foreach ($response['data'] as $card) { if ($card['is_default']) { $primary = $card; } } ``` Response 200 ```json { "data": [ { "id": 88, "last4": "4242", "brand": "visa", "module": "Stripe", "is_default": true, "expiry": "12/27" } ] } ``` #### Setting the Default Card put/api/v1/admin/clients/{id}/cards/{card_id}/default `Clients/SetClientCardDefault` admin Makes a card the default. The flag on the previous one clears by itself. Body — ——No body is needed; send an empty one. Both the client and the card come from the path. ——There is no way to clear the default without another card taking its place. Response fields data — 2 card_idintId of the card that became the default. defaultboolAlways `true`. Errors 2 not_found404No such client or card. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/cards/88/default' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/cards/88/default', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/cards/88/default'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SetClientCardDefault([ 'id' => 42, 'card_id' => 88, ]); ``` #### Deleting a Card delete/api/v1/admin/clients/{id}/cards/{card_id} `Clients/DeleteClientCard` admin may move the default Deletes the stored card. If it was the default, another one takes its place and the id comes back in the response. Response fields data — 3 deletedboolWhether the delete succeeded. card_idintId of the deleted card. new_default_idintId of the new default card. `0` when the deleted card was not the default. Errors 2 not_found404No such client or card. 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/clients/42/cards/88' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/cards/88', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); if (body.data.new_default_id) { // The default moved to another card. } ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/cards/88'); 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::Clients()->DeleteClientCard([ 'id' => 42, 'card_id' => 88, ]); $newDefault = $response['data']['new_default_id'] ?? 0; ``` ### Pitfalls > **There is no endpoint that adds a card** > > A card can only be stored during the client's payment flow, because the number is entered at the provider. An integration trying to add one through the API is looking for an endpoint that does not exist. > **Deleting can move the default** > > Delete the default card and another takes its place; its id comes back in `new_default_id`. If you keep the default on your side, read that value and update it. > **What you see is not the card number** > > `last4` is only there to recognise the card. The API never returns the card number, the full expiry or the security code on any endpoint. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) ## Document Scheme https://dev.wisecp.com/en/document-scheme The ten endpoints that define which document is asked of whom: the field pool and the filters that use it. ### Overview The document scheme has two layers. **Fields** are a shared pool — single inputs such as "passport copy" or "tax certificate". **Filters** pick a set out of that pool and carry the rules that decide *who* is asked for it. One field can sit in several filters; that is what the pool is for. Updating a field reaches every filter using it at once. ### Reference #### Listing Filters get/api/v1/admin/clients/document-filters `Clients/GetDocumentFilters` admin Returns the document filters. Each one carries a set of fields and the rules that decide who is asked for them. Query parameters 3 statusstring`active` or `inactive`. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 5 idintFilter id. namestringFilter name. statusstring`active` or `inactive`. fieldsint[]An **ordered** list of ids from the field pool. The order is what the client sees. rulesobject[] 3 fieldsThe rules that decide who the filter applies to. typestringWhat the rule looks at: `email_provider`, `vpn_proxy`, `account_age`, `service_count`, `total_spending`, `country_mismatch`, `country`. valuestringDepends on the type: a list of domains, `yes`, a numeric threshold or a country id. extrastringAn extra value for some types. Empty on most rules. 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/clients/document-filters' \ -H "Authorization: Bearer $API_KEY" \ -d status=active ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/document-filters'); url.searchParams.set('status', 'active'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/document-filters?' . http_build_query(['status' => 'active']); $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::Clients()->GetDocumentFilters([], ['status' => 'active']); ``` #### Adding a Filter post/api/v1/admin/clients/document-filters `Clients/CreateDocumentFilter` admin Defines a new filter. The field ids have to exist in the pool. Body 4 namestringrequiredFilter name. fieldsint[]requiredOrdered field ids, for example `[3, 1]`. activeboolDefaults to `false`; the filter is born `inactive`. rulesobject[] 3 fieldsThe rules that apply it. typestringWhat the rule looks at: `email_provider`, `vpn_proxy`, `account_age`, `service_count`, `total_spending`, `country_mismatch`, `country`. valuestringDepends on the type: a list of domains, `yes`, a numeric threshold or a country id. extrastringAn extra value for some types. Empty on most rules. Response fields data — 5 idintId of the new filter. namestringFilter name. statusstring`active` or `inactive`. A new filter is born inactive unless you sent `active`. fieldsint[]The ordered field ids, as stored. rulesobject[]The stored rules — same `type` / `value` / `extra` shape as the listing. Errors 5 name_required422`name` was empty. fields_required422`fields` was empty or malformed. fields_invalid422None of the ids exist in the field pool. filter_add_failed500The record could not be created. 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/clients/document-filters' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"High spend","fields":[3,1],"active":true,"rules":[{"type":"total_spending","value":"5000","extra":""}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-filters', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({"name":"High spend","fields":[3,1],"active":true,"rules":[{"type":"total_spending","value":"5000","extra":""}]}), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-filters'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'High spend', 'fields' => [3, 1], 'active' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateDocumentFilter([ 'name' => 'High spend', 'fields' => [3, 1], 'active' => true, 'rules' => [ ['type' => 'total_spending', 'value' => '5000', 'extra' => ''], ], ]); ``` #### Filter Detail get/api/v1/admin/clients/document-filters/{fid} `Clients/GetDocumentFilter` admin Returns one filter; the schema is the same as in the list. Response fields data — 5 idintFilter id. namestringFilter name. statusstring`active` or `inactive`. fieldsint[]The ordered field ids, exactly as the listing returns them. rulesobject[]The rules, same `type` / `value` / `extra` shape as the listing. Errors 2 not_found404No such record. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/document-filters/7' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-filters/7', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-filters/7'); 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::Clients()->GetDocumentFilter(['fid' => 7]); ``` #### Updating a Filter patch/api/v1/admin/clients/document-filters/{fid} `Clients/UpdateDocumentFilter` admin Changes the filter name, the field order, the status or the rules. Body at least one namestringFilter name. fieldsint[]The new ordered field list. What you send replaces the old list. activeboolTurns the filter on or off. rulesobject[]The new rule list. This replaces as well, it does not append. Response fields data — 5 idintFilter id. namestringFilter name after the update. statusstring`active` or `inactive`. fieldsint[]The stored field order — read it back to see what the replace actually left. rulesobject[]The stored rules; an empty array means the filter now applies to everyone. Errors 5 not_found404No such filter. name_required422`name` was sent empty. fields_required422`fields` was sent empty or malformed. fields_invalid422An id does not exist in the field pool. 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/clients/document-filters/7' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"active":false}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-filters/7', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ active: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-filters/7'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['active' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateDocumentFilter([ 'fid' => 7, 'active' => false, ]); ``` #### Deleting a Filter delete/api/v1/admin/clients/document-filters/{fid} `Clients/DeleteDocumentFilter` admin Deletes the filter. The field pool is untouched; the fields stay in whatever other filters use them. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted filter. Errors 1 not_found404No such filter. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/document-filters/7' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-filters/7', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-filters/7'); 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::Clients()->DeleteDocumentFilter(['fid' => 7]); ``` #### Listing Fields get/api/v1/admin/clients/document-fields `Clients/GetDocumentFields` admin Returns the shared field pool. Filters pick their fields from here. Query parameters 4 statusstring`active` or `inactive`. pageintDefaults to 1. limitintDefaults to 25, maximum 100. typestringFilters by input type. Response fields data[] — 8 idintField id. A filter's `fields` list points here. statusstring`active` or `inactive`. An inactive field is not shown to the client. typestring`input`, `textarea`, `selectbox`, `radio`, `checkbox`, `file`. labelsobjectLabel per language, for example `{"en": "Passport copy"}`. optionsobjectOptions per language. Only meaningful on the choice types. allowed_extstringAllowed file extensions. Only on the `file` type. max_sizeintMaximum file size in MB. Only on the `file` type. used_inobject[]The filters using this field: `[{ id, name }]`. 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/clients/document-fields' \ -H "Authorization: Bearer $API_KEY" \ -d type=file ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/document-fields'); url.searchParams.set('type', 'file'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/document-fields?' . http_build_query(['type' => 'file']); $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::Clients()->GetDocumentFields([], ['type' => 'file']); ``` #### Adding a Field post/api/v1/admin/clients/document-fields `Clients/CreateDocumentField` admin Adds a field to the pool. The label has to be given in at least one language. Body 6 typestringrequiredInput type. labelsobjectrequiredLabel per language; at least one. activeboolDefaults to `true`. optionsobjectOptions per language. In practice required on the choice types. allowed_extstringAllowed extensions. Only on the `file` type. max_sizeintLimit in MB. Only on the `file` type. Response fields data — 7 idintId of the new field. statusstring`active` or `inactive`. typestringThe stored input type. labelsobjectThe stored labels, per language. optionsobjectThe stored choices, per language. allowed_extstringAllowed file extensions. max_sizeintMaximum file size in MB. used_in—Not part of this answer: a newly created field belongs to no filter yet. Errors 4 type_invalid422`type` is not one of the allowed input types. labels_required422No language carried a non-empty label. field_add_failed500The record could not be created. 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/clients/document-fields' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"file","labels":{"en":"Passport copy"},"allowed_ext":"jpg,png,pdf","max_size":5}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-fields', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'file', labels: {"en":"Passport copy"}, allowed_ext: 'jpg,png,pdf', max_size: 5, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-fields'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'file', 'labels' => ['en' => 'Passport copy'], 'allowed_ext' => 'jpg,png,pdf', 'max_size' => 5, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateDocumentField([ 'type' => 'file', 'labels' => ['en' => 'Passport copy'], 'allowed_ext' => 'jpg,png,pdf', 'max_size' => 5, ]); ``` #### Field Detail get/api/v1/admin/clients/document-fields/{fid} `Clients/GetDocumentField` admin Returns one field. `used_in` comes with it, showing which filters use it. Response fields data — 8 idintField id — this is what a filter's `fields` list refers to. statusstring`active` or `inactive`. An inactive field is not shown to clients even where a filter still lists it. typestringInput type: `input`, `textarea`, `selectbox`, `radio`, `checkbox`, `file`. labelsobjectThe label per language, for example `{ "en": "Passport copy" }`. optionsobjectPer-language choices for the list types. Empty on the other types. allowed_extstringAllowed file extensions — `file` type only. max_sizeintMaximum file size in MB — `file` type only. used_inobject[]The filters referencing this field, as `{ id, name }`. Read it before deleting — every one of them loses the field. Errors 2 not_found404No such field. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/document-fields/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-fields/3', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-fields/3'); 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::Clients()->GetDocumentField(['fid' => 3]); // Before deleting: which filters hold this field? $usedIn = $response['data']['used_in'] ?? []; ``` #### Updating a Field patch/api/v1/admin/clients/document-fields/{fid} `Clients/UpdateDocumentField` admin Updates the field. The change reaches every filter using it at once. Body 6 typestringrequiredInput type. Required even on an update — this endpoint rewrites the field rather than patching single keys. labelsobjectrequiredLabel per language; at least one must be non-empty. activeboolTurns the field on or off. Defaults to `true`. optionsobjectChoices per language. In practice required on the choice types. allowed_extstringAllowed extensions. Only on the `file` type. max_sizeintLimit in MB. Only on the `file` type. Response fields data — 7 idintField id. statusstring`active` or `inactive`. typestringThe stored input type. labelsobjectThe stored labels, per language. optionsobjectThe stored choices, per language. allowed_extstringAllowed file extensions. max_sizeintMaximum file size in MB. Errors 4 not_found404No such field. type_invalid422`type` is not one of the allowed input types. labels_required422No language carried a non-empty label. 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/clients/document-fields/3' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"max_size":10}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-fields/3', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ max_size: 10 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-fields/3'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['max_size' => 10]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateDocumentField([ 'fid' => 3, 'max_size' => 10, ]); ``` #### Deleting a Field delete/api/v1/admin/clients/document-fields/{fid} `Clients/DeleteDocumentField` admin affects filters Removes the field from the pool. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted field. Errors 1 not_found404No such field. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/document-fields/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-fields/3', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-fields/3'); 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::Clients()->DeleteDocumentField(['fid' => 3]); ``` ### Pitfalls > **The field list is ordered** > > `fields` is not a set but an **ordered** list, and the client sees the fields in that order. On update the list you send replaces the old one; it does not append to it. > **A field is shared, not copied** > > Updating or deleting a field affects **every** filter that uses it. Look at `used_in` on the field detail before you delete. > **A new filter is born switched off** > > Leave `active` out and the filter is created as `inactive`, asking nobody for anything. Creating one and walking away leaves a verification that never runs. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) ## Client Documents https://dev.wisecp.com/en/client-documents The six endpoints that read a client's submitted documents, approve or reject them, and manage the canned rejection reasons. ### Overview A client fills in the fields the document scheme asks for and submits them; these endpoints read those submissions and settle them. The scheme itself is a separate job and lives in **Document Scheme**. Each record maps to one field and carries its own status. A client can have some documents approved while others are still waiting. ### Reference #### Fetching Documents get/api/v1/admin/clients/{id}/documents `Clients/GetClientDocuments` admin marks as read Returns the document records the client submitted. Records hang off the field id and every filter is merged into one list. Query parameter 1 filter_idintIf given, the list narrows to the records that came from that filter. Response fields data — 2 filtersobject[]The filters the returned records came from: `[{ id, name }]`. recordsobject[] 8 fieldsThe document records. idintId of the document record. This is the key you use in the review request. field_keyintId of the field in the shared pool. field_namestringThe field label. Refreshed from the pool; if the field was deleted, the copy taken at submission comes back. field_typestringInput type: `input`, `textarea`, `selectbox`, `radio`, `checkbox`, `file`. field_valuestringThe value the client submitted. On file fields, JSON carrying the upload identifier. filter_idintThe filter this record came from. statusstring`awaiting`, `verified` or `unverified`. status_msgstringThe review note; a rejection reason goes here. Errors 2 not_found404No such client or record. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/documents' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/documents', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); const waiting = body.data.records.filter((r) => r.status === 'awaiting'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/documents'); 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::Clients()->GetClientDocuments(['id' => 42]); $waiting = []; foreach ($response['data']['records'] as $record) { if ($record['status'] === 'awaiting') { $waiting[] = $record['id']; } } ``` #### Reviewing Documents patch/api/v1/admin/clients/{id}/documents `Clients/ReviewClientDocuments` admin notifies the client Updates the verification status of several records at once. Every record whose status changes sends the client a notification. Body 1 statusesobjectrequired 2 fieldsA map keyed by record id: `{"": {"status": …, "message": …}}`. statusstringrequired`verified`, `unverified` or `awaiting`. A record carrying anything else is skipped in silence. messagestringThe review note. Put the reason here when rejecting; the client sees it. Response fields data — 3 reviewedboolWhether the review was processed. verifiedintHow many records were approved. rejectedintHow many records were rejected. Errors 2 not_found404No such client, or the client has no records at all. 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/clients/42/documents' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"statuses":{"51":{"status":"verified"},"52":{"status":"unverified","message":"The document is unreadable, please send a clearer copy"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/documents', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ statuses: { 51: { status: 'verified' }, 52: { status: 'unverified', message: 'The document is unreadable, please send a clearer copy' }, }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/documents'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'statuses' => [ 51 => ['status' => 'verified'], 52 => ['status' => 'unverified', 'message' => 'The document is unreadable, please send a clearer copy'], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->ReviewClientDocuments([ 'id' => 42, 'statuses' => [ 51 => ['status' => 'verified'], 52 => ['status' => 'unverified', 'message' => 'The document is unreadable, please send a clearer copy'], ], ]); $rejected = $response['data']['rejected'] ?? 0; ``` Response 200 ```json { "data": { "reviewed": true, "verified": 1, "rejected": 1 } } ``` #### Deleting a Document Record delete/api/v1/admin/clients/{id}/documents/{record_id} `Clients/DeleteClientDocument` admin Deletes a single document record. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted record. Errors 3 not_found404No such client. record_required422The record id was missing. 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/clients/42/documents/51' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/documents/51', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/documents/51'); 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::Clients()->DeleteClientDocument([ 'id' => 42, 'record_id' => 51, ]); ``` #### Listing Rejection Reasons get/api/v1/admin/clients/document-rejection-reasons `Clients/GetDocumentRejectionReasons` admin Returns the saved rejection reasons. These are the canned texts used while reviewing. Response fields data[] datastring[]A plain list of reason texts — there are no ids here, the text itself is the identity. 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/clients/document-rejection-reasons' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-rejection-reasons', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-rejection-reasons'); 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::Clients()->GetDocumentRejectionReasons(); ``` #### Adding a Rejection Reason post/api/v1/admin/clients/document-rejection-reasons `Clients/AddDocumentRejectionReason` admin Adds a new canned reason to the list. Body 1 valuestringrequiredThe reason text. Response fields data[] datastring[]The whole list after the add, not the added text alone. Errors 2 value_required422`value` was empty. 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/clients/document-rejection-reasons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"value":"The document is unreadable, please send a clearer copy"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-rejection-reasons', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ value: 'The document is unreadable, please send a clearer copy' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-rejection-reasons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['value' => 'The document is unreadable, please send a clearer copy']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->AddDocumentRejectionReason(['value' => 'The document is unreadable, please send a clearer copy']); ``` #### Deleting a Rejection Reason delete/api/v1/admin/clients/document-rejection-reasons `Clients/DeleteDocumentRejectionReason` admin deleted by text Removes a reason from the list. You send the text itself, not an id. Body 1 valuestringrequiredThe text of the reason to remove. It has to match the stored one exactly. Response fields data[] datastring[]The reasons that remain. A text that did not match leaves the list unchanged — compare it to know whether anything went. Errors 2 value_required422`value` was empty. 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/clients/document-rejection-reasons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"value":"The document is unreadable, please send a clearer copy"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/document-rejection-reasons', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ value: 'The document is unreadable, please send a clearer copy' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/document-rejection-reasons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['value' => 'The document is unreadable, please send a clearer copy']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->DeleteDocumentRejectionReason(['value' => 'The document is unreadable, please send a clearer copy']); ``` ### Pitfalls > **Listing marks the records as read** > > The fetch endpoint is not read-only: calling it marks the records as read. An integration polling this while watching the pending-document badge will clear that badge on every poll. > **A review notifies the client** > > Every record whose status changes sends an approval or rejection notice. Sending the same status twice counts as no change, but rejecting a record by mistake and fixing it produces **two** notices. > **A rejection reason is deleted by its text** > > The delete request takes `value`, not an id, and the text has to match exactly. Even one space of difference will not find the record. ### Related Articles - [Document Scheme](https://dev.wisecp.com/en/document-scheme) - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) ## GDPR Requests https://dev.wisecp.com/en/gdpr-requests The ten endpoints that read a client's data removal requests, settle them, and manage the feature's settings. ### Overview A client can ask for their data to be removed. These endpoints read those requests, settle them, and manage the feature's own settings. Removal has three scopes and **no way back**: cutting access, anonymising the identifying data, deleting the user outright. Which one ran is in `remove_action` on the response. ### Reference #### Listing Requests get/api/v1/admin/clients/gdpr-requests `Clients/GetGdprRequests` admin Returns the list of data requests coming from clients. Query parameters 4 statusstringFilters by status. typestringFilters by request type. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 5 idintRequest id. user_idintThe client who asked. typestringWhat was asked for, for example `remove`. statusstringWhere the request stands, for example `pending`. created_atstringWhen it arrived. Meta 4 totalintTotal matching requests. pageintThe page you are on. limitintPage size. next_pageintThe next page, or `0` 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/clients/gdpr-requests' \ -H "Authorization: Bearer $API_KEY" \ -d limit=50 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/gdpr-requests'); url.searchParams.set('limit', '50'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/gdpr-requests?' . http_build_query(['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); ``` ```php $response = Api::Clients()->GetGdprRequests([], ['limit' => 50]); ``` #### Request Detail get/api/v1/admin/clients/gdpr-requests/{rid} `Clients/GetGdprRequest` admin Returns one request. This is what you read before deciding. Response fields data — 1 requestobject 7 fieldsThe request record. idintRequest id. statusstringStatus of the request. remove_typestringThe scope asked for: `block_access`, `identifying_data` or `all`. invoice_countintHow many invoices the client has. Worth reading before deciding: invoices fall under legal retention. processedboolWhether it has been settled. processed_byintId of the admin who settled it. created_atstringWhen the request arrived. Errors 2 not_found404No such request. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/gdpr-requests/9' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-requests/9', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-requests/9'); 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::Clients()->GetGdprRequest(['rid' => 9]); $request = $response['data']['request']; // Asking for 'all' on a client with invoices can collide with legal retention. $hasInvoices = $request['invoice_count'] > 0; ``` #### Settling a Request post/api/v1/admin/clients/gdpr-requests/{rid}/process `Clients/ProcessGdprRequest` admin cannot be undone Approves or refuses the request. On approval the data removal runs in the scope you choose. Body 5 statusstringrequiredThe decision: `remove`, `anonymize`, `destroy`, or `cancelled` to refuse. remove_typestringThe removal scope. Only meaningful when approving. `block_access` cuts access, `identifying_data` anonymises the identifying data, `all` deletes the user. status_notestringA note on the decision. Put the reason here when refusing. notificationboolSends the client a notification. blacklistboolBlacklists or unblacklists the client in the same operation. Response fields data — 4 processedboolWhether it was settled. statusstringThe status that was applied. remove_actionstringThe removal that actually ran: `none`, `block_access`, `identifying_data` or `all`. blacklistintBlacklist state afterwards: `0` or `1`. Errors 4 not_found404No such request. status_invalid422`status` is not one of the allowed values. blocked_by_gate422A hook vetoed the operation. One of your own addons may be blocking the removal. gdpr_delete_failed500The user data could not be removed. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/admin/clients/gdpr-requests/9/process' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"anonymize","remove_type":"identifying_data","status_note":"Request verified","notification":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-requests/9/process', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'anonymize', remove_type: 'identifying_data', status_note: 'Request verified', notification: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-requests/9/process'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 'anonymize', 'remove_type' => 'identifying_data', 'status_note' => 'Request verified', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->ProcessGdprRequest([ 'rid' => 9, 'status' => 'anonymize', 'remove_type' => 'identifying_data', 'status_note' => 'Request verified', ]); $applied = $response['data']['remove_action'] ?? 'none'; ``` Response 200 422 ```json { "data": { "processed": true, "status": "anonymize", "remove_action": "identifying_data", "blacklist": 0 } } ``` ```json { "error": { "code": "blocked_by_gate", "message": "The removal was blocked by a hook." } } ``` #### Deleting a Request delete/api/v1/admin/clients/gdpr-requests/{rid} `Clients/DeleteGdprRequest` admin Deletes the request record. It does not touch the client's data; only the record goes. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted request. Errors 1 not_found404No such request. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/gdpr-requests/9' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-requests/9', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-requests/9'); 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::Clients()->DeleteGdprRequest(['rid' => 9]); ``` #### Reading the Settings get/api/v1/admin/clients/gdpr-settings `Clients/GetGdprSettings` admin Returns whether the feature is on, which contract page is attached, and the canned denial reasons. Response fields data — 4 enabledboolWhether the feature is on. requiredboolWhether consent is mandatory. contract_page_idintId of the attached contract page. denial_reasonsarrayThe canned denial reasons. An empty array when there are none. 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/clients/gdpr-settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-settings'); 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::Clients()->GetGdprSettings(); ``` #### Saving the Settings put/api/v1/admin/clients/gdpr-settings `Clients/SaveGdprSettings` admin Turns the feature on or off and attaches the contract page. Body 3 enabledboolTurns the feature on or off. requiredboolMakes consent mandatory. contract_page_idintId of the contract page to attach. List: `gdpr-contracts`. Response fields data — 3 enabledboolWhether the feature is on after the write. requiredboolWhether consent is mandatory. contract_page_idintThe attached contract page. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/gdpr-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"required":true,"contract_page_id":12}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, required: true, contract_page_id: 12 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'enabled' => true, 'required' => true, 'contract_page_id' => 12, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SaveGdprSettings([ 'enabled' => true, 'required' => true, 'contract_page_id' => 12, ]); ``` #### Searching Contract Pages get/api/v1/admin/clients/gdpr-contracts `Clients/GetGdprContracts` admin Returns the pages that can be attached as the contract. This is where `contract_page_id` comes from. Response fields data[] — 2 idintPage id — this is what you send as `contract_page_id`. titlestringPage title. 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/clients/gdpr-contracts' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-contracts', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-contracts'); 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::Clients()->GetGdprContracts(); ``` #### Listing Denial Reasons get/api/v1/admin/clients/gdpr-denial-reasons `Clients/GetGdprDenialReasons` admin Returns the canned texts used when refusing a request. Response fields data[] datastring[]A plain list of reason texts — there are no ids here, the text itself is the identity. 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/clients/gdpr-denial-reasons' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons'); 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::Clients()->GetGdprDenialReasons(); ``` #### Adding a Denial Reason post/api/v1/admin/clients/gdpr-denial-reasons `Clients/AddGdprDenialReason` admin Adds a new canned reason to the list. Body 1 valuestringrequiredThe reason text. Response fields data[] datastring[]The whole list after the add, not the added text alone. Errors 1 value_required422`value` was empty. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"value":"The legal retention period has not ended"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ value: 'The legal retention period has not ended' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['value' => 'The legal retention period has not ended']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->AddGdprDenialReason(['value' => 'The legal retention period has not ended']); ``` #### Deleting a Denial Reason delete/api/v1/admin/clients/gdpr-denial-reasons `Clients/DeleteGdprDenialReason` admin deleted by text Removes a reason from the list. You send the text itself, not an id. Body 1 valuestringrequiredThe text to remove. It has to match the stored one exactly. Response fields data[] datastring[]The reasons that remain. A text that did not match leaves the list unchanged — compare it to know whether anything went. Errors 1 value_required422`value` was empty. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"value":"The legal retention period has not ended"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ value: 'The legal retention period has not ended' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/gdpr-denial-reasons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['value' => 'The legal retention period has not ended']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->DeleteGdprDenialReason(['value' => 'The legal retention period has not ended']); ``` ### Pitfalls > **Read the invoice count before deciding** > > The request detail carries `invoice_count`. In most countries invoices fall under a legal retention period, so asking for `all` on a client with invoices can collide with that duty. That is why the field is in the response. > **A hook can veto the operation** > > A `blocked_by_gate` error does not mean your request was wrong; it means an addon in the installation blocked the removal. If you wrote your own hook, look there first. > **Deleting the request does not delete the data** > > The delete endpoint only removes the request record. The client's data stays exactly as it was; removal runs only through the process endpoint. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Blacklist and Duplicates](https://dev.wisecp.com/en/blacklist-and-duplicates) ## Affiliate https://dev.wisecp.com/en/affiliate-endpoints The nine endpoints that open an affiliate partnership, set the commission, run the payout and scan for suspicious referrals. ### Overview In an affiliate partnership a client earns commission on every sale they bring. These endpoints open the partnership, set the commission rule, run the payout and scan for suspicious referrals. Commission has two periods: `lifetime` pays on every renewal the referred client makes, `onetime` only on the first sale. The choice is per partner. ### Reference #### Reading the Affiliate Status get/api/v1/admin/clients/{id}/affiliate `Clients/GetClientAffiliate` admin Returns the client's affiliate status, commission settings and balance. Response fields data — 8 is_affiliateboolWhether the client is an affiliate. activated_atdatetimeWhen the partnership was opened. disabledboolWhether the partnership is switched off. disabled_reasonstringWhy it was switched off. commission_valuefloatCommission rate as a percentage. Between 0 and 100. commission_periodstring`lifetime` pays on every renewal, `onetime` only on the first sale. An empty value uses the default. balancefloatThe partner's earnings balance. currency_idintCurrency id of the balance. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/affiliate' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/affiliate', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/affiliate'); 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::Clients()->GetClientAffiliate(['id' => 42]); if (!($response['data']['is_affiliate'] ?? false)) { return; } ``` #### Opening the Partnership post/api/v1/admin/clients/{id}/affiliate `Clients/ActivateClientAffiliate` admin Makes the client an affiliate. On a client who already is one, it returns an error. Body 3 commission_valuenumberCommission rate. Clamped to 0-100; a value outside that is not refused, it is trimmed. commission_periodstring`lifetime` or `onetime`. Anything else becomes empty. currency_idintBalance currency. List: `reference/currencies`. Response fields data dataobjectThe partnership opened, returned with `201`. Same shape as the status endpoint. Errors 4 not_found404No such client. already_affiliate422The client is already an affiliate. activate_failed500The partnership could not be opened. 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/clients/42/affiliate' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"commission_value":10,"commission_period":"lifetime","currency_id":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/affiliate', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ commission_value: 10, commission_period: 'lifetime', currency_id: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/affiliate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'commission_value' => 10, 'commission_period' => 'lifetime', 'currency_id' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->ActivateClientAffiliate([ 'id' => 42, 'commission_value' => 10, 'commission_period' => 'lifetime', 'currency_id' => 1, ]); ``` #### Updating the Partnership patch/api/v1/admin/clients/{id}/affiliate `Clients/UpdateClientAffiliate` admin blocking lives here Changes the commission, the balance and the partner's state. Blocking goes through here too. Body 7 commission_valuenumberCommission rate; clamped to 0-100. commission_periodstring`lifetime` ya da `onetime`. currency_idintBalance currency. balancenumberThe partner's balance. A negative value is pulled up to zero. disabledboolSwitches the partnership off or back on. disabled_reasonstringWhy it was switched off. Only written while `disabled` is on. block_partnerboolBlocks the partner **and cancels the waiting withdrawal requests**. Response fields data dataobjectThe partnership as it now stands. Same shape as the status endpoint. Errors 3 not_found404No such client. not_affiliate422The client is not an affiliate. 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/clients/42/affiliate' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"commission_value":15}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/affiliate', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ commission_value: 15 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/affiliate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['commission_value' => 15]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Blocking also cancels the waiting withdrawal requests. $response = Api::Clients()->UpdateClientAffiliate([ 'id' => 42, 'block_partner' => true, 'block_reason' => 'Fake referrals', ]); ``` #### Running a Fraud Check post/api/v1/admin/clients/{id}/affiliate/fraud-check `Clients/CheckClientAffiliateFraud` admin Scans the partner's referrals and returns the suspicious patterns. Body — ——No body is needed. The scan always covers the partner's whole referral history and cannot be narrowed. Response fields data — 3 flaggedboolWhether the partner was flagged. self_referralsintHow many referrals the partner made to themselves. shared_ip_referralsarrayReferrals coming from the same IP as the partner. Errors 3 not_found404No such client. not_affiliate422The client is not an affiliate. 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/clients/42/affiliate/fraud-check' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/affiliate/fraud-check', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/affiliate/fraud-check'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CheckClientAffiliateFraud(['id' => 42]); // The scan only reports; blocking is your decision and your call. if ($response['data']['flagged'] ?? false) { $selfReferrals = $response['data']['self_referrals']; } ``` #### Listing Withdrawal Requests get/api/v1/admin/clients/{id}/affiliate/withdrawals `Clients/GetClientAffiliateWithdrawals` admin Returns the requests the partner opened to withdraw their earnings. Response fields data[] — 6 idintId of the withdrawal request. amountfloatThe amount asked for. gatewaystringThe method the payment goes out through. statusstring`awaiting`, `process`, `completed`, `rejected` or `cancelled`. status_msgstringA note on the status. created_atdatetimeWhen the request was opened. Errors 3 not_found404No such client. not_affiliate422The client is not an affiliate. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/affiliate/withdrawals' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/affiliate/withdrawals', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/affiliate/withdrawals'); 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::Clients()->GetClientAffiliateWithdrawals(['id' => 42]); ``` #### Updating a Withdrawal Request patch/api/v1/admin/clients/{id}/affiliate/withdrawals/{wid} `Clients/UpdateClientAffiliateWithdrawal` admin a receipt can be attached Changes the request status. If you made the payment, the receipt goes in the same request. Body 4 statusstringrequiredThe new status. Older names are accepted: `pending` → `awaiting`, `inprocess` → `process`, `paid` → `completed`. status_msgstringA message for the partner. receiptstring | objectThe payment receipt. Accepted only on the move to `completed`. A base64 data URI, `{filename, content}` or `{url}`. Allowed: images and PDF. remove_receiptboolRemoves the receipt already attached. Response fields data dataobjectThe request as it now stands. Same shape as an item in the request list. Errors 4 not_found404No such client or withdrawal request. status_required422`status` was empty. file_invalid422The receipt could not be read or stored. 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/clients/42/affiliate/withdrawals/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"completed","receipt":{"url":"https://example.com/receipt.pdf"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/affiliate/withdrawals/5', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'completed', receipt: { url: 'https://example.com/receipt.pdf' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/affiliate/withdrawals/5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 'completed', 'receipt' => ['url' => 'https://example.com/receipt.pdf'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateClientAffiliateWithdrawal([ 'id' => 42, 'wid' => 5, 'status' => 'completed', 'receipt' => ['url' => 'https://example.com/receipt.pdf'], ]); ``` #### Listing Canned Texts get/api/v1/admin/clients/affiliate/templates `Clients/GetAffiliateTemplates` admin Returns the canned texts used in affiliate work: deactivation reasons, withdrawal notes, block reasons. Response fields data — 3 deactivate_reasonsstring[]Reasons for closing a partnership. withdrawal_notesstring[]Notes put on a withdrawal request. block_reasonsstring[]Reasons for blocking. 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/clients/affiliate/templates' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/affiliate/templates', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/affiliate/templates'); 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::Clients()->GetAffiliateTemplates(); ``` #### Adding a Canned Text post/api/v1/admin/clients/affiliate/templates `Clients/AddAffiliateTemplate` admin Adds a text to the type you choose. Body 2 typestringrequiredMetnin türü: `deactivate_reasons`, `withdrawal_notes` or `block_reasons`. valuestringrequiredThe text to add. Response fields data — 2 typestringThe type the text went into. itemsstring[]That type's list as it now stands. Errors 3 type_invalid422The type is not one of the three values. value_required422`value` was empty. 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/clients/affiliate/templates' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"block_reasons","value":"Fake referrals detected"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/affiliate/templates', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'block_reasons', value: 'Fake referrals detected' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/affiliate/templates'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'block_reasons', 'value' => 'Fake referrals detected', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->AddAffiliateTemplate([ 'type' => 'block_reasons', 'value' => 'Fake referrals detected', ]); ``` #### Deleting a Canned Text delete/api/v1/admin/clients/affiliate/templates `Clients/DeleteAffiliateTemplate` admin deleted by text Removes a text from the list. You send the text itself, not an id. Body 2 typestringrequiredMetnin türü: `deactivate_reasons`, `withdrawal_notes` or `block_reasons`. valuestringrequiredThe text to remove. It has to match the stored one exactly. Response fields data — 2 typestringThe type the text came out of. itemsstring[]That type's list as it now stands. Errors 3 type_invalid422The type is not one of the three values. value_required422`value` was empty. 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/clients/affiliate/templates' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"block_reasons","value":"Fake referrals detected"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/affiliate/templates', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'block_reasons', value: 'Fake referrals detected' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/affiliate/templates'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'block_reasons', 'value' => 'Fake referrals detected', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->DeleteAffiliateTemplate([ 'type' => 'block_reasons', 'value' => 'Fake referrals detected', ]); ``` ### Pitfalls > **Values are trimmed, not refused** > > The commission rate is **clamped** to 0-100, a negative balance is pulled up to zero, and an unrecognised commission period becomes empty. None of these raise an error; a wrong value is quietly stored as a corrected one. Read back after writing. > **Blocking cancels waiting payouts** > > `block_partner` does not only stop the partner; it also cancels the waiting withdrawal requests. Look at the request list before blocking a partner who is about to be paid. > **The fraud scan makes no decision** > > The scan only reports: how many self-referrals there are and which records came from the same IP. Blocking the partner is a separate request and the decision is yours. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Blacklist and Duplicates](https://dev.wisecp.com/en/blacklist-and-duplicates) ## Account State and Bulk Actions https://dev.wisecp.com/en/account-state-and-bulk-actions The six endpoints that block an account, suspend or cancel its services in bulk, and chase unpaid invoices. ### Overview These endpoints change the state of an account and of the services attached to it. That covers blocking, bulk suspension, bulk cancellation and invoice reminders. Blocking comes in two shapes. For a single client the `block` endpoint also stores the reason; for a list, `bulk` does the same job without one. The panel walks bulk service actions one at a time to drive its progress bar. The API does the whole set in **one call**. ### Reference #### Blocking an Account put/api/v1/admin/clients/{id}/block `Clients/SetClientBlock` admin sends a notification Blocks the account or lifts the block. The client is notified. Body 2 blockedboolrequired`true` blocks, `false` lifts the block. reasonstringWhy it was blocked. Stored only while blocking. Response fields data — 1 blockedboolThe block state afterwards. Errors 3 not_found404No such client. blocked_by_gate422The `gate:user.block` hook vetoed the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/block' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"blocked":true,"reason":"Payment dispute"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/block', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ blocked: true, reason: 'Payment dispute' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/block'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'blocked' => true, 'reason' => 'Payment dispute', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SetClientBlock([ 'id' => 42, 'blocked' => true, 'reason' => 'Payment dispute', ]); // If a hook vetoes, the call returns an error and nothing is blocked. if (isset($response['error'])) { $code = $response['error']['code']; } ``` #### Applying a Bulk Action post/api/v1/admin/clients/bulk `Clients/BulkClientActions` admin many clients Applies the same action to a list of clients. Only member accounts are processed; admins are skipped. Body 2 actionstringrequiredThe action to apply. `verify` marks e-mail and phone as verified, `reactivate` puts the account back to active, `block` blocks it. idsint[]requiredThe client ids. Response fields data — 2 actionstringThe action that was applied. processedint[]The ids that were actually processed. Skipped admin accounts are not here, so compare against the list you sent. Errors 3 action_invalid422The action is not one of the three values. ids_required422`ids` was empty. 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/clients/bulk' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"action":"verify","ids":[80,81]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/bulk', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'verify', ids: [80, 81] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/bulk'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'action' => 'verify', 'ids' => [80, 81], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $sent = [80, 81, 3]; $response = Api::Clients()->BulkClientActions([ 'action' => 'verify', 'ids' => $sent, ]); // Compare the lists to see what was skipped (admin accounts are not processed). $skipped = array_diff($sent, $response['data']['processed'] ?? []); ``` Response 200 422 ```json { "data": { "action": "verify", "processed": [80, 81] } } ``` ```json { "error": { "code": "action_invalid", "message": "action must be verify/reactivate/block." } } ``` #### Suspending Every Service post/api/v1/admin/clients/{id}/services/suspend `Clients/SuspendClientServices` admin no body Suspends every active service the client has. The reason is written as `Account bulk suspended`. Body — ——No body is needed. The client comes from the path and the set cannot be narrowed: every active service is taken. Response fields data — 3 suspendedintHow many services were suspended. idsint[]Ids of the suspended services. failedobject[]The services that failed. Each element carries `{id, error}`. An empty array means all of them went through. Errors 2 not_found404No such client. 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/clients/42/services/suspend' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/services/suspend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/services/suspend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SuspendClientServices(['id' => 42]); // Partial success is possible: some go through, others come back in 'failed'. foreach ($response['data']['failed'] ?? [] as $fail) { $serviceId = $fail['id']; $reason = $fail['error']; } ``` #### Lifting the Bulk Suspension post/api/v1/admin/clients/{id}/services/unsuspend `Clients/UnsuspendClientServices` admin bulk suspensions only Puts the bulk-suspended services back. It does not touch services suspended for another reason. Body — ——No body is needed. The client comes from the path and the set is fixed: the services carrying the bulk-suspension reason. Response fields data — 3 unsuspendedintHow many services were put back. idsint[]Ids of the services affected. failedobject[]The services that failed. Each element carries `{id, error}`. An empty array means all of them went through. Errors 2 not_found404No such client. 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/clients/42/services/unsuspend' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/services/unsuspend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/services/unsuspend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UnsuspendClientServices(['id' => 42]); ``` #### Cancelling Every Service post/api/v1/admin/clients/{id}/services/cancel `Clients/CancelClientServices` admin cannot be undone Cancels every service the client has that is not already cancelled or finished. Body — ——No body is needed. The client comes from the path; there is no field to pick single services with. Response fields data — 3 cancelledintHow many services were cancelled. idsint[]Ids of the cancelled services. failedobject[]The services that failed. Each element carries `{id, error}`. An empty array means all of them went through. Errors 3 not_found404No such client. blocked_by_gate422The `gate:user.services_bulk_cancel` hook vetoed the operation. 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/clients/42/services/cancel' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/services/cancel', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/services/cancel'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CancelClientServices(['id' => 42]); ``` #### Reminding About Unpaid Invoices post/api/v1/admin/clients/{id}/remind-invoices `Clients/RemindClientInvoices` admin one notice per invoice Sends a reminder notice for every unpaid invoice the client has. Body — ——No body is needed. The client comes from the path; every unpaid invoice gets a notice, and single ones cannot be chosen. Response fields data — 1 remindedintHow many invoices got a reminder. Errors 3 not_found404No such client. no_unpaid_invoices422The client has no unpaid invoices. You get an error, not an empty response. 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/clients/42/remind-invoices' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/remind-invoices', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/remind-invoices'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->RemindClientInvoices(['id' => 42]); // No unpaid invoices is an error here, not an empty result. if (($response['error']['code'] ?? '') === 'no_unpaid_invoices') { return; } ``` ### Pitfalls > **Partial success is quiet** > > The bulk service endpoints can return `200` while some services still failed; those come back in the `failed` array as `{id, error}`. A client that only reads the status code will miss it. > **Unsuspending is selective** > > The unsuspend endpoint only reopens services that were stopped by the **bulk suspension**. A service suspended for overdue payment or by hand stays where it is, which stops one call from silently restarting it. > **Bulk actions skip admin accounts** > > An account in your list that is not a member gets skipped without a word. It does not appear in `processed`, so compare the list you sent with the one you get back. > **The panel asks for a password, the API for a scope** > > Bulk cancellation asks for the admin password in the panel. The API has no such second step: the key's scope is enough. Hand out a key carrying this scope knowing exactly who holds it. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Blacklist and Duplicates](https://dev.wisecp.com/en/blacklist-and-duplicates) ## Client Configuration https://dev.wisecp.com/en/client-configuration The six endpoints that read and save client groups, badge thresholds and trust score tiers. ### Overview These six endpoints read and write the installation-wide client configuration: groups, badge thresholds and trust score tiers. None of them looks at an individual client. The trust score is worked out on four axes: service count, revenue, account age and support tickets. Each axis is a list of tiers, and a client scores whatever the tier they fall into is worth. ### Reference #### Listing the Groups get/api/v1/admin/clients/groups `Clients/GetClientGroups` admin Returns every client group in the installation. Response fields data[] — 11 idintId of an existing group. Leave it out or send `0` for a new one. namestringrequiredThe group name. If it is empty the group is quietly skipped. descriptionstringA description. colorstringBadge colour. Defaults to `#095174`. iconstringIcon name. discount_ratenumberThe discount rate applied to the group. discount_productsstringProduct groups the discount covers. Comma-separated ids. combine_discountboolLets the discount stack with other discounts. protectionboolPuts the group under protection. priorityintPriority order. separate_invoicesboolIssues separate invoices for the clients in the group. 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/clients/groups' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/groups', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/groups'); 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::Clients()->GetClientGroups(); ``` #### Saving the Groups put/api/v1/admin/clients/groups `Clients/SaveClientGroups` admin the whole list Saves the group list as it stands. The list you send becomes the truth: a group missing from it is deleted. Body 1 groupsobject[]required 11 fieldsThe complete set of groups. idintId of an existing group. Leave it out or send `0` for a new one. namestringrequiredThe group name. If it is empty the group is quietly skipped. descriptionstringA description. colorstringBadge colour. Defaults to `#095174`. iconstringIcon name. discount_ratenumberThe discount rate applied to the group. discount_productsstringProduct groups the discount covers. Comma-separated ids. combine_discountboolLets the discount stack with other discounts. protectionboolPuts the group under protection. priorityintPriority order. separate_invoicesboolIssues separate invoices for the clients in the group. Response fields data[] — 11 dataobject[]The group list as it now stands. Same shape as the listing endpoint. Errors 2 groups_invalid422`groups` is not an array. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/groups' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"groups":[{"id":5,"name":"VIP","discount_rate":10,"discount_products":"1,2","priority":1}]}' ``` ```javascript // Read the list first, change it, then send all of it back. const current = await (await fetch('https://panel.example.com/api/v1/admin/clients/groups', { headers: { Authorization: `Bearer ${apiKey}` }, })).json(); const groups = current.data.map((g) => g.id === 5 ? { ...g, discount_rate: 15 } : g); const res = await fetch('https://panel.example.com/api/v1/admin/clients/groups', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ groups }), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/groups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'groups' => [ [ 'id' => 5, 'name' => 'VIP', 'discount_rate' => 10, 'priority' => 1, ], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Even to change one group, the WHOLE list goes back. $groups = Api::Clients()->GetClientGroups()['data']; foreach ($groups as &$group) if ((int) $group['id'] === 5) $group['discount_rate'] = 15; unset($group); $response = Api::Clients()->SaveClientGroups(['groups' => $groups]); ``` #### Reading the Badge Thresholds get/api/v1/admin/clients/badge-settings `Clients/GetBadgeSettings` admin Returns the thresholds at which client badges are earned. If none were saved, you get the defaults. Response fields data — 5 loyal_yearsintYears needed for the loyal client badge. rev_silverintRevenue threshold for the silver badge. rev_goldintRevenue threshold for the gold badge. multi_service_minintFewest services needed for the multi-service badge. experienced_maxintUpper bound of the experienced badge. 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/clients/badge-settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/badge-settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/badge-settings'); 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::Clients()->GetBadgeSettings(); ``` #### Saving the Badge Thresholds put/api/v1/admin/clients/badge-settings `Clients/SaveBadgeSettings` admin values get trimmed Saves all five thresholds at once. One you leave out drops back to its default, so send the whole set. Values are pulled into a safe range rather than refused. Body 5 loyal_yearsintLoyal client year threshold. Minimum 1. rev_silverintSilver revenue threshold. Minimum 0. rev_goldintGold revenue threshold. If you send it at or below silver it becomes `rev_silver + 1`. multi_service_minintMulti-service badge minimum. Minimum 1. experienced_maxintExperienced badge upper bound. Minimum 0. Response fields data — 5 dataobjectThe thresholds as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/badge-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"loyal_years":5,"rev_silver":500,"rev_gold":1000,"multi_service_min":5,"experienced_max":15}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/badge-settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ loyal_years: 5, rev_silver: 500, rev_gold: 1000, multi_service_min: 5, experienced_max: 15, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/badge-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'loyal_years' => 5, 'rev_silver' => 500, 'rev_gold' => 1000, 'multi_service_min' => 5, 'experienced_max' => 15, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SaveBadgeSettings([ 'rev_silver' => 500, 'rev_gold' => 400, ]); // The response carries what was stored: rev_gold comes back as 501 here. $saved = $response['data']['rev_gold']; ``` #### Reading the Trust Score Tiers get/api/v1/admin/clients/trust-score-settings `Clients/GetTrustScoreSettings` admin Returns the tiers on all four trust score axes. If none were saved, you get the defaults. Response fields data — 4 servicesobject[] 2 fieldsThe service count axis. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. revenueobject[] 2 fieldsThe revenue axis. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. ageobject[] 2 fieldsThe account age axis. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. ticketsobject[] 2 fieldsThe support ticket axis. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. 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/clients/trust-score-settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/trust-score-settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/trust-score-settings'); 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::Clients()->GetTrustScoreSettings(); ``` Response 200 ```json { "data": { "services": [ { "max": 5, "points": 20 }, { "max": null, "points": 30 } ], "revenue": [{ "max": 1000, "points": 25 }], "age": [{ "max": 12, "points": 10 }], "tickets": [{ "max": null, "points": 5 }] } } ``` #### Saving the Trust Score Tiers put/api/v1/admin/clients/trust-score-settings `Clients/SaveTrustScoreSettings` admin all four axes at once Saves all four axes at once. An axis you leave out is saved empty and loses its tiers, so send every axis you want to keep. Body 4 servicesobject[] 2 fieldsService count tiers. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. revenueobject[] 2 fieldsRevenue tiers. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. ageobject[] 2 fieldsAccount age tiers. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. ticketsobject[] 2 fieldsSupport ticket tiers. maxint | nullrequiredThe upper bound of the tier. `null` is the last, unbounded tier. pointsintrequiredThe points the tier is worth. Response fields data — 4 dataobjectThe tiers as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/trust-score-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"services":[{"max":5,"points":20},{"max":null,"points":30}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/trust-score-settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ services: [ { max: 5, points: 20 }, { max: null, points: 30 }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/trust-score-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'services' => [ ['max' => 5, 'points' => 20], ['max' => null, 'points' => 30], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The last tier needs 'max' => null, or a client above the bound scores nothing. $response = Api::Clients()->SaveTrustScoreSettings([ 'services' => [ ['max' => 5, 'points' => 20], ['max' => null, 'points' => 30], ], ]); ``` ### Pitfalls > **Saving groups replaces the whole list** > > The save endpoint does not merge. A group missing from the list you send is **deleted**, and the clients in it are left without one. To change a single group, read the list first, edit it, and send all of it back. > **The gold threshold is pushed above silver** > > Sending the gold revenue threshold at or below silver does not raise an error; the value quietly becomes `rev_silver + 1`. Read the response to see what was stored. > **The last tier has to be unbounded** > > Leave `max` as `null` on an axis's last tier. Otherwise a client above the highest bound falls into no tier and scores nothing on that axis. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Account State and Bulk Actions](https://dev.wisecp.com/en/account-state-and-bulk-actions) ## WHOIS Profiles https://dev.wisecp.com/en/whois-profiles The six endpoints that manage the registrant profiles a client uses on domain orders. ### Overview A WHOIS profile is the registrant data a client reuses across domain orders. A client can hold several profiles but only **one default**. The fields are `snake_case` and carry the same names as the domain WHOIS contact endpoints. Storage uses a different shape, but the API converts it, so that is not your concern. ### Reference #### Listing the Profiles get/api/v1/admin/clients/{id}/whois-profiles `Clients/GetClientWhoisProfiles` admin default first Returns every WHOIS profile the client has. The default one comes first. Response fields data[] — 6 idintProfile id. namestringProfile name. is_defaultboolWhether it is the client's default. contactobject 14 fieldsThe contact fields. first_namestringThe registrant's first name. last_namestringThe registrant's last name. companystringCompany name. emailstringE-mail address. phonestringPhone number without the country code. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or province. zipcodestringPostal code. countrystringCountry code. The numeric code, not the two-letter one: `840`. created_atstringWhen it was created. updated_atstringWhen it was last changed. Errors 2 not_found404No such client or profile. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/whois-profiles' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/whois-profiles', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/whois-profiles'); 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::Clients()->GetClientWhoisProfiles(['id' => 42]); // The default is first, but be ready for an empty list. $default = $response['data'][0] ?? null; ``` Response 200 ```json { "data": [ { "id": 13, "name": "Primary", "is_default": true, "contact": { "first_name": "John", "last_name": "Doe", "email": "john@example.com", "phone": "5550100", "phone_cc": "1", "address_line1": "123 Market Street", "city": "San Francisco", "state": "California", "zipcode": "94105", "country": "840" }, "created_at": "2026-06-21 18:00:00", "updated_at": "2026-06-21 18:05:00" } ] } ``` #### Creating a Profile post/api/v1/admin/clients/{id}/whois-profiles `Clients/CreateClientWhoisProfile` admin 201 Adds a new WHOIS profile to the client. Body 3 namestringrequiredThe profile name. contactobject 14 fieldsThe contact fields. first_namestringThe registrant's first name. last_namestringThe registrant's last name. companystringCompany name. emailstringE-mail address. phonestringPhone number without the country code. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or province. zipcodestringPostal code. countrystringCountry code. The numeric code, not the two-letter one: `840`. defaultboolMakes the profile the client's default. Response fields data — 6 dataobjectThe profile created. Same shape as the detail endpoint. Errors 4 not_found404No such client or profile. name_required422The profile name was empty. create_failed500The profile could not be created. 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/clients/42/whois-profiles' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "name": "Primary", "default": true, "contact": { "first_name": "John", "last_name": "Doe", "email": "john@example.com", "country": "840" } }' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/whois-profiles', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Primary', default: true, contact: { first_name: 'John', last_name: 'Doe', email: 'john@example.com', phone: '5550100', phone_cc: '1', address_line1: '123 Market Street', city: 'San Francisco', state: 'California', zipcode: '94105', country: '840', }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/whois-profiles'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Primary', 'default' => true, 'contact' => [ 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '5550100', 'phone_cc' => '1', 'address_line1' => '123 Market Street', 'city' => 'San Francisco', 'state' => 'California', 'zipcode' => '94105', 'country' => '840', ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientWhoisProfile([ 'id' => 42, 'name' => 'Primary', 'default' => true, 'contact' => [ 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'phone' => '5550100', 'phone_cc' => '1', 'address_line1' => '123 Market Street', 'city' => 'San Francisco', 'state' => 'California', 'zipcode' => '94105', 'country' => '840', ], ]); $profileId = $response['data']['id']; ``` Response 201 422 ```json { "data": { "id": 14, "name": "Primary", "is_default": true, "contact": { "first_name": "John", "last_name": "Doe", "country": "840" }, "created_at": "2026-06-21 18:00:00", "updated_at": "2026-06-21 18:00:00" } } ``` ```json { "error": { "code": "name_required", "message": "Profile name is required." } } ``` #### Profile Detail get/api/v1/admin/clients/{id}/whois-profiles/{pid} `Clients/GetClientWhoisProfile` admin Returns one profile. The schema is the same as a list item. Response fields data — 6 idintProfile id. namestringProfile name. is_defaultboolWhether it is the client's default. contactobject 14 fieldsThe contact fields. first_namestringThe registrant's first name. last_namestringThe registrant's last name. companystringCompany name. emailstringE-mail address. phonestringPhone number without the country code. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or province. zipcodestringPostal code. countrystringCountry code. The numeric code, not the two-letter one: `840`. created_atstringWhen it was created. updated_atstringWhen it was last changed. Errors 2 not_found404No such client or profile. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13'); 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::Clients()->GetClientWhoisProfile(['id' => 42, 'pid' => 13]); ``` #### Updating a Profile put/api/v1/admin/clients/{id}/whois-profiles/{pid} `Clients/UpdateClientWhoisProfile` admin contact is written whole Updates the profile. A top-level field you leave out stays as it was. Body 3 namestringThe profile name. Leave it out and the current name is kept. contactobject 14 fieldsThe contact fields. If you send it, the block is written whole, so a missing field is emptied. first_namestringThe registrant's first name. last_namestringThe registrant's last name. companystringCompany name. emailstringE-mail address. phonestringPhone number without the country code. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or province. zipcodestringPostal code. countrystringCountry code. The numeric code, not the two-letter one: `840`. defaultbool`true` makes it the default, `false` takes that away. Response fields data — 6 dataobjectThe profile as it now stands. Same shape as the detail endpoint. Errors 3 not_found404No such client or profile. name_required422The profile name you sent was empty. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Billing Contact"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Billing Contact' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['name' => 'Billing Contact']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To change one contact field, read the current block first. $current = Api::Clients()->GetClientWhoisProfile(['id' => 42, 'pid' => 13])['data']['contact']; $current['email'] = 'billing@example.com'; $response = Api::Clients()->UpdateClientWhoisProfile([ 'id' => 42, 'pid' => 13, 'contact' => $current, ]); ``` #### Setting the Default put/api/v1/admin/clients/{id}/whois-profiles/{pid}/default `Clients/SetClientWhoisProfileDefault` admin one default only Makes the profile the client's default. The previous default is cleared in the same operation. Body — ——No body is needed; send an empty one. Both the client and the profile come from the path. Response fields data — 2 idintId of the profile that became the default. defaultboolAlways comes back as `true`. Errors 2 not_found404No such client or profile. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13/default' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13/default', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13/default'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SetClientWhoisProfileDefault(['id' => 42, 'pid' => 13]); ``` #### Deleting a Profile delete/api/v1/admin/clients/{id}/whois-profiles/{pid} `Clients/DeleteClientWhoisProfile` admin Deletes the profile. Contact data already registered on domains is not affected. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted profile. Errors 2 not_found404No such client or profile. 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/clients/42/whois-profiles/13' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/whois-profiles/13'); 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::Clients()->DeleteClientWhoisProfile(['id' => 42, 'pid' => 13]); ``` ### Pitfalls > **The contact block is written whole** > > Sending `contact` on an update replaces the entire block, and the fields you left out are **emptied**. To change one field, read the current block, edit it, and send all of it back. This differs from the top-level fields: leave out `name` and it is kept. > **The country goes in as a numeric code** > > The `country` field wants the numeric code, not the two-letter one: `840` for the United States. Sending the abbreviation leaves the profile without a country and the domain registration can fail at the registrar. > **Moving the default is quiet** > > Making a profile the default clears the client's previous default in the same operation. There is no separate confirmation or warning, so read the list back to check the outcome. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Client Addresses](https://dev.wisecp.com/en/client-addresses) ## Sending Notifications https://dev.wisecp.com/en/sending-notifications The five endpoints that send a client a template, an e-mail or an SMS, and read the result back. ### Overview These endpoints send a message to a client and read back what was sent. There are three ways to send: a stored template, a free e-mail and a free SMS. Every send is **synchronous**. The response comes after the dispatch attempt finishes; nothing is queued. A slow provider slows your request down with it. ### Reference #### Previewing the Recipients get/api/v1/admin/clients/{id}/notifications/recipients `Clients/GetClientNotificationRecipients` admin sends nothing Returns who a template would reach, without sending anything. Query parameters 2 templatestringThe template id, in `group/name` form. channelstring`email` or `sms`. Give it and you get only that channel's recipients. Response fields data — 2 mailobject[]The e-mail recipients. Each element carries `email` and `name`. smsobject[]The SMS recipients. An empty array when the client has no phone. Errors 3 not_found404No such client. template_invalid422`template` is not in `group/name` form. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -G 'https://panel.example.com/api/v1/admin/clients/42/notifications/recipients' \ -H "Authorization: Bearer $API_KEY" \ -d template=user/welcome ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/42/notifications/recipients'); url.searchParams.set('template', 'user/welcome'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/42/notifications/recipients?' . http_build_query(['template' => 'user/welcome']); $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 // Check for recipients first: a template with none comes back as a 500. $preview = Api::Clients()->GetClientNotificationRecipients([ 'id' => 42, 'template' => 'user/welcome', ], ['channel' => 'email']); if (!($preview['data']['mail'] ?? [])) { return; } ``` Response 200 ```json { "data": { "mail": [ { "email": "john@example.com", "name": "John Doe" } ], "sms": [] } } ``` #### Sending a Template post/api/v1/admin/clients/{id}/notifications/template `Clients/SendClientTemplate` admin synchronous Sends a stored notification template to the client. Body 2 templatestringrequiredThe template id, in `group/name` form. Both halves have to be filled: `user/welcome`. channelstring`email` or `sms`. Defaults to `email`. Response fields data — 3 sentboolAlways `true`. A failure comes back as an error, not as a value in this field. channelstringThe channel that was used. templatestringId of the template that was sent. Errors 4 not_found404No such client. template_invalid422`template` is not in `group/name` form. send_failed500The dispatch 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/clients/42/notifications/template' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"template":"user/welcome","channel":"email"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notifications/template', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ template: 'user/welcome', channel: 'email' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notifications/template'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'template' => 'user/welcome', 'channel' => 'email', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SendClientTemplate([ 'id' => 42, 'template' => 'user/welcome', 'channel' => 'email', ]); ``` #### Sending a Custom E-mail post/api/v1/admin/clients/{id}/notifications/email `Clients/SendClientEmail` admin synchronous Sends an e-mail with a free subject and body, without going through a template. Body 3 subjectstringrequiredThe subject line. messagestringrequiredThe body. copy_to_adminboolCopies the sending admin. Off by default. Response fields data — 1 sentboolAlways `true`. Errors 5 not_found404No such client. subject_required422`subject` was empty. message_required422`message` was empty. send_failed500The dispatch failed **or there was no valid recipient**. 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/clients/42/notifications/email' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"subject":"An update about your account","message":"Hello, your request has been processed.","copy_to_admin":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notifications/email', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject: 'An update about your account', message: 'Hello, your request has been processed.', copy_to_admin: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notifications/email'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'subject' => 'An update about your account', 'message' => 'Hello, your request has been processed.', 'copy_to_admin' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SendClientEmail([ 'id' => 42, 'subject' => 'An update about your account', 'message' => 'Hello, your request has been processed.', ]); // 'send_failed' covers both a failed dispatch and no recipient at all. $failed = ($response['error']['code'] ?? '') === 'send_failed'; ``` #### Sending a Custom SMS post/api/v1/admin/clients/{id}/notifications/sms `Clients/SendClientSms` admin synchronous Sends the client an SMS with free content. Body 1 messagestringrequiredThe message body. Response fields data — 1 sentboolAlways `true`. Errors 4 not_found404No such client. message_required422`message` was empty. send_failed500The dispatch failed or there was no valid recipient. 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/clients/42/notifications/sms' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"message":"Hello, your request has been processed."}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/notifications/sms', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Hello, your request has been processed.' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/notifications/sms'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['message' => 'Hello, your request has been processed.']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SendClientSms([ 'id' => 42, 'message' => 'Hello, your request has been processed.', ]); ``` #### Reading a Sent Message get/api/v1/admin/clients/messages/preview `Clients/GetMessagePreview` admin decrypted Returns the content of an e-mail or SMS that was sent earlier. Query parameters 2 typestringrequired`email` ya da `sms`. idintrequiredThe log record id. Not the client id, the record's own id. Response fields data — 2 typestringThe record type. contentstringThe decrypted message content. Errors 2 invalid_request422`type` or `id` is missing or invalid. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -G 'https://panel.example.com/api/v1/admin/clients/messages/preview' \ -H "Authorization: Bearer $API_KEY" \ -d type=email \ -d id=901 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/messages/preview'); url.searchParams.set('type', 'email'); url.searchParams.set('id', '901'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/messages/preview?' . http_build_query(['type' => 'email', 'id' => 901]); $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::Clients()->GetMessagePreview([], [ 'type' => 'email', 'id' => 901, ]); $content = $response['data']['content']; ``` ### Pitfalls > **The sent field is always true** > > The `sent` field in the response is a constant, not an outcome. A failure arrives as an error body, not as this field turning `false`. Branch on the error code, not on the field. > **A send with no recipient returns a 500** > > If the client has no e-mail or phone the error is `send_failed`. That is not a server fault, it is the absence of a recipient. To tell them apart, call the preview endpoint first and skip the send when the list comes back empty. > **The request waits for the dispatch** > > Nothing is queued. If you are writing a batch job you pay the provider's response time for every client, so set your timeout accordingly. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Account State and Bulk Actions](https://dev.wisecp.com/en/account-state-and-bulk-actions) ## Sub-users https://dev.wisecp.com/en/sub-users The five endpoints that manage the limited-permission accounts reaching a client's panel. ### Overview A sub-user is a second person who reaches a client's panel with limited permissions: the accountant sees only invoices, the technical team only services. The permission set is **fixed** and holds ten values. The owner's own profile, billing contacts and e-mail history cannot be handed over; those stay with the account itself. ### Reference #### Listing the Sub-users get/api/v1/admin/clients/{id}/subusers `Clients/GetClientSubusers` admin Returns every sub-user the client has. Response fields data[] — 12 idintSub-user id. emailstringE-mail address. labelstring | nullA label. For a department or role name. statusstring`pending` is awaiting the invite, `active` is live, `inactive` is switched off. permissionsarray 10 permissionsThe permissions granted. view_servicespermissionSees the services. view_passwordspermissionSees service passwords. allow_ssopermissionSigns in to a service panel in one click. view_domainspermissionSees the domains. manage_domainspermissionManages the domains. view_invoicespermissionSees the invoices. view_ticketspermissionSees the support tickets. view_affiliatepermissionSees the affiliate page. view_resellerpermissionSees the reseller page. new_orderspermissionPlaces new orders. email_notificationsintE-mail notification preference. A bitmask. sms_notificationsintSMS notification preference. A bitmask. linked_user_idintId of the real client account it is linked to. `0` when there is none. linked_user_namestring | nullName of the linked account. invited_atstring | nullWhen the invite went out. accepted_atstring | nullWhen the invite was accepted. created_atstring | nullWhen the record was created. Errors 2 not_found404No such client or sub-user. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/subusers' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/subusers', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/subusers'); 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::Clients()->GetClientSubusers(['id' => 42]); ``` Response 200 ```json { "data": [ { "id": 4, "email": "accounting@example.com", "label": "Accounting", "status": "active", "permissions": ["view_invoices", "view_services"], "email_notifications": 0, "sms_notifications": 0, "linked_user_id": 31, "linked_user_name": "John Doe", "invited_at": "2026-06-01 10:00:00", "accepted_at": "2026-06-02 09:12:00", "created_at": "2026-06-01 10:00:00" } ] } ``` #### Adding a Sub-user post/api/v1/admin/clients/{id}/subusers `Clients/CreateClientSubuser` admin 201 Adds a sub-user to the client. The record starts out `pending`. Body 6 emailstringrequiredE-mail address. It has to differ from the owner's and not be in use already. labelstringA label. permissionsarray 10 permissionsThe permissions to grant. An unrecognised key is dropped without a word. view_servicespermissionSees the services. view_passwordspermissionSees service passwords. allow_ssopermissionSigns in to a service panel in one click. view_domainspermissionSees the domains. manage_domainspermissionManages the domains. view_invoicespermissionSees the invoices. view_ticketspermissionSees the support tickets. view_affiliatepermissionSees the affiliate page. view_resellerpermissionSees the reseller page. new_orderspermissionPlaces new orders. email_notificationsintE-mail notification preference. Defaults to 0. sms_notificationsintSMS notification preference. Defaults to 0. send_inviteboolSends the invite e-mail. On by default. Response fields 201 — data dataobjectThe sub-user created. Same shape as the listing endpoint. Errors 6 not_found404No such client or sub-user. email_invalid422The e-mail is not valid. email_is_owner422The e-mail is the owner's own. email_exists422A sub-user with this e-mail already exists. subuser_add_failed500The 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/clients/42/subusers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"email":"accounting@example.com","label":"Accounting","permissions":["view_invoices","view_services"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/subusers', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ email: 'accounting@example.com', label: 'Accounting', permissions: ['view_invoices', 'view_services'], send_invite: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/subusers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'email' => 'accounting@example.com', 'label' => 'Accounting', 'permissions' => ['view_invoices', 'view_services'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->CreateClientSubuser([ 'id' => 42, 'email' => 'accounting@example.com', 'permissions' => ['view_invoices', 'view_services'], ]); // Read the granted permissions back: an unrecognised key is dropped silently. $granted = $response['data']['permissions']; ``` #### Updating a Sub-user patch/api/v1/admin/clients/{id}/subusers/{subuser_id} `Clients/UpdateClientSubuser` admin can link an account Updates the fields you send and leaves the rest alone. Body 5 labelstringThe label. statusstringThe new status. An unrecognised value keeps the current one. permissionsarray 10 permissionsThe permission list. If you send it, the list is written whole. view_servicespermissionSees the services. view_passwordspermissionSees service passwords. allow_ssopermissionSigns in to a service panel in one click. view_domainspermissionSees the domains. manage_domainspermissionManages the domains. view_invoicespermissionSees the invoices. view_ticketspermissionSees the support tickets. view_affiliatepermissionSees the affiliate page. view_resellerpermissionSees the reseller page. new_orderspermissionPlaces new orders. email_notificationsintE-mail notification preference. sms_notificationsintSMS notification preference. Response fields data dataobjectThe sub-user as it now stands. Same shape as the listing endpoint. Errors 2 not_found404No such client or sub-user. 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/clients/42/subusers/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"active"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/subusers/5', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'active' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/subusers/5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 'active']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->UpdateClientSubuser([ 'id' => 42, 'subuser_id' => 5, 'status' => 'active', ]); // Going active for the first time links a real account when the e-mail matches. $linkedTo = $response['data']['linked_user_id']; ``` #### Resending the Invite post/api/v1/admin/clients/{id}/subusers/{subuser_id}/resend-invite `Clients/ResendClientSubuserInvite` admin pending only Sends the invite again to a sub-user still waiting for one. Body — ——No body is needed, send an empty one. The invite goes to the address already stored on the record and cannot be redirected here. Response fields data — 2 resentboolWhether the invite went out again. idintSub-user id. Errors 3 not_found404No such client or sub-user. not_pending422The sub-user is not awaiting an invite. A live or switched-off record cannot be invited. 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/clients/42/subusers/5/resend-invite' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/subusers/5/resend-invite', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/subusers/5/resend-invite'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->ResendClientSubuserInvite([ 'id' => 42, 'subuser_id' => 5, ]); ``` #### Deleting a Sub-user delete/api/v1/admin/clients/{id}/subusers/{subuser_id} `Clients/DeleteClientSubuser` admin Deletes the sub-user. The real client account it was linked to is not affected. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted sub-user. Errors 2 not_found404No such client or sub-user. 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/clients/42/subusers/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/subusers/5', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/subusers/5'); 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::Clients()->DeleteClientSubuser([ 'id' => 42, 'subuser_id' => 5, ]); ``` ### Pitfalls > **An unrecognised permission is dropped silently** > > The permission list is a closed set. Send a key that is not in it and you get no error; the key is thrown away and the record is stored with fewer permissions than you meant. Read the `permissions` field back from the write to see what was granted. > **Going active can link an account** > > The first time a sub-user is set to `active`, a matching e-mail on an existing client account **links that account**. That means someone can sign in as themselves and step into another client's panel, so know whose e-mail it is before changing status. > **The invite only goes to one still waiting** > > The resend endpoint only works on a record that is still `pending`. To invite a live sub-user you have to move the status back first. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Client Security Endpoints](https://dev.wisecp.com/en/client-security-endpoints) ## Reseller https://dev.wisecp.com/en/reseller-endpoints The five endpoints that manage a client's reseller status and the programme's installation-wide settings. ### Overview A reseller partnership is a client buying from you at a discount and selling on to their own customers. These endpoints run two separate layers that should not be confused. The first three look at **one client**: their reseller status, credit threshold and discount tiers. The last two run **the programme itself**: how applications are approved, whether verification is required, whether resellers get API access. Whether the path carries a client id tells you which layer you are on. ### Reference #### Reading the Reseller Status get/api/v1/admin/clients/{id}/reseller `Clients/GetClientReseller` admin two shapes Returns the client's reseller status and the settings that belong to them. Response fields data — 7 is_resellerboolWhether the partnership is live. With no reseller record at all, this is the only field in the response. statusstring`active` or `inactive`. activation_timedatetime | nullWhen it first went live. min_creditobject 2 fieldsThe credit needed to buy. amountfloatThe amount. currency_idintCurrency id. min_discountobject 2 fieldsThe balance needed for the discount to apply. amountfloatThe amount. currency_idintCurrency id. only_credit_paymentboolWhether paying by credit is the only option. discountsobject 3 fieldsA map from product group id to discount tiers. fromintLower bound of the tier. tointUpper bound of the tier. ratefloatThe discount rate for the tier. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/reseller' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/reseller', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); // With no reseller record the response holds a single field. if (!body.data.is_reseller) return; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/reseller'); 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::Clients()->GetClientReseller(['id' => 42]); // The other fields only arrive when a record exists - read them null-safe. $minCredit = $response['data']['min_credit']['amount'] ?? 0; ``` Response 200 200 ```json { "data": { "is_reseller": true, "status": "active", "activation_time": "2026-02-01 12:00:00", "min_credit": { "amount": 100.00, "currency_id": 840 }, "min_discount": { "amount": 50.00, "currency_id": 840 }, "only_credit_payment": false, "discounts": { "5": [{ "from": 1, "to": 10, "rate": 15.0 }] } } } ``` ```json { "data": { "is_reseller": false } } ``` #### Saving the Reseller Settings patch/api/v1/admin/clients/{id}/reseller `Clients/UpdateClientReseller` admin notifies on first activation Saves the reseller settings that belong to the client and switches the partnership on or off. Body 5 activeboolSwitches the partnership on or off. min_creditobject 2 fieldsThe minimum credit requirement. Only meaningful while the partnership is on. amountfloatThe amount. currency_idintCurrency id. min_discountobject 2 fieldsThe minimum balance for the discount. Only meaningful while the partnership is on. amountfloatThe amount. currency_idintCurrency id. only_credit_paymentboolLimits payment to credit. discountsobject 3 fieldsA map from product group id to discount tiers. fromintLower bound of the tier. tointUpper bound of the tier. ratefloatThe discount rate for the tier. Response fields data dataobjectThe reseller record after the save. Same shape as the read endpoint. Errors 2 not_found404No such client. 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/clients/42/reseller' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"active":true,"min_credit":{"amount":100,"currency_id":840},"only_credit_payment":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/reseller', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ active: true, min_credit: { amount: 100, currency_id: 840 }, only_credit_payment: true, discounts: { 5: [{ from: 1, to: 10, rate: 15 }], }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/reseller'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'active' => true, 'min_credit' => ['amount' => 100, 'currency_id' => 840], 'only_credit_payment' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // While the partnership is on, a setting you omit REMOVES that limit - read the current state first. $current = Api::Clients()->GetClientReseller(['id' => 42])['data']; $response = Api::Clients()->UpdateClientReseller([ 'id' => 42, 'active' => true, 'min_credit' => $current['min_credit'], 'min_discount' => $current['min_discount'], 'only_credit_payment' => true, 'discounts' => $current['discounts'], ]); ``` #### Changing the Reseller Status put/api/v1/admin/clients/{id}/reseller/status `Clients/SetClientResellerStatus` admin leaves settings alone Switches an existing reseller record on or off. The settings stay as they are. Body 1 actionstringrequired`activate` or `terminate`. Response fields data — 2 statusstringThe status afterwards. is_resellerboolWhether the partnership is live. Errors 3 not_found404No such client. action_invalid422`action` is neither of the two values. not_reseller422The client has no reseller record. Create one with the settings endpoint first. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/42/reseller/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"action":"activate"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/reseller/status', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'activate' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/reseller/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['action' => 'activate']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SetClientResellerStatus([ 'id' => 42, 'action' => 'activate', ]); ``` #### Reading the Programme Settings get/api/v1/admin/clients/reseller/config `Clients/GetResellerConfig` admin installation-wide Returns the installation-wide settings of the reseller programme. It looks at no single client. Response fields data — 10 enabledboolWhether the programme is on. activationstringHow applications are approved. The stored value is `manuel` or `auto`. allow_non_membersboolWhether non-members can see the reseller storefront. api_accessboolWhether resellers get API access. payment_restrictionboolWhether payment is limited to credit. verificationboolWhether applications must be verified. verification_methodsstring[]The verification methods required. min_creditobject 2 fieldsThe minimum credit amount. amountfloatThe amount. currency_idintCurrency id. credit_thresholdobject 2 fieldsThe credit threshold. amountfloatThe amount. currency_idintCurrency id. discount_ratesobject 3 fieldsA map from product group id to discount tiers. fromintLower bound of the tier. tointUpper bound of the tier. ratefloatThe discount rate for the tier. 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/clients/reseller/config' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/reseller/config', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/reseller/config'); 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::Clients()->GetResellerConfig(); ``` #### Saving the Programme Settings put/api/v1/admin/clients/reseller/config `Clients/SaveResellerConfig` admin installation-wide Saves the installation-wide settings of the reseller programme. Body 10 enabledboolTurns the programme on or off. activationstring`manual`, `automatic` or `auto`. An unrecognised value falls back to manual approval. allow_non_membersboolOpens the storefront to non-members. api_accessboolGives resellers API access. payment_restrictionboolLimits payment to credit. verificationboolMakes verification mandatory on application. verification_methodsstring[]The verification methods to require. min_creditobject 2 fieldsThe minimum credit amount. amountfloatThe amount. currency_idintCurrency id. credit_thresholdobject 2 fieldsThe credit threshold. amountfloatThe amount. currency_idintCurrency id. discount_ratesobject 3 fieldsA map from product group id to discount tiers. fromintLower bound of the tier. tointUpper bound of the tier. ratefloatThe discount rate for the tier. Response fields data dataobjectThe programme settings after the save. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/clients/reseller/config' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"activation":"manual","api_access":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/reseller/config', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, activation: 'manual', api_access: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/reseller/config'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'enabled' => true, 'activation' => 'manual', 'api_access' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Clients()->SaveResellerConfig([ 'enabled' => true, 'activation' => 'manual', 'api_access' => true, ]); ``` ### Pitfalls > **A setting you omit removes the limit** > > Saving settings while the partnership is on **drops** any limit you did not send; the old value is not kept. Forgetting the credit threshold while you only meant to change the payment restriction removes that threshold. Read the current settings, edit them, and send all of it back. > **The response shape depends on the record** > > With no reseller record the status endpoint returns only `is_reseller` and the other fields are **absent**. Read them null-safe rather than directly, or every non-reseller client will break your code. > **The status endpoint cannot create a partnership** > > The quick toggle only changes an existing record; on a client without one it returns `not_reseller`. Opening a new partnership is the settings endpoint's job. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Affiliate](https://dev.wisecp.com/en/affiliate-endpoints) ## Quick Reads https://dev.wisecp.com/en/quick-reads The four read-only endpoints that search clients, give the counts and fill the summary card. ### Overview These four endpoints tell you something quickly about a client and change nothing. The panel's search box, dashboard counters and summary card all read from them. Two look across the installation (search and statistics) and two at a single client (summary and the standout note). Whether the path carries a client id tells you which one you are on. ### Reference #### Searching for a Client get/api/v1/admin/clients/search `Clients/SearchClients` admin autocomplete Searches clients by name, company or e-mail. It was written for autocomplete boxes. Query parameters 1 qstringThe search term. The name `search` is accepted too. Response fields data[] — 4 idintClient id. full_namestringFirst and last name. company_namestringCompany name. textstringA ready-made display label with the name and company joined. 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/clients/search' \ -H "Authorization: Bearer $API_KEY" \ -d q=acme ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/search'); url.searchParams.set('q', 'acme'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/search?' . http_build_query(['q' => 'acme']); $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::Clients()->SearchClients([], ['q' => 'acme']); ``` #### Client Statistics get/api/v1/admin/clients/stats `Clients/GetClientsStats` admin all clients Returns the counts across every client in the installation. It looks at no single client. Query parameters 1 periodstringThe period: `all_time`, `today`, `week`, `month` or `year`. Defaults to `all_time`. Response fields data — 9 activeintHow many clients are active. blockedintHow many clients are blocked. newintHow many clients arrived in the period. blacklistedintHow many clients are blacklisted. active_servicesintHow many services are live. unpaid_invoicesintHow many invoices are unpaid. credit_balancefloatThe total credit balance. A raw number; the currency symbol is yours to add. growth_ratefloatThe growth rate as a percentage. totalintThe total client count in the period. 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/clients/stats' \ -H "Authorization: Bearer $API_KEY" \ -d period=month ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/clients/stats'); url.searchParams.set('period', 'month'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/clients/stats?' . http_build_query(['period' => 'month']); $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::Clients()->GetClientsStats([], ['period' => 'month']); ``` Response 200 ```json { "data": { "active": 120, "blocked": 3, "new": 18, "blacklisted": 2, "active_services": 340, "unpaid_invoices": 41, "credit_balance": 14620.12, "growth_rate": 4.5, "total": 145 } } ``` #### Client Summary get/api/v1/admin/clients/{id}/summary `Clients/GetClientSummary` admin badges and trust score Returns summary card data for one client: revenue, service and ticket counts, badges, trust score. Response fields data — 13 user_idintClient id. full_namestringFirst and last name. company_namestringCompany name. created_atstringWhen they joined. A raw value; formatting is yours. total_revenuefloatWhat they have paid in total so far. revenue_currencyintCurrency id of the amount. List: `reference/currencies`. paid_invoicesintHow many invoices were paid. active_servicesintHow many services are live. inactive_servicesintHow many services are not live. total_ticketsintHow many tickets in total. recent_ticketsintHow many tickets recently. badgesobject 4 fieldsWhich badges were earned. loyalboolThe membership age threshold was passed. revenueboolThe revenue threshold was passed. multi_serviceboolThey hold more than one live service. experiencedboolThe past interaction threshold was passed. trust_scoreobject 6 fieldsThe trust score and its parts. totalintThe total score. Between 0 and 100. labelstring`poor`, `fair`, `good` ya da `excellent`. servicesintPoints from the service axis. revenueintPoints from the revenue axis. ageintPoints from the account age axis. ticketsintPoints from the support ticket axis. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/summary' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/summary', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/summary'); 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::Clients()->GetClientSummary(['id' => 42]); // The parts tell you where the score came from. $score = $response['data']['trust_score']; $fromServices = $score['services']; ``` Response 200 ```json { "data": { "user_id": 9, "full_name": "Test Client", "company_name": "", "created_at": "2021-03-18 00:00:00", "total_revenue": 36, "revenue_currency": 4, "paid_invoices": 1, "active_services": 16, "inactive_services": 16, "total_tickets": 0, "recent_tickets": 0, "badges": { "loyal": true, "revenue": false, "multi_service": true, "experienced": true }, "trust_score": { "total": 50, "label": "fair", "services": 30, "revenue": 5, "age": 15, "tickets": 0 } } } ``` #### Reading the Standout Note get/api/v1/admin/clients/{id}/latest-note `Clients/GetClientLatestNote` admin pinned first Returns one of the client's notes: the pinned one if there is one, otherwise the newest. Response fields data — 6 idintNote id. contentstringThe note content. pinnedboolWhether the note is pinned. added_byintId of the admin who added it. added_by_namestringName of the admin who added it. created_atstringWhen the note was added. Errors 2 not_found404No such client. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/clients/42/latest-note' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/clients/42/latest-note', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); // With no notes at all, data comes back null - not a 404. if (body.data === null) return; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/clients/42/latest-note'); 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::Clients()->GetClientLatestNote(['id' => 42]); // On a client with no notes, 'data' is null. $note = $response['data'] ?? null; ``` ### Pitfalls > **Amounts arrive raw, not formatted** > > The credit balance and revenue fields are numbers without a symbol, and the date is raw too. You read the currency from its own field and do the formatting yourself. That is deliberate: the server does not know the reader's locale. > **A client with no notes returns null** > > On a client with no notes the standout-note endpoint returns `data: null`, not a `404`. Code that reaches straight into the fields breaks here, so check for empty first. > **The trust score depends on your settings** > > The score in the summary is not a fixed scale; it is worked out from the tiers in your configuration. Change the tiers and the same client scores differently, so do not compare the score across installations. ### Related Articles - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Client Configuration](https://dev.wisecp.com/en/client-configuration) - [Client Internal Notes](https://dev.wisecp.com/en/client-internal-notes) # API / Admin API / Finance ## Discount Coupons https://dev.wisecp.com/en/discount-coupons The seven endpoints that define discount coupons, copy them and set their conditions. ### Overview Coupons are the **discount codes** a client uses in the basket or on an invoice. A coupon takes off either a share or a fixed amount. The type field names which of the two, and the matching field is the one to fill. When a coupon is valid rests on **three things**: its stored status, its date range and its use limit. The listing folds the three together and returns the real state as well, and that is what a client meets. The conditions cover the rest: which products, which cycles, which kind of client and the smallest basket it works on. The list of product values comes from an **endpoint of its own**. ### Reference #### Listing the Coupons get/api/v1/admin/financial/coupons `Financial/GetCoupons` admin Returns the discount coupons with the state they are really in. Query 4 pageintWhich page. limitintRecords per page. Clamped between one and a hundred. searchstringSearches the code and the notes. statusstringFilters by the real state: live, off, not yet started, expired or used up. Response fields data[] — 15 + meta — 4 idintThe coupon id. codestringThe code a client types in. statusstringThe stored status: live or off. effective_statusstringThe state it is really in. Worked out with the dates and the use limit taken in. typestringThe discount type. ratefloatThe share taken off. amountfloatThe fixed amount taken off. currency_idintThe currency of the fixed amount. auto_applyboolWhether it applies itself. max_usesintThe use limit. usesintHow often it was used. start_datestring | nullWhen it becomes valid. due_datestring | nullWhen it stops being valid. created_atstring | nullWhen it was created. totalintHow many match the filter. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/financial/coupons?status=active' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/financial/coupons'); url.searchParams.set('status', 'active'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons?' . http_build_query(['status' => 'active'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There are two status fields: the stored one and the REAL one. Clients meet the second. $rows = Api::Financial()->GetCoupons()['data']; $live = array_filter($rows, fn ($c) => $c['effective_status'] === 'active'); ``` #### Creating a Coupon post/api/v1/admin/financial/coupons `Financial/CreateCoupon` admin born live Defines a new discount coupon. Body 26 codestringreqThe code a client types in. It has to be unique on the installation. typestringThe discount type: `percentage` or `amount`. A share by default. ratefloatThe share taken off. amountfloatThe fixed amount taken off. currency_idintThe currency of the fixed amount. product_servicesstring[]The products, categories, domain endings and add-ons the coupon applies to. required_productsstring[]What the basket has to hold for the coupon to work. validity_cyclesobjectThe billing cycles the coupon applies to. required_product_cyclesobjectThe cycles the required products have to carry. min_amountfloatThe smallest basket the coupon works on. min_amount_currency_idintThe currency that smallest basket is in. max_usesintHow many times it can be used. Zero means without limit. recurringboolWhether the discount carries on into renewals. recurring_numintHow many renewals it carries on. auto_applyboolApplies itself to the basket. apply_onceboolApplies once per client. onetime_use_per_orderboolApplies once per order. tax_freeboolCounts the discount as free of tax. new_signups_onlyboolOpens it to new sign-ups alone. existing_customers_onlyboolOpens it to existing clients alone. dealership_onlyboolOpens it to resellers alone. allow_mergeboolLets it stack with other coupons. used_in_invoicesboolLets it be used on invoices too. start_datestringWhen it becomes valid. due_datestringWhen it stops being valid. Left empty, it never expires. notesstringA note on the coupon. Response fields 201 — data dataobjectThe coupon created. Same shape as the detail endpoint. Errors 4 code_required422The coupon code is empty. coupon_save_failed422The code is taken, or the rate is not valid. blocked_by_gate422A hook refused the save. 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/financial/coupons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"code":"HOSGELDIN30","type":"percentage","rate":30,"max_uses":50}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/coupons', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ code: 'WELCOME30', type: 'percentage', rate: 30, max_uses: 50, due_date: '2026-12-31', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'code' => 'WELCOME30', 'type' => 'percentage', 'rate' => 30, 'max_uses' => 50, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A coupon is born LIVE, and with no end date it NEVER EXPIRES; weigh the two together. Api::Financial()->CreateCoupon([ 'code' => 'WELCOME30', 'rate' => 30, 'max_uses' => 50, 'due_date' => '2026-12-31', ]); ``` #### Reading the Product Tree get/api/v1/admin/financial/coupons/products-hierarchy `Financial/GetCouponProductsHierarchy` admin Returns the products and categories a coupon can be tied to. Response fields data dataarrayA flat list of the products, categories, domain endings and add-ons you can pick. The values for the coupon fields come from here. 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/financial/coupons/products-hierarchy' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/coupons/products-hierarchy', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons/products-hierarchy'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Do not write the values into your code: the list follows the installation's own products. $options = Api::Financial()->GetCouponProductsHierarchy()['data']; ``` #### Reading One Coupon get/api/v1/admin/financial/coupons/{id} `Financial/GetCoupon` admin Returns one coupon with all of its conditions. Response fields data — 29 idintThe coupon id. codestringThe code a client types in. statusstringThe stored status. typestringThe discount type. ratefloatThe share taken off. amountfloatThe fixed amount taken off. currency_idintThe currency of the fixed amount. product_servicesstring[]The products the coupon applies to. validity_cyclesobjectThe billing cycles it applies to. required_productsstring[]What the basket has to hold. required_product_cyclesobjectThe cycles those have to carry. min_amountfloatThe smallest basket. min_amount_currency_idintThe currency that is in. auto_applyboolWhether it applies itself. max_usesintThe use limit. usesintHow often it was used. recurringboolWhether it carries on into renewals. recurring_numintHow many renewals it lasts. apply_onceboolWhether it applies once per client. onetime_use_per_orderboolWhether it applies once per order. tax_freeboolWhether the discount counts as free of tax. new_signups_onlyboolWhether it is open to new sign-ups alone. existing_customers_onlyboolWhether it is open to existing clients alone. dealership_onlyboolWhether it is open to resellers alone. allow_mergeboolWhether it stacks with other coupons. used_in_invoicesboolWhether it can be used on invoices. notesstringThe note on the coupon. start_datestring | nullWhen it becomes valid. due_datestring | nullWhen it stops being valid. created_atstring | nullWhen it was created. Errors 2 not_found404No such coupon. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/financial/coupons/19' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/coupons/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The detail carries NO real-state field; only the list works that out. $coupon = Api::Financial()->GetCoupon(['id' => $id])['data']; ``` #### Updating a Coupon patch/api/v1/admin/financial/coupons/{id} `Financial/UpdateCoupon` admin Changes the coupon fields you send. Body 26 codestringThe code a client types in. typestringThe discount type: `percentage` or `amount`. A share by default. ratefloatThe share taken off. amountfloatThe fixed amount taken off. currency_idintThe currency of the fixed amount. product_servicesstring[]The products, categories, domain endings and add-ons the coupon applies to. required_productsstring[]What the basket has to hold for the coupon to work. validity_cyclesobjectThe billing cycles the coupon applies to. required_product_cyclesobjectThe cycles the required products have to carry. min_amountfloatThe smallest basket the coupon works on. min_amount_currency_idintThe currency that smallest basket is in. max_usesintHow many times it can be used. Zero means without limit. recurringboolWhether the discount carries on into renewals. recurring_numintHow many renewals it carries on. auto_applyboolApplies itself to the basket. apply_onceboolApplies once per client. onetime_use_per_orderboolApplies once per order. tax_freeboolCounts the discount as free of tax. new_signups_onlyboolOpens it to new sign-ups alone. existing_customers_onlyboolOpens it to existing clients alone. dealership_onlyboolOpens it to resellers alone. allow_mergeboolLets it stack with other coupons. used_in_invoicesboolLets it be used on invoices too. start_datestringWhen it becomes valid. due_datestringWhen it stops being valid. Left empty, it never expires. notesstringA note on the coupon. Response fields data dataobjectThe coupon as it now stands. Same shape as the detail endpoint. Errors 4 not_found404No such coupon. coupon_save_failed422The code is taken, or the rate is not valid. blocked_by_gate422A hook refused the save. 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/financial/coupons/19' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rate":25,"due_date":"2027-01-31"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/coupons/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rate: 25, due_date: '2027-01-31', auto_apply: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['rate' => 25, 'due_date' => '2027-01-31']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing the rate does not reach PAST orders; it touches later uses alone. Api::Financial()->UpdateCoupon(['id' => $id, 'rate' => 25]); ``` #### Deleting a Coupon delete/api/v1/admin/financial/coupons/{id} `Financial/DeleteCoupon` admin Removes a coupon. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the coupon removed. Errors 2 not_found404No such coupon. 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/financial/coupons/19' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/coupons/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons/' . $id); 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 // Switch it off rather than delete: the code stays taken and its history survives. Api::Financial()->UpdateCoupon(['id' => $id, 'status' => 'inactive']); ``` #### Copying a Coupon post/api/v1/admin/financial/coupons/{id}/duplicate `Financial/DuplicateCoupon` admin the copy is born off Opens a new coupon carrying an existing one's settings. Body — ——No body is needed. The coupon comes from the path; send an empty body. Response fields 201 — data dataobjectThe copy created. Its code gains a copy suffix, its status is off, its use count is cleared and its start date is dropped. Errors 2 not_found404No such coupon. 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/financial/coupons/19/duplicate' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/coupons/${id}/duplicate`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); console.log(data.code); // WELCOME30-COPY ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/coupons/' . $id . '/duplicate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The copy is born OFF: fix its code and dates, then switch it on yourself. $copy = Api::Financial()->DuplicateCoupon(['id' => $id])['data']; Api::Financial()->UpdateCoupon([ 'id' => $copy['id'], 'code' => 'SUMMER30', 'status' => 'active', ]); ``` ### Pitfalls > **There are two status fields** > > The stored status is the switch an operator flips. The **real state** is worked out with the date range and the use limit. A coupon that reads live may not work because it expired or was used up. The second field is what tells you what a client will meet, and it comes back on the listing alone. > **With no end date a coupon never expires** > > A coupon left without an end date **never stops**. A code opened for a campaign and forgotten still takes money off months later. With the use limit at zero as well it is both endless and unlimited. Fill in at least one of the two on a campaign code. > **A copy is born off and under a new code** > > The duplicate endpoint opens the new coupon **switched off**. It also adds a copy suffix to the code, clears the use count and drops the start date. The copy is not ready to use: fix its code and switch it on. Duplicating the same coupon twice numbers the suffix upward. > **A change does not reach the past** > > Changing a coupon's rate or its conditions touches **later uses**. Orders and invoices already cut with it keep the old rate. That is right, because those documents tell the story of a moment. Correcting a mistake means going to the invoice itself as well. > **The product values belong to the installation** > > The values naming which products a coupon applies to come from a **separate endpoint** and follow the installation's own product tree. Writing them into your code leaves a coupon quietly matching nothing on another installation, or once the products change. Read the list each time. ### Related Articles - [Currencies and Rates](https://dev.wisecp.com/en/currencies-and-rates) - [Taxation Rules](https://dev.wisecp.com/en/taxation-rules) - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) ## Currencies and Rates https://dev.wisecp.com/en/currencies-and-rates The eight endpoints for the currencies, their rates and the rate provider. ### Overview An installation has one **base currency**, and the others carry their rate against it. Prices, invoices and reports all convert through that point. Rates are either written by hand or come from a **provider module**. You pick the provider and set how often it runs, and a scheduled task refreshes the rates. An endpoint for pulling them by hand sits alongside. A currency can be **tied to countries**: a visitor's country decides which one they see. A country belongs to one currency at a time. ### Reference #### Listing the Currencies get/api/v1/admin/financial/currencies `Financial/GetCurrencies` admin Returns every currency on the installation with its rate. Response fields data[] — 13 idintThe currency id. codestringIts international code. namestringThe currency name. statusstringWhether it is live or off. localboolWhether it is the installation's own. Every conversion runs through it. hiddenboolWhether clients get to see it. ratefloatIts rate against the installation's own. formatintHow the number gets formatted. prefixstringThe mark that goes before the amount. suffixstringThe mark that goes after the amount. countrystringIts default country. countriesstring[]The countries tied to it. A visitor's country picks it. modulesstring[]The payment methods working in it. 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/financial/currencies' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/currencies', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const base = data.find((c) => c.local); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currencies'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Rates run against the BASE currency; converting between two others passes through it. $all = Api::Financial()->GetCurrencies()['data']; $base = current(array_filter($all, fn ($c) => $c['local'])); ``` #### Reading the Rate Settings get/api/v1/admin/financial/currency-settings `Financial/GetCurrencySettings` admin Returns which provider supplies the rates and how often they refresh. Response fields data — 5 modulestringThe module supplying the rates. auto_rate_enabledboolWhether the rates refresh by themselves. update_periodstringHow often they refresh: hourly or daily. last_run_atstring | nullWhen they last refreshed. available_modulesstring[]The rate providers installed. 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/financial/currency-settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/currency-settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currency-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An OLD last-run time means stale rates; the refresh may have quietly stopped. $s = Api::Financial()->GetCurrencySettings()['data']; ``` #### Writing the Rate Settings put/api/v1/admin/financial/currency-settings `Financial/UpdateCurrencySettings` admin Changes the rate provider and how the refresh runs. Body 4 modulestringThe rate provider to use. auto_rate_enabledboolLets the rates refresh by themselves. update_periodstringHow often they refresh. Daily by default. module_dataobjectThe provider's own settings. Response fields data — 5 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 2 module_not_found422No such provider. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/financial/currency-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"module":"WAtlas","auto_rate_enabled":true,"update_period":"day"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/currency-settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ module: 'WAtlas', auto_rate_enabled: true, update_period: 'day', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currency-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'module' => 'WAtlas', 'auto_rate_enabled' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Put a provider through the test endpoint BEFORE switching; a bad key gives quiet staleness. Api::Financial()->TestCurrencyModule(['module' => 'WAtlas']); Api::Financial()->UpdateCurrencySettings(['module' => 'WAtlas']); ``` #### Testing the Provider post/api/v1/admin/financial/currency-modules/test `Financial/TestCurrencyModule` admin it saves nothing Tests a rate provider by pulling a sample rate from it. Body 2 modulestringreqThe provider to test. module_dataobjectSettings to test with. They are not saved and serve this call alone. Response fields data — 2 localstringThe base currency code. ratesobjectThe sample rates the provider returned. Errors 6 module_required422No provider name was given. module_not_found422No such provider. not_supported422The provider supplies no rates. no_local422No base currency is set. test_failed422The provider returned no rate at all. 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/financial/currency-modules/test' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"module":"WAtlas"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/currency-modules/test', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ module: 'WAtlas' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currency-modules/test'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['module' => 'WAtlas']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The test connects LIVE and saves nothing; a provider not yet chosen can be tried too. $r = Api::Financial()->TestCurrencyModule([ 'module' => 'WAtlas', 'module_data' => ['WAtlas' => ['api_key' => $key]], ])['data']; ``` #### Refreshing the Rates post/api/v1/admin/financial/currencies/sync `Financial/SyncCurrencyRates` admin it runs on the spot Pulls the rates from the provider by hand. Body — ——No body is needed. The provider and the period come from the currency settings, not from the call, so send an empty body. Response fields data — 3 rates_changedboolWhether the rates moved. reasonstringWhy nothing moved. It comes back only when the refresh was skipped. last_run_atstring | nullWhen the refresh happened. Errors 2 sync_failed422The rates could not be pulled. 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/financial/currencies/sync' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/currencies/sync', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); if (! data.rates_changed) console.warn(data.reason); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currencies/sync'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A successful call does not mean the rates MOVED; read the reason field. $r = Api::Financial()->SyncCurrencyRates()['data']; if (! $r['rates_changed']) error_log($r['reason'] ?? 'skipped'); ``` #### Reading One Currency get/api/v1/admin/financial/currencies/{id} `Financial/GetCurrency` admin Returns a single currency. Response fields data — 13 idintThe currency id. codestringIts international code. namestringThe currency name. statusstringWhether it is live or off. localboolWhether it is the installation's own. Every conversion runs through it. hiddenboolWhether clients get to see it. ratefloatIts rate against the installation's own. formatintHow the number gets formatted. prefixstringThe mark that goes before the amount. suffixstringThe mark that goes after the amount. countrystringIts default country. countriesstring[]The countries tied to it. A visitor's country picks it. modulesstring[]The payment methods working in it. Errors 2 not_found404No such currency. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/financial/currencies/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/currencies/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currencies/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The base currency's rate is ALWAYS one; trying to change it means nothing. $c = Api::Financial()->GetCurrency(['id' => $id])['data']; ``` #### Updating a Currency patch/api/v1/admin/financial/currencies/{id} `Financial/UpdateCurrency` admin it can move the base Changes a currency's look, its rate and what it is tied to. Body 9 namestringThe currency name. prefixstringThe mark that goes before the amount. suffixstringThe mark that goes after the amount. formatintHow the number gets formatted. ratefloatIts rate against the base. With the refresh on, the next run writes over it. hiddenboolKeeps it from clients. countriesstring[]The countries to tie to it. A country can belong to one currency alone. modulesstring[]The payment methods that work in it. localboolMakes this the installation's base currency. The point every conversion rests on moves. Response fields data — 13 dataobjectThe currency as it now stands. Same shape as the detail endpoint. Errors 4 not_found404No such currency. country_conflict422The country belongs to another currency. currency_save_failed422The record could not be written. 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/financial/currencies/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"US Dollar","prefix":"$","hidden":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/currencies/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'US Dollar', prefix: '$', hidden: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currencies/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['name' => 'US Dollar', 'prefix' => '$']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Do not read the base field as ONE MORE switch: it moves the installation's money base // and every rate is worked out again. Api::Financial()->UpdateCurrency(['id' => $id, 'prefix' => '$']); ``` #### Switching a Currency On and Off put/api/v1/admin/financial/currencies/{id}/status `Financial/SetCurrencyStatus` admin Decides whether a currency can be used at all. Body 1 statusboolreqSwitches the currency on or off. Response fields data — 13 dataobjectThe currency as it now stands. Same shape as the detail endpoint. Errors 4 not_found404No such currency. local_currency422The base currency cannot be switched off. blocked_by_gate422A hook refused the change. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/financial/currencies/4/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/financial/currencies/${id}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/currencies/' . $id . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Switching off leaves PAST invoices in that currency alone; it stops new choices. Api::Financial()->SetCurrencyStatus(['id' => $id, 'status' => false]); ``` ### Pitfalls > **Moving the base currency shifts everything** > > The base-currency field in the update body is no ordinary option. It **moves the installation's money base**, writes to the settings file and has every rate worked out again. Prices keep the same numbers while now meaning a different unit. Sending this field by accident is among the costliest mistakes here. > **A rate written by hand goes at the next refresh** > > With the refresh on, writing a rate by hand is a **passing** correction: the provider writes over it on the next run. For a rate that holds, switch the refresh off first. This is why a rate seems to "come back" after being set by hand. > **A successful refresh does not mean the rates moved** > > The refresh endpoint counts as **successful** even when it returns without pulling anything, and names the reason in a separate field. The provider may not have answered, the interval may not have passed, or the setting may be off. Monitoring that reads only the response code will not notice rates gone stale for months. > **A country belongs to one currency** > > Tying a country to a currency while it sits on another gets the call **refused**. The country list replaces what was there, so a country left out drops its tie. When changing them, read the current list and add to it. > **Test a provider before choosing it** > > The test endpoint **opens a live connection and saves nothing**. A provider not yet chosen can be tried with settings passed in. Switching provider without testing leaves the rates quietly unrefreshed. No error shows, and only the last-run time gives it away by standing still. ### Related Articles - [Taxation Rules](https://dev.wisecp.com/en/taxation-rules) - [Discount Coupons](https://dev.wisecp.com/en/discount-coupons) - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) ## Taxation Rules https://dev.wisecp.com/en/taxation-rules The seven endpoints for tax rates, country rules and the invoice document settings. ### Overview Tax is built in three layers. At the bottom sit the **basic settings**. They say whether tax is on, what the default rate is, and where the tax sits. Above them stand the **country and state rules**. When a rule matches a client's address the rate comes from there. A state rule comes before a country rule, and a country rule before the default. A separate group covers the **invoice document itself**. It shapes the numbering, opens or closes formalising, names the tax fields a company client is asked for, and handles posting a printed bill. ### Reference #### Reading the Tax Settings get/api/v1/admin/financial/taxation `Financial/GetTaxation` admin Returns whether tax is on, its rate and how it is worked out. Response fields data — 4 enabledboolWhether tax is on. ratefloatThe default tax rate. taxation_typestringWhether tax sits inside the amount or on top of it. send_bill_to_addressobjectPosting a printed bill: whether it is on, its charge and its currency. 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/financial/taxation' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/taxation', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/taxation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The rate here is the DEFAULT; a rule set for the client's country comes first. $t = Api::Financial()->GetTaxation()['data']; ``` #### Writing the Tax Settings put/api/v1/admin/financial/taxation `Financial/UpdateTaxation` admin Switches tax on and writes its rate and how it is worked out. Body 3 enabledboolSwitches tax on or off. ratefloatThe default tax rate. taxation_typestringWhether tax sits inside the amount or on top of it. Response fields data — 4 dataobjectThe tax settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/financial/taxation' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"rate":20,"taxation_type":"exclusive"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/taxation', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, rate: 20, taxation_type: 'exclusive', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/taxation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => true, 'rate' => 20]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing how tax is worked out turns the SAME price into a different total; think again // about what the prices you advertise are meant to say. Api::Financial()->UpdateTaxation(['taxation_type' => 'inclusive']); ``` #### Reading the Advanced Settings get/api/v1/admin/financial/taxation/advanced `Financial/GetTaxationAdvanced` admin Returns the settings for invoice numbering and the company tax fields. Response fields data — 18 invoice_show_requires_loginboolWhether seeing an invoice needs signing in. payment_commission_taxboolWhether the payment commission and the instalment surcharge carry the invoice's rate. delete_invoice_item_aocboolWhether an invoice line can be removed. invoice_formalization_statusboolWhether formalising is available. firstly_create_invoiceboolWhether the invoice is cut before the order. balance_taxationstringHow topping up the balance gets taxed. invoice_special_notestringA note put on every invoice. pdf_fontstringThe typeface used in the document. invoice_number_formatstringThe invoice number's shape. It has to carry the placeholder marking where the number goes. invoice_number_format_statusboolWhether that shape is used. paid_invoice_number_formatstringThe paid-invoice number's shape. paid_invoice_number_format_statusboolWhether that shape is used. invoice_incrementintThe number invoice numbering starts from. paid_invoice_incrementintThe number paid-invoice numbering starts from. send_bill_to_addressobjectPosting a printed bill: whether it is on, its charge and its currency. company_tax_officeobjectThe tax office field: whether it shows and whether it is required. company_tax_numberobjectThe tax number field: whether it shows, whether it is required and whether it is checked. 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/financial/taxation/advanced' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/taxation/advanced', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/taxation/advanced'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There are two numbering series: one for invoices and one for PAID invoices. $a = Api::Financial()->GetTaxationAdvanced()['data']; ``` #### Writing the Advanced Settings put/api/v1/admin/financial/taxation/advanced `Financial/UpdateTaxationAdvanced` admin Changes the advanced tax and invoice settings you send. Body 18 invoice_show_requires_loginboolWhether seeing an invoice needs signing in. payment_commission_taxboolWhether the payment commission and the instalment surcharge carry the invoice's rate. delete_invoice_item_aocboolWhether an invoice line can be removed. invoice_formalization_statusboolWhether formalising is available. firstly_create_invoiceboolWhether the invoice is cut before the order. balance_taxationstringHow topping up the balance gets taxed. invoice_special_notestringA note put on every invoice. pdf_fontstringThe typeface used in the document. invoice_number_formatstringThe invoice number's shape. It has to carry the placeholder marking where the number goes. invoice_number_format_statusboolWhether that shape is used. paid_invoice_number_formatstringThe paid-invoice number's shape. paid_invoice_number_format_statusboolWhether that shape is used. invoice_incrementintThe number invoice numbering starts from. paid_invoice_incrementintThe number paid-invoice numbering starts from. send_bill_to_addressobjectPosting a printed bill: whether it is on, its charge and its currency. company_tax_officeobjectThe tax office field: whether it shows and whether it is required. company_tax_numberobjectThe tax number field: whether it shows, whether it is required and whether it is checked. Response fields data — 18 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 2 invalid_number_format422The number format carries no placeholder. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/financial/taxation/advanced' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"invoice_number_format":"FTR-{NUMBER}","invoice_number_format_status":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/taxation/advanced', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ invoice_number_format: 'INV-{NUMBER}', invoice_number_format_status: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/taxation/advanced'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'invoice_number_format' => 'INV-{NUMBER}', 'invoice_number_format_status' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Lowering the numbering START can hand the same number out twice; do not drop below // the highest number already given. Api::Financial()->UpdateTaxationAdvanced(['invoice_increment' => 1000]); ``` #### Listing the Tax Rules get/api/v1/admin/financial/tax-rules `Financial/GetTaxRules` admin Returns the tax rates set by country and state. Response fields data[] — 7 country_idintThe country id. country_namestringThe country name. ccstringThe two-letter country code. state_idintThe state id. Zero says the rule covers the whole country. state_namestringThe state name. tax_ratefloatThe total rate. The parts added together. ratesobject[]The parts making up the rate: each with its name and value. 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/financial/tax-rules' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/tax-rules', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/tax-rules'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A state rule comes before the COUNTRY rule; with neither, the default rate applies. $rules = Api::Financial()->GetTaxRules()['data']; ``` #### Writing a Tax Rule put/api/v1/admin/financial/tax-rules `Financial/UpdateTaxRule` admin Writes the tax rate for a country or a state. Body 4 country_idintreqThe country the rule covers. state_idintThe state the rule covers. Zero means the whole country. state_namestringThe name of a new state. Given, the state is created first. ratesobject[]The parts making up the rate: each with its name and value. Response fields data — 3 country_idintThe rule's country. state_idintThe rule's state. A newly created state comes back with its id here. ratefloatThe total rate worked out. Errors 5 invalid_country422The country is not valid. state_insert_failed422The new state could not be created. tax_rule_failed422The rule could not be written. blocked_by_gate422A hook refused the save. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/financial/tax-rules' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"country_id":792,"state_id":0,"rates":[{"name":"KDV","value":20}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/tax-rules', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ country_id: 840, state_id: 0, rates: [{ name: 'VAT', value: 20 }], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/tax-rules'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'country_id' => 840, 'rates' => [['name' => 'VAT', 'value' => 20]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The parts REPLACE what was there: your list clears the old one rather than adding. Api::Financial()->UpdateTaxRule([ 'country_id' => 840, 'rates' => [['name' => 'VAT', 'value' => 20]], ]); ``` #### Defining the European Rates post/api/v1/admin/financial/tax-rules/define-all `Financial/DefineAllTaxRates` admin it writes over existing rules Defines the value-added tax rates of the European Union countries at once. Body — ——No body is needed. The rate set is built in rather than sent, so there is nothing to pick; send an empty body. Response fields data — 1 definedintHow many rates were defined. Errors 1 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/financial/tax-rules/define-all' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/financial/tax-rules/define-all', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); console.log(data.defined); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/financial/tax-rules/define-all'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // If the installation's own country is on the list the DEFAULT rate moves; keep the old // rules first. $before = Api::Financial()->GetTaxRules()['data']; Api::Financial()->DefineAllTaxRates(); ``` ### Pitfalls > **A rate can come from three places** > > The rate on an invoice comes first from the client's **state rule**. Failing that it comes from the **country rule**, and only then from the default. Changing the default moves nothing for clients in countries that carry a rule. That is usually behind a "the rate update did nothing" report. > **The rule parts replace what was there** > > When writing a tax rule the list of parts you send **replaces** what was there rather than adding to it. To add a second part to a country, read the current list and send both together. Sending one part quietly drops the others. > **The bulk define writes over existing rules** > > Defining the European rates in bulk **writes over the rules you entered by hand** for those countries. When the installation's own country is among them, the default rate moves as well. Read and keep the current rules before running it, because there is no other way back. > **Lowering the numbering start causes clashes** > > Dropping the invoice numbering start **below the highest number already given** hands the same number out twice. Two documents sharing a number is a serious matter in accounting and hard to put right afterwards. Move the start upward alone. > **How tax is worked out changes the total** > > Whether tax sits inside the amount or on top of it changes **what your advertised prices say**. Inside, an item marked a hundred sells for a hundred with the tax taken out of it. On top, the client pays a hundred and twenty. Changing this once an installation is live means going back over the whole price list. ### Related Articles - [Currencies and Rates](https://dev.wisecp.com/en/currencies-and-rates) - [Discount Coupons](https://dev.wisecp.com/en/discount-coupons) - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) # API / Admin API / Invoices ## Managing Invoices https://dev.wisecp.com/en/managing-invoices The six endpoints that issue, read and edit invoices and give the revenue summary. ### Overview These six endpoints deal with the **invoice itself**: listing, issuing, reading, editing its header fields and deleting it. A revenue summary giving a period total sits alongside them. An invoice carries a **snapshot**: the client's name, address and tax details are held inside the record as they stood at issue. A client changing their address later leaves the invoice untouched, because a document once issued tells the story of a moment. The money has three layers: the **lines** give the subtotal, the **tax** what sits on top of it, and the **commission and surcharge** what the payment method brought. Together the three make the grand total. ### Reference #### Listing the Invoices get/api/v1/admin/invoices `Invoices/GetInvoices` admin Returns invoices, filtered and paged. Query 15 pageintWhich page. limitintRecords per page. A value out of range falls back to the default. searchstringSearches the invoice number and the client details. statusstringFilters by status: `waiting`, `unpaid`, `paid`, `cancelled`, `refund`. It also takes derived filters such as overdue, upcoming, formalised and not formalised. client_idintFilters by client. currency_idintFilters by currency. taxedboolSeparates the formalised ones. amountstringFilters by amount. amount_opstringWhich way the amount comparison runs. item_descriptionstringSearches the line descriptions. cdatestringFilters by the date issued. cdate_opstringWhich way that comparison runs. duedatestringFilters by the due date. duedate_opstringWhich way that comparison runs. daterangestringFilters between two dates. Its direction comes from its own comparison field. Response fields data[] — 14 + meta — 4 idintThe invoice id. numberstring | nullThe invoice number. Empty until a number is given. statusstringThe invoice status: `waiting`, `unpaid`, `paid`, `cancelled`, `refund`. currency_idintThe invoice currency. subtotalfloatThe subtotal. taxfloatThe tax. totalfloatThe grand total. formalizedboolWhether it was turned into a formal invoice. payment_methodstring | nullThe payment method. created_atstring | nullWhen it was issued. due_datestring | nullWhen it falls due. paid_atstring | nullWhen it was paid. refund_datestring | nullWhen it was refunded. clientobjectThe client: id, name, company and e-mail. As they stood when the invoice was issued. total intHow many match the filter. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/invoices?status=unpaid&limit=25' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/invoices'); url.searchParams.set('status', 'unpaid'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices?' . http_build_query(['status' => 'unpaid', 'limit' => 25])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Draft and system invoices NEVER enter this list; the count will not match the table. $rows = Api::Invoices()->GetInvoices([], ['status' => 'unpaid'])['data']; ``` #### Issuing an Invoice post/api/v1/admin/invoices `Invoices/CreateInvoice` admin the notice is optional Issues an invoice to a client by hand and writes its lines. Body 8 client_idintreqThe client the invoice goes to. itemsarrayreqThe invoice lines. At least one line is needed. currency_idintThe invoice currency. Left out, the client's own is used. statusstringThe status it opens with. Unpaid by default. payment_methodstringThe payment method. created_atstringThe date issued. Left out, the moment of the call is used. due_datestringThe due date. Left out, the end of today is used. send_notificationboolSends the client the notice that suits the status. Line fields items[] — 8 descriptionstringreqWhat the line is for. quantityintreqHow many. One at the least. amountfloatreqThe unit amount. discountfloatThe discount value. discount_typestringWhether the discount is an amount or a share. tax_ratefloatA tax rate for this line. tax_exemptboolKeeps the line free of tax. user_pidintThe service it belongs to. Zero says the line stands alone. Response fields 201 — data dataobjectThe invoice issued. Same shape as the detail endpoint. Errors 6 invalid_client422No client was given. not_found404No such client. currency_required422No currency could be resolved. items_required422No line was given. create_failed422The invoice could not be issued. 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/invoices' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"client_id":87,"currency_id":840,"items":[{"description":"Hosting","quantity":1,"amount":12.5}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/invoices', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: 87, currency_id: 840, due_date: '2026-07-01', items: [ { description: 'Hosting Plan', quantity: 1, amount: 12.5 }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'client_id' => 87, 'currency_id' => 840, 'items' => [ ['description' => 'Hosting Plan', 'quantity' => 1, 'amount' => 12.5], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The notice is OPTIONAL: leave it out and the client never hears about the invoice. Api::Invoices()->CreateInvoice([ 'client_id' => $uid, 'items' => $lines, 'send_notification' => true, ]); ``` #### Reading the Revenue Summary get/api/v1/admin/invoices/stats `Invoices/GetInvoiceStats` admin Returns the total for the type and period you pick. Query 2 typestringWhat gets added up: unpaid, paid, overdue or tax. The unpaid ones by default. periodstringWhich period: the last seven days, the last fifteen, this month, last month, this year or last year. This month by default. Response fields data — 5 typestringThe type applied. periodstringThe period applied. amountfloatThe total. A raw number, with other currencies converted into the local one before adding. countintHow many invoices went into it. currency_idintThe currency the total is in. 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/invoices/stats?type=unpaid&period=this-month' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/invoices/stats'); url.searchParams.set('type', 'unpaid'); url.searchParams.set('period', 'this-month'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/stats?' . http_build_query(['type' => 'unpaid', 'period' => 'this-month'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The amount is a CONVERTED total, worked out at the day's rate, and you format it yourself. $s = Api::Invoices()->GetInvoiceStats([], ['type' => 'unpaid'])['data']; $sum = Money::formatter_symbol($s['amount'], $s['currency_id']); ``` #### Reading One Invoice get/api/v1/admin/invoices/{id} `Invoices/GetInvoice` admin Returns an invoice with its lines, payments and the client snapshot. Response fields data — 33 idintThe invoice id. numberstring | nullThe invoice number. statusstringThe invoice status: `waiting`, `unpaid`, `paid`, `cancelled`, `refund`. currency_idintThe invoice currency. created_atstring | nullWhen it was issued. due_datestring | nullWhen it falls due. paid_atstring | nullWhen it was paid. refund_datestring | nullWhen it was refunded. subtotalfloatThe subtotal. taxfloatThe tax. Commission and surcharge taxes do not enter here. additional_taxfloatThe additional tax. totalfloatThe grand total. total_paidfloatWhat was paid, worked out from the payment records. balancefloatWhat remains owing. tax_ratefloatThe tax rate. taxation_typestringWhether tax sits inside the amount or outside it. formalizedboolWhether it was turned into a formal invoice. taxfreeboolWhether it is free of tax. localboolWhether it is a local invoice. recurringboolWhether it repeats. recurring_timeintHow often it repeats. recurring_periodstringThe period it repeats over. payment_methodstring | nullThe payment method. payment_method_statusstring | nullThe state of the payment method. payment_method_dataobjectWhat the payment method carries with it. payment_method_commissionfloatThe payment commission. payment_method_commission_ratefloatThe commission rate. payment_method_commission_taxfloatThe tax on the commission. It enters the grand total and not the tax field. installment_surchargefloatThe instalment surcharge. installment_surcharge_taxfloatThe tax on the surcharge. This too enters the grand total and not the tax field. installment_countintHow many instalments. send_bill_to_addressfloatThe charge for posting the bill. notesstringThe note on the invoice. unreadboolWhether the client has yet to read it. exchange_ratefloatThe rate at the time of issue. discountsobjectThe discount lines and their total. user_dataobjectThe client and their address as they stood at issue. clientobjectThe client: id, name, company and e-mail. itemsarrayThe invoice lines. paymentsarrayThe payment records. Line fields items[] — 17 idintThe line id. parent_idintThe parent line. Sub-lines hang off a line above. owner_idintThe invoice it belongs to. client_idintThe client. service_idintThe service it covers. Zero says the line stands alone. descriptionstringWhat the line is for. quantityintHow many. tax_exemptboolWhether it is free of tax. tax_ratefloatThe line's tax rate. Minus one says the rate comes from the invoice. additional_taxesarrayThe extra taxes on the line. additional_tax_totalfloatWhat those add up to. amountfloatThe unit amount. total_amountfloatThe line total. Quantity times amount, less the discount. currency_idintThe line currency. due_datestring | nullThe end of the line's period. rankintWhere it sits on the invoice. optionsarrayExtra details on the line. Payment fields payments[] — 14 idintThe payment id. amount_infloatWhat came in. amount_outfloatWhat went out. feesfloatThe fee taken. currency_idintThe payment currency. ratefloatThe rate at the time of payment. payment_methodstring | nullThe payment method. transaction_idstring | nullThe transaction number. descriptionstringWhat the payment was. paid_atstring | nullWhen it was paid. created_atstring | nullWhen the record was made. created_byintThe staff member who made it. recorded_bystring | nullTheir name. ipstring | nullThe address it came from. Errors 2 not_found404No such invoice. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/invoices/1212' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // When REBUILDING the total, add the two taxes yourself; the tax field leaves them out. $inv = Api::Invoices()->GetInvoice(['id' => $id])['data']; $sum = $inv['subtotal'] + $inv['tax'] + $inv['payment_method_commission'] + $inv['payment_method_commission_tax'] + $inv['installment_surcharge'] + $inv['installment_surcharge_tax']; ``` #### Editing an Invoice patch/api/v1/admin/invoices/{id} `Invoices/UpdateInvoice` admin Changes an invoice's header fields, recomputing the totals when needed. Body 11 numberstringThe invoice number. payment_methodstringThe payment method. taxation_typestringWhether tax sits inside the amount. Changing it recomputes the totals. currency_idintThe invoice currency. Changing it recomputes the totals. tax_ratefloatThe tax rate. formalizedboolTurns it into a formal invoice. The first time it does, the client is told. taxfreeboolMakes the invoice free of tax. It takes precedence over formalising. created_atstringThe date issued. due_datestringThe due date. paid_atstringThe date paid. refund_datestringThe date refunded. Response fields data dataobjectThe invoice as it now stands. Same shape as the detail endpoint. Errors 2 not_found404No such invoice. 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/invoices/1212' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"number":"INV-2026-0042","tax_rate":20}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ number: 'INV-2026-0042', tax_rate: 20, due_date: '2026-07-15 23:59:00', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'number' => 'INV-2026-0042', 'tax_rate' => 20, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Making it tax-free OVERRIDES formalising; sending both together makes no sense. Api::Invoices()->UpdateInvoice(['id' => $id, 'formalized' => true]); ``` #### Deleting an Invoice delete/api/v1/admin/invoices/{id} `Invoices/DeleteInvoice` admin unwinds a chain of records Removes an invoice and every record hanging off it. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the invoice removed. Errors 3 not_found404No such invoice. blocked_by_gate422A hook refused the delete. 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/invoices/1226' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id); 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 // Cancel it rather than delete: the accounting trail stays and it leaves what is owed. Api::Invoices()->SetInvoiceStatus(['id' => $id, 'status' => 'cancelled']); ``` ### Pitfalls > **The tax field does not hold every tax** > > The taxes on the payment commission and the instalment surcharge **enter the grand total and not the tax field**. Add the subtotal to the tax expecting the grand total and the difference has no obvious source. Rebuilding the total means adding those two taxes from their own fields. > **Draft invoices are not in the list** > > The listing endpoint **hides** draft and system invoices. That matches what the panel shows, yet the count from the list will not match the rows in the database. Weigh that when writing an accounting report; adding a filter does not bring the hidden ones back. > **Formal and tax-free share one field** > > An invoice's tax state is one field taking one of three values: normal, formal or tax-free. Making it tax-free **overrides** formalising, and sending both together means nothing. The first time formalising is applied, the client is told as well. > **A delete unwinds the chain** > > Deleting an invoice does more than remove the record: it also **unwinds** the lines, the income and expense entries, the links to services and any pending metered rows. A hook can refuse the delete, because some invoices leaving would break the books. To back out, cancel rather than delete: the trail stays and the debt goes. > **Empty dates come back empty** > > Fields for things that have not happened, such as the paid and refund dates, come back **empty**. The database keeps placeholder dates there and the response clears them. Check a date exists before reading it, or you end up with a screen treating a placeholder as real. ### Related Articles - [Invoice Items and Payments](https://dev.wisecp.com/en/invoice-items-and-payments) - [Invoice Status and Notices](https://dev.wisecp.com/en/invoice-status-and-notices) - [Cash Records](https://dev.wisecp.com/en/cash-records) ## Invoice Status and Notices https://dev.wisecp.com/en/invoice-status-and-notices The five endpoints for an invoice's status, its client details and the notices sent. ### Overview These five endpoints run the **life** of an invoice once issued. They change its status, correct the client details, send a notice, formalise it and remind. The status change is the **heaviest** thing here. Marking an invoice paid opens an income entry, gives it a paid-invoice number and sets the linked services moving; refunding winds that back and can order a refund through the payment module. Three of the endpoints **mail the client**: the notice, the reminder and, when its file is there, formalising. Calling them from a script reaches real people. ### Reference #### Changing the Status put/api/v1/admin/invoices/{id}/status `Invoices/UpdateInvoiceStatus` admin it has side effects Changes an invoice's status and carries out what that status brings. Body 5 statusstringreqThe new status: `paid`, `unpaid`, `refund` or `cancelled`. payment_methodstringThe payment method. On a paid invoice, sending this alone changes the method. refund_methodstringHow the refund happens. It can go back through the payment module. cancel_servicesboolCancels the services on the invoice as well. It works on refund and cancel. notifyboolTells the client. Response fields data dataobjectThe invoice as it now stands. Same shape as the detail endpoint. Errors 5 not_found404No such invoice. invalid_status422The status is not recognised. same_status422The invoice already stands there. blocked_by_gate422A hook refused the change. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/invoices/1212/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"paid","payment_method":"Balance","notify":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'paid', payment_method: 'Balance', notify: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 'paid', 'payment_method' => 'Balance', 'notify' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Marking it paid OPENS AN INCOME ENTRY, gives a number and sets the services moving. Api::Invoices()->UpdateInvoiceStatus([ 'id' => $id, 'status' => 'paid', 'notify' => true, ]); ``` #### Correcting the Client Details patch/api/v1/admin/invoices/{id}/client-details `Invoices/UpdateInvoiceClientDetails` admin this invoice only Corrects the client and address details frozen onto an invoice. Body 10 kindstringWhether the client is a person or a company. On a person the company name is cleared. first_namestringThe first name. The full name is built from it. last_namestringThe last name. emailstringThe e-mail address. phonestringThe phone. identitystringThe identity or tax number. company_namestringThe company name. It applies to a company. tax_numberstringThe tax number. tax_officestringThe tax office. addressobjectThe address: street, country, state, city and postcode. State and city are resolved from an id when given as a number and stored as written when given as text. Response fields data dataobjectThe invoice as it now stands. Same shape as the detail endpoint. Errors 2 not_found404No such invoice. 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/invoices/1212/client-details' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"kind":"corporate","company_name":"Ornek A.S.","tax_number":"0000000000"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/client-details`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ kind: 'corporate', company_name: 'Example Inc.', tax_number: '000000000', address: { detail: '123 Market Street', country_id: 840, zipcode: '94105' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/client-details'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'kind' => 'corporate', 'company_name' => 'Example Inc.', 'tax_number' => '000000000', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint DOES NOT TOUCH the live client; it corrects the copy on this invoice. Api::Invoices()->UpdateInvoiceClientDetails([ 'id' => $id, 'company_name' => 'Example Inc.', ]); ``` #### Sending a Notice post/api/v1/admin/invoices/{id}/notifications `Invoices/SendInvoiceNotification` admin a real e-mail goes out Sends the client the invoice notice template you pick. Body 1 templatestringreqThe key of the template to send. It has to be one of the invoice notices and switched on. Response fields data — 3 sentboolWhether the send ran. templatestringThe template sent. invoice_idintThe invoice id. Errors 4 not_found404No such invoice. template_required422No template was given. invalid_template422The template is not recognised, or it is switched off. 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/invoices/1231/notifications' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"template":"invoice-created"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/notifications`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ template: 'invoice-created' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/notifications'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['template' => 'invoice-created']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A switched-off template gives a 422; the list on screen does not mark which are on. Api::Invoices()->SendInvoiceNotification([ 'id' => $id, 'template' => 'invoice-created', ]); ``` #### Formalising an Invoice post/api/v1/admin/invoices/{id}/formalize `Invoices/FormalizeInvoice` admin once only Turns an invoice into a formal one. Body 1 notifyboolTells the client. It goes out when a formalisation file is there. Response fields data dataobjectThe invoice as it now stands. Same shape as the detail endpoint. Errors 4 not_found404No such invoice. already_formalized422The invoice is already formal. blocked_by_gate422A hook refused it. 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/invoices/1231/formalize' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"notify":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/formalize`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notify: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/formalize'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['notify' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A second call gives a 422; in a batch script read the invoice's state first. $inv = Api::Invoices()->GetInvoice(['id' => $id])['data']; if (! $inv['formalized']) Api::Invoices()->FormalizeInvoice(['id' => $id]); ``` #### Sending a Reminder post/api/v1/admin/invoices/{id}/remind `Invoices/RemindInvoice` admin Sends the client a reminder for an unpaid invoice. Body — ——No body is needed, send an empty one. The invoice comes from the path, and the notification sent is `invoice-reminder`; there is no field to change it with. Response fields data — 2 remindedboolWhether the send ran. invoice_idintThe invoice id. Errors 2 not_found404No such invoice. 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/invoices/1231/remind' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/remind`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/remind'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The call counts NO ATTEMPTS; it will remind the same invoice again and again. Api::Invoices()->RemindInvoice(['id' => $id]); ``` ### Pitfalls > **A status change never travels alone** > > Marking an invoice paid **opens an income entry**. It also gives the invoice a paid number and sets the linked services moving. Refunding winds all of that back, unwinds the metered rows and can order a refund through the payment module. Do not try this on a real client's invoice. > **Moving to the same status is an error** > > When the invoice already stands there, the call is **refused**. The one exception is changing the payment method on a paid invoice, where the status holds and only the method is written. A script that syncs statuses should read the current one first. > **The client correction belongs to the invoice** > > This endpoint corrects the **frozen copy** on the invoice and leaves the live client record alone. A wrong address may need fixing in both places. Correct it here for the document already issued, and on the client for the ones to come. A correction here does not reach other invoices either. > **Formalising happens once** > > Formalising an invoice that is already formal **gives an error**. In a batch script that stops the loop at the first one, so read each invoice's state first. A hook can refuse it as well, because on some installations the act has a counterpart in the books. > **Reminders are not counted** > > The reminder endpoint **keeps no count** of how often it was called, and calling it twice sends the same client two mails. A script that walks the overdue invoices should keep on its own side which invoice was reminded and when. Otherwise every run warns everyone again. ### Related Articles - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) - [Invoice Items and Payments](https://dev.wisecp.com/en/invoice-items-and-payments) - [Cash Records](https://dev.wisecp.com/en/cash-records) ## Invoice Items and Payments https://dev.wisecp.com/en/invoice-items-and-payments The four endpoints that edit and split invoice lines and hold the payment records. ### Overview These four endpoints run the **money side** of an invoice. Two deal with its lines: editing them and moving some onto an invoice of their own. The other two deal with payment records. The line endpoint works in **bulk**: one call adds, updates and removes. When it is done the totals are worked out again. The subtotal, the tax and the grand total are never written by hand. A payment record moves the invoice status **one way only**. Clearing the balance turns it paid, while removing a payment does not turn it back. ### Reference #### Writing the Lines put/api/v1/admin/invoices/{id}/items `Invoices/UpdateInvoiceItems` admin the totals are recomputed Adds, updates and removes an invoice's lines in one call. Body 3 itemsarrayThe lines to work on. One carrying an id gets updated, one without is added as new. deleted_idsint[]The line ids to remove. removed_discountsarrayThe discounts to take off. Each entry carries the discount type and the line it belongs to. Line fields items[] — 10 item_idintThe id of an existing line. Left out, a new line is opened. descriptionstringreqWhat the line is for. quantityintreqHow many. amountfloatreqThe unit amount. discountfloatA discount on this line. discount_typestringWhether the discount is an amount or a share. tax_ratefloatA tax rate for this line. Left empty, the invoice's rate is used. tax_exemptboolKeeps the line free of tax. user_pidintThe service it covers. oduedatestringThe end of the line's period. Response fields data dataobjectThe invoice as it now stands. Its totals arrive recomputed. Errors 3 not_found404No such invoice. item_required422No line would remain when the call is done. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/invoices/1212/items' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"items":[{"item_id":2811,"description":"Hosting","quantity":1,"amount":12.5}],"deleted_ids":[2812]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/items`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ items: [ { item_id: 2811, description: 'Hosting Plan', quantity: 1, amount: 12.5 }, { description: 'Setup Fee', quantity: 1, amount: 5 }, ], deleted_ids: [2812], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/items'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'items' => [ ['item_id' => 2811, 'description' => 'Hosting Plan', 'quantity' => 1, 'amount' => 12.5], ['description' => 'Setup Fee', 'quantity' => 1, 'amount' => 5], ], 'deleted_ids' => [2812], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A line you leave out is NOT removed; to remove one, put its id in the deleted list. Api::Invoices()->UpdateInvoiceItems([ 'id' => $id, 'items' => [['item_id' => 2811, 'description' => 'Hosting', 'quantity' => 1, 'amount' => 12.5]], 'deleted_ids' => [2812], ]); ``` #### Splitting the Lines post/api/v1/admin/invoices/{id}/items/split `Invoices/SplitInvoiceItems` admin opens a new invoice Moves the lines you pick onto a new invoice. Body 1 item_idsint[]reqThe lines to move. All have to belong to the source, and one line has to stay behind. Response fields data — 4 source_invoice_idintThe source invoice id. new_invoice_idintThe invoice opened. new_invoice_numberstring | nullThe new invoice's number. Empty until a number is given. moved_item_idsint[]The lines that moved. Errors 6 not_found404No such invoice. split_required422No line was picked. no_items422The lines do not belong to this invoice. split_one_left422No line would stay on the source. split_failed422The new invoice could not be opened. 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/invoices/1228/items/split' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"item_ids":[2835]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/items/split`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ item_ids: [2835] }), }); const { data } = await res.json(); console.log(data.new_invoice_id); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/items/split'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['item_ids' => [2835]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The new invoice opens UNPAID and the client is NOT told about it by itself. $r = Api::Invoices()->SplitInvoiceItems(['id' => $id, 'item_ids' => $picked])['data']; Api::Invoices()->SendInvoiceNotification([ 'id' => $r['new_invoice_id'], 'template' => 'invoice-created', ]); ``` #### Recording a Payment post/api/v1/admin/invoices/{id}/payments `Invoices/AddInvoicePayment` admin clears the balance to paid Adds a payment record to an invoice by hand. Body 7 amountfloatreqThe amount paid. It has to be above zero. payment_methodstringreqHow the money came in. currency_idintThe payment currency. It gets converted when it differs from the invoice. transaction_idstringThe transaction number. A second record with the same one is refused. descriptionstringWhat the payment was. paid_atstringThe date it was paid. feesfloatThe fee taken. Response fields data + meta — 1 dataobjectThe invoice as it now stands. Once the balance clears, the status turns to paid. payment_idintThe id of the payment record opened. It comes back under meta. Errors 5 not_found404No such invoice. invalid_amount422The amount is zero or below. method_required422No payment method was given. payment_rejected422The invoice is already paid, the transaction number repeats, or the currency is not valid. 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/invoices/1212/payments' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"amount":12.5,"payment_method":"Balance","transaction_id":"TXN-1042"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/payments`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 12.5, payment_method: 'Balance', currency_id: 840, transaction_id: 'TXN-1042', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/payments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 12.5, 'payment_method' => 'Balance', 'transaction_id' => 'TXN-1042', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The transaction number guards against DOUBLES; retrying after a network error is safe. Api::Invoices()->AddInvoicePayment([ 'id' => $id, 'amount' => 12.5, 'payment_method' => 'Balance', 'transaction_id' => $txn, ]); ``` #### Removing a Payment delete/api/v1/admin/invoices/{id}/payments/{payment_id} `Invoices/DeleteInvoicePayment` admin the status does not follow Takes a payment record off an invoice. Response fields data dataobjectThe invoice as it now stands. What was paid and what remains are worked out again. Errors 3 not_found404That payment is not on this invoice. invalid_payment422The payment id is not valid. 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/invoices/1212/payments/31' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/${id}/payments/${paymentId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/' . $id . '/payments/' . $paymentId); 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 // The invoice stays PAID: the balance reopens, and turning the status back is your job. Api::Invoices()->DeleteInvoicePayment(['id' => $id, 'payment_id' => $pid]); Api::Invoices()->UpdateInvoiceStatus(['id' => $id, 'status' => 'unpaid']); ``` ### Pitfalls > **Leaving a line out does not remove it** > > A line you do not send on the write call **stays put**. Removing it means putting its id in the deleted list. A script that sends the line set as it stands will not remove what was taken out. Compare the two lists and write the difference into the deleted list. > **An invoice cannot be left with no lines** > > Both the write and the split call want **at least one line** left on the invoice when they finish. Trying to delete every line, or to split them all away, gives an error. To be rid of the invoice, remove or cancel the invoice itself rather than its lines. > **Do not write the totals by hand** > > When the write call finishes, the subtotal, the tax and the grand total are **worked out again**. Trying to write a total through the invoice edit endpoint at the same time leaves a record pulled by two sources. Totals come from the lines, and changing them means changing the lines. > **Removing a payment does not turn the status back** > > Clearing the balance turns an invoice paid by itself, yet removing the payment does not **walk that back**. What was paid and what remains are worked out again while the status stays paid. When undoing a payment entered by mistake, turn the status back to unpaid as a separate step. > **A split brings a new debt into being** > > The split call opens a **new unpaid invoice** and tells the client nothing by itself. The client is left with another debt they have not heard of. After a split, take the new invoice id and send a notice. The source total drops as well, so both documents have changed. ### Related Articles - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) - [Invoice Status and Notices](https://dev.wisecp.com/en/invoice-status-and-notices) - [Cash Records](https://dev.wisecp.com/en/cash-records) ## Cash Records https://dev.wisecp.com/en/cash-records The six endpoints that keep an installation's income and expense book. ### Overview The cash book holds an installation's **income and expenses**. Two kinds of record sit in it together: the ones invoices open by themselves once paid, and the ones an operator enters by hand. The **system mark** tells them apart. A record from an invoice is a reflection: it cannot be changed or removed, because its source is the invoice itself. Records entered by hand can be edited. The summary gives **this month's** income, expenses and the difference between them. Amounts in other currencies are converted into the local one before adding. ### Reference #### Listing the Records get/api/v1/admin/invoices/cash `Invoices/GetCashEntries` admin Returns the income and expense records, filtered. Query 10 pageintWhich page. limitintRecords per page. A value out of range falls back to the default. searchstringSearches the description, the staff member and the invoice number. typestringLimits it to income or to expenses. currency_idintFilters by currency. staff_idintFilters by staff member. amountstringFilters by amount. amount_opstringWhich way the amount comparison runs. descriptionstringSearches the description. daterangestringFilters between two dates. Response fields data[] — 13 + meta — 4 idintThe record id. invoice_idintThe invoice it belongs to. Zero says the record was entered by hand. typestringWhether it is income or an expense: `income`, `expense`. amountfloatThe amount. currency_idintThe currency it is in. staff_idintThe staff member who entered it. payment_methodstring | nullHow the money came in or went out. descriptionstringWhat the record is for. created_atstring | nullThe record date. is_systemboolWhether it came from an invoice. When true the record cannot be changed. invoice_numberstring | nullThe number of the invoice it belongs to. staff_namestring | nullThe staff member's name. clientobject | nullThe client on that invoice. It comes back in the list alone. totalintHow many match the filter. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/invoices/cash?type=expense' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/invoices/cash'); url.searchParams.set('type', 'expense'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/cash?' . http_build_query(['type' => 'expense'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list carries records FROM INVOICES too; filter on the system mark for hand-entered ones. $manual = array_filter( Api::Invoices()->GetCashEntries()['data'], fn ($e) => ! $e['is_system'], ); ``` #### Adding a Record post/api/v1/admin/invoices/cash `Invoices/CreateCashEntry` admin Puts an income or expense record into the book by hand. Body 7 typestringreqWhether it is income or an expense: `income`, `expense`. currency_idintreqThe currency of the amount. amountfloatreqThe amount. It has to be above zero. descriptionstringWhat the record is for. payment_methodstringHow the money moved. A method that is switched off ends up empty. staff_idintThe staff member entering it. Left out, the key's owner is written. created_atstringThe record date. Left out, the moment of the call is used. Response fields 201 — data — 13 dataobjectThe record opened. Same shape as a list item. Errors 5 invalid_type422The type is neither income nor expense. currency_required422No currency was given. invalid_amount422The amount is zero or below. create_failed422The record could not be opened. 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/invoices/cash' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"expense","currency_id":840,"amount":49.9,"description":"Sunucu gideri"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/invoices/cash', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'expense', currency_id: 840, amount: 49.9, description: 'Server cost', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/cash'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'expense', 'currency_id' => 840, 'amount' => 49.9, 'description' => 'Server cost', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A hand-entered record IS NOT TIED to an invoice; those only come from the payment flow. Api::Invoices()->CreateCashEntry([ 'type' => 'expense', 'currency_id' => 840, 'amount' => 49.9, ]); ``` #### Reading the Cash Summary get/api/v1/admin/invoices/cash/summary `Invoices/GetCashSummary` admin Returns this month's income, expenses and balance. Response fields data — 4 currency_idintThe currency the totals are in. incomefloatThis month's income. expensefloatThis month's expenses. balancefloatThe expenses taken from the income. 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/invoices/cash/summary' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/invoices/cash/summary', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/cash/summary'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The summary is ALWAYS this month; for another period pull the list by date and add it up. $sum = Api::Invoices()->GetCashSummary()['data']; ``` #### Reading One Record get/api/v1/admin/invoices/cash/{cash_id} `Invoices/GetCashEntry` admin Returns a single cash record. Response fields data — 13 idintThe record id. invoice_idintThe invoice it belongs to. Zero says the record was entered by hand. typestringWhether it is income or an expense: `income`, `expense`. amountfloatThe amount. currency_idintThe currency it is in. staff_idintThe staff member who entered it. payment_methodstring | nullHow the money came in or went out. descriptionstringWhat the record is for. created_atstring | nullThe record date. is_systemboolWhether it came from an invoice. When true the record cannot be changed. invoice_numberstring | nullThe number of the invoice it belongs to. staff_namestring | nullThe staff member's name. clientobject | nullThe client on that invoice. It comes back in the list alone. Errors 2 not_found404No such cash record. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/invoices/cash/51' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/cash/${cashId}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/cash/' . $cashId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The client details DO NOT arrive here; that field is filled by the list join alone. $entry = Api::Invoices()->GetCashEntry(['cash_id' => $cashId])['data']; ``` #### Updating a Record patch/api/v1/admin/invoices/cash/{cash_id} `Invoices/UpdateCashEntry` admin invoice records are locked Changes a cash record entered by hand. Body 7 typestringWhether it is income or an expense. currency_idintThe currency of the amount. amountfloatThe amount. descriptionstringWhat the record is for. payment_methodstringHow the money moved. staff_idintThe staff member who entered it. created_atstringThe record date. Response fields data — 13 dataobjectThe record as it now stands. Same shape as a list item. Errors 6 not_found404No such cash record. system_record422A record tied to an invoice cannot be touched. invalid_type422The type is neither income nor expense. currency_required422No currency was given. invalid_amount422The amount is zero or below. 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/invoices/cash/51' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"amount":59.9,"description":"Sunucu gideri (guncellendi)"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/cash/${cashId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 59.9, description: 'Server cost (updated)' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/cash/' . $cashId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['amount' => 59.9]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A record marked as system gives a 422; in a bulk fix, filter on that field first. $entry = Api::Invoices()->GetCashEntry(['cash_id' => $cashId])['data']; if (! $entry['is_system']) Api::Invoices()->UpdateCashEntry(['cash_id' => $cashId, 'amount' => 59.9]); ``` #### Removing a Record delete/api/v1/admin/invoices/cash/{cash_id} `Invoices/DeleteCashEntry` admin Removes a cash record entered by hand. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the record removed. Errors 4 not_found404No such cash record. system_record422A record tied to an invoice cannot be touched. delete_failed422The record could not be removed. 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/invoices/cash/52' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/cash/${cashId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/cash/' . $cashId); 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 // A record from an invoice is removed on the INVOICE side; it cannot go from here. Api::Invoices()->DeleteCashEntry(['cash_id' => $cashId]); ``` ### Pitfalls > **A record from an invoice cannot be changed** > > A cash record tied to an invoice is **read-only**: the update and delete calls are refused. That is deliberate, because the record reflects the invoice and cannot contradict it. To correct an amount, go back to the invoice and the cash record follows. > **The list mixes the two kinds** > > The listing returns hand-entered records and invoice ones **together**, with no filter to separate them. To add up only the hand-entered expenses in a report, sift on the system mark yourself; otherwise invoice income lands in the sum. > **The summary is always this month** > > The summary takes no period and always gives the **month you are in**. For last month or a quarter, pull the list by date and add it up yourself. You also have to handle the currency conversion, because the list does not arrive converted the way the summary does. > **The detail carries no client** > > The client on the linked invoice arrives **in the list alone**, and reading one record leaves that field empty. The list performs a join and the detail does not. To get the client for a single record, read the invoice from its number. > **A hand-entered record cannot be tied to an invoice** > > The create endpoint always opens a **standalone** record, and no invoice number ties one to an invoice. Records with that link come from the payment flow alone. So entering income by hand and also marking the same invoice paid shows the same money twice in the book. ### Related Articles - [Recurring Expenses](https://dev.wisecp.com/en/recurring-expenses) - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) - [Invoice Status and Notices](https://dev.wisecp.com/en/invoice-status-and-notices) ## Recurring Expenses https://dev.wisecp.com/en/recurring-expenses The six endpoints that define the fixed outgoings repeating each month. ### Overview Recurring expenses define the **payments that come round every month**: server rent, a licence fee, a subscription. What lives here is not an expense but a **rule** that brings expenses into being. Each rule carries a **day and a time**. When the scheduled job reaches that moment the rule runs and writes that month's expense into the cash book. So the number of records here is not the number in the book. The summary **looks forward**: it says how much fixed outgoing an installation has each month. To see what was actually paid, look at the cash book. ### Reference #### Listing the Expenses get/api/v1/admin/invoices/periodic `Invoices/GetPeriodicExpenses` admin Returns the expenses that repeat every month, filtered. Query 8 pageintWhich page. limitintRecords per page. A value out of range falls back to the default. searchstringSearches the description. currency_idintFilters by currency. amountstringFilters by amount. amount_opstringWhich way the amount comparison runs. descriptionstringSearches the description. daterangestringFilters between two dates. Response fields data[] — 9 + meta — 4 idintThe record id. amountfloatThe amount charged each month. currency_idintThe currency it is in. descriptionstringWhat the expense is. dayintWhich day of the month it runs. hourintThe hour it runs. minuteintThe minute it runs. timestringThe hour and minute joined together. created_atstring | nullWhen the record was opened. totalintHow many match the filter. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/invoices/periodic' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/invoices/periodic', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/periodic'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // These are RULES rather than records: each one brings a cash expense into being monthly. $rules = Api::Invoices()->GetPeriodicExpenses()['data']; ``` #### Adding an Expense post/api/v1/admin/invoices/periodic `Invoices/CreatePeriodicExpense` admin a day and a time are needed Defines an expense to be charged every month by itself. Body 5 currency_idintreqThe currency of the amount. amountfloatreqThe amount charged each month. It has to be above zero. dayintreqWhich day of the month it runs. Between one and thirty-one. timestringreqThe time it runs. Given as an hour and a minute, then split into both. descriptionstringWhat the expense is. Response fields 201 — data — 9 dataobjectThe expense opened. Same shape as a list item. Errors 6 currency_required422No currency was given. invalid_amount422The amount is zero or below. invalid_day422The day is out of range. invalid_time422The time is not in the expected form. create_failed422The record could not be opened. 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/invoices/periodic' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"currency_id":840,"amount":49.9,"day":1,"time":"09:00","description":"Aylik sunucu gideri"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/invoices/periodic', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ currency_id: 840, amount: 49.9, day: 1, time: '09:00', description: 'Monthly server cost', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/periodic'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'currency_id' => 840, 'amount' => 49.9, 'day' => 1, 'time' => '09:00', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Picking the thirty-first can leave the record unprocessed in the SHORT months. Api::Invoices()->CreatePeriodicExpense([ 'currency_id' => 840, 'amount' => 49.9, 'day' => 1, 'time' => '09:00', ]); ``` #### Reading the Summary get/api/v1/admin/invoices/periodic/summary `Invoices/GetPeriodicSummary` admin Returns the total of the fixed monthly outgoings. Response fields data — 3 currency_idintThe currency the total is in. totalfloatThe monthly total. Other currencies are converted before adding. countintHow many records went into it. 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/invoices/periodic/summary' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/invoices/periodic/summary', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/periodic/summary'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This total looks FORWARD: it says what will be paid each month, not what was. $plan = Api::Invoices()->GetPeriodicSummary()['data']; ``` #### Reading One Expense get/api/v1/admin/invoices/periodic/{periodic_id} `Invoices/GetPeriodicExpense` admin Returns a single recurring expense. Response fields data — 9 idintThe record id. amountfloatThe amount charged each month. currency_idintThe currency it is in. descriptionstringWhat the expense is. dayintWhich day of the month it runs. hourintThe hour it runs. minuteintThe minute it runs. timestringThe hour and minute joined together. created_atstring | nullWhen the record was opened. Errors 2 not_found404No such recurring expense. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/invoices/periodic/8' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/periodic/${periodicId}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/periodic/' . $periodicId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The record does NOT carry when it last ran; read that from the cash book instead. $rule = Api::Invoices()->GetPeriodicExpense(['periodic_id' => $id])['data']; ``` #### Updating an Expense patch/api/v1/admin/invoices/periodic/{periodic_id} `Invoices/UpdatePeriodicExpense` admin Changes a recurring expense's amount or its timing. Body 5 currency_idintThe currency of the amount. amountfloatThe amount charged each month. It has to be above zero. dayintWhich day of the month it runs. Between one and thirty-one. timestringThe time it runs. An hour and a minute on the twenty-four hour clock. descriptionstringWhat the expense is. Response fields data — 9 dataobjectThe expense as it now stands. Same shape as a list item. Errors 6 not_found404No such recurring expense. currency_required422No currency was given. invalid_amount422The amount is zero or below. invalid_day422The day is out of range. invalid_time422The time is not in the expected form. 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/invoices/periodic/8' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"amount":59.9,"day":5,"time":"10:30"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/periodic/${periodicId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 59.9, day: 5, time: '10:30' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/periodic/' . $periodicId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['amount' => 59.9, 'day' => 5]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The change does NOT reach back: what already ran this month keeps the old amount. Api::Invoices()->UpdatePeriodicExpense(['periodic_id' => $id, 'amount' => 59.9]); ``` #### Removing an Expense delete/api/v1/admin/invoices/periodic/{periodic_id} `Invoices/DeletePeriodicExpense` admin Removes a recurring expense. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the record removed. Errors 3 not_found404No such recurring expense. delete_failed422The record could not be removed. 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/invoices/periodic/8' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/invoices/periodic/${periodicId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/invoices/periodic/' . $periodicId); 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 // A delete leaves PAST cash records alone; it only stops the rule running again. Api::Invoices()->DeletePeriodicExpense(['periodic_id' => $id]); ``` ### Pitfalls > **A late day can be skipped in the short months** > > The thirty-first is **not in every month**, and neither are the thirtieth and the twenty-ninth in some. A rule set to such a day can go unprocessed in those months and the expense never reaches the book. For an outgoing that has to run monthly, pick an early day. > **Changing a rule does not reach back** > > Changing the amount or the day touches **only what comes after**. An expense already processed this month stays in the book at the old amount, and the new one shows next month. To correct the past, update the record in the cash book as a separate step. > **A delete does not remove past expenses** > > Deleting a rule only stops it **running again**; the expense records it made up to that day stay in the book. That is the right behaviour, because those payments really happened. Clearing the past means removing those cash records as a separate step. > **The record does not carry when it last ran** > > A recurring expense record does not say **when it last ran**. Telling whether a rule ran this month means looking in the cash book for a record matching its description. Weigh that when writing a check: the list here gives no run history on its own. > **The summary looks forward and the book looks back** > > The summary here says **what will be paid** each month, while the cash summary says what was paid this month. The two do not match and are not meant to: a rule running on the twentieth is not in the book on the tenth. Keep them on separate lines in a report. ### Related Articles - [Cash Records](https://dev.wisecp.com/en/cash-records) - [Managing Invoices](https://dev.wisecp.com/en/managing-invoices) - [Adding a Scheduled Job](https://dev.wisecp.com/en/adding-a-scheduled-task) # API / Admin API / Knowledge Base ## Help Articles https://dev.wisecp.com/en/help-articles The five endpoints that write, read, update and remove help articles. ### Overview A help article lives in two layers. The **structural data** holds the category, the state, who sees it and the order. The **content per language** holds the title, slug, body, tags and the search engine fields. A title is required in every live language. The slug is optional and comes from the title when left empty, and it has to be unique **per language**. The update is named as a partial change and behaves as a full write. The translation set is saved again for every language. Read the current state before changing anything. ### Reference #### Listing the Articles get/api/v1/admin/knowledgebase/articles `Knowledgebase/GetKnowledgebaseArticles` admin Returns the help articles with their read and vote counts. Query 3 searchstringSearches the id, the title and the tags. pageintWhich page. limitintRecords per page. 100 at the most. Response fields data[] — 10 + meta — 3 idintThe article id. titlestringThe title. In the panel's current language. routestringThe address slug. categorystringThe category name. statusstringPublished or a draft. privateboolWhether only signed-in clients see it. viewsintHow often it was read. usefulintThe helpful votes. uselessintThe unhelpful votes. created_atstringWhen it was written. totalintHow many articles there are. It comes back under meta. pageintThe page you are on. limitintThe page size. 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/knowledgebase/articles?search=password' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/knowledgebase/articles', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const poor = data.filter((a) => a.useless > a.useful); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/articles'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The title comes in ONE language; comparing translations wants the detail endpoint. $rows = Api::Knowledgebase()->GetKnowledgebaseArticles()['data']; $weak = array_filter($rows, fn ($a) => $a['useless'] > $a['useful']); ``` #### Writing an Article post/api/v1/admin/knowledgebase/articles `Knowledgebase/CreateKnowledgebaseArticle` admin every language needed Opens a new help article. Body 6 translationsobjectreq translations.The content per language. A title is needed in every live language. titlestringThe article title. It is needed in every live language. routestringThe address slug. Left empty it comes from the title. contentstringThe article body. It takes markup. tagsstringThe tags. seo_titlestringThe search engine title. seo_keywordsstringThe search engine keywords. seo_descriptionstringThe search engine description. category_idintThe category it sits in. Zero means none. statusstringPublished or a draft. privateboolWhether only signed-in clients see it. sidebarboolWhether the side column shows. rankintWhere it sits in the listing. Response fields 201 — data — 11 dataobjectThe article made. Same shape as the read endpoint, and the new id comes under meta as well. Errors 4 title_required422The title is missing in one of the live languages. route_in_use422The slug is taken by another article in that language. vetoed422A hook refused the save. 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/knowledgebase/articles' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"category_id":3,"translations":{"en":{"title":"How to reset your password","content":"

    Open the account page.

    "}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/knowledgebase/articles', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ category_id: 3, status: 'published', translations: { en: { title: 'How to reset your password', route: 'reset-password', content: html }, }, }), }); const { data, meta } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/articles'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'category_id' => 3, 'translations' => ['en' => ['title' => $title, 'content' => $html]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // It wants a title in EVERY live language; a missing one turns the whole call down and nothing is saved. $langs = array_column(Api::Reference()->GetLanguages()['data'], 'code'); foreach ($langs as $l) $t[$l] = ['title' => $titles[$l] ?? $titles['en']]; Api::Knowledgebase()->CreateKnowledgebaseArticle(['translations' => $t]); ``` #### Reading an Article get/api/v1/admin/knowledgebase/articles/{id} `Knowledgebase/GetKnowledgebaseArticle` admin Returns an article with all of its languages. Response fields data — 11 idintThe article id. category_idintThe category it sits in. statusstringPublished or a draft. privateboolWhether only signed-in clients see it. sidebarboolWhether the side column shows. rankintWhere it sits in the listing. viewsintHow often it was read. usefulintThe helpful votes. uselessintThe unhelpful votes. created_atstringWhen it was written. translationsobject translations.The content per language. titlestringThe article title. It is needed in every live language. routestringThe address slug. Left empty it comes from the title. contentstringThe article body. It takes markup. tagsstringThe tags. seo_titlestringThe search engine title. seo_keywordsstringThe search engine keywords. seo_descriptionstringThe search engine description. Errors 2 not_found404No such article. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/knowledgebase/articles/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/articles/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const missing = langs.filter((l) => ! data.translations[l]?.content); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/articles/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read here BEFORE updating: an update rewrites the WHOLE translation set. $a = Api::Knowledgebase()->GetKnowledgebaseArticle(['id' => $id])['data']; $a['translations']['en']['content'] = $newHtml; ``` #### Updating an Article patch/api/v1/admin/knowledgebase/articles/{id} `Knowledgebase/UpdateKnowledgebaseArticle` admin a full write Rewrites the article in full. Body 6 translationsobjectreq translations.The content per language. The set is rewritten and a language you leave out is lost. titlestringThe article title. It is needed in every live language. routestringThe address slug. Left empty it comes from the title. contentstringThe article body. It takes markup. tagsstringThe tags. seo_titlestringThe search engine title. seo_keywordsstringThe search engine keywords. seo_descriptionstringThe search engine description. category_idintThe category it sits in. Zero means none. statusstringPublished or a draft. privateboolWhether only signed-in clients see it. sidebarboolWhether the side column shows. rankintWhere it sits in the listing. Response fields data — 11 dataobjectThe article as it now stands. Same shape as the read endpoint. Errors 5 not_found404No such article. title_required422The title is missing in one of the live languages. route_in_use422The slug is taken by another article in that language. vetoed422A hook refused the save. 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/knowledgebase/articles/12' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"draft","translations":{"en":{"title":"How to reset your password","content":"

    Updated steps.

    "}}}' ``` ```javascript const cur = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/articles/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }).then((r) => r.json()); cur.data.translations.en.content = html; const res = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/articles/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(cur.data), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/articles/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($article), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // It is named PATCH and behaves as a FULL WRITE: the translations are saved again for every language. $a = Api::Knowledgebase()->GetKnowledgebaseArticle(['id' => $id])['data']; $a['translations']['en']['content'] = $newHtml; Api::Knowledgebase()->UpdateKnowledgebaseArticle($a + ['id' => $id]); ``` #### Removing an Article delete/api/v1/admin/knowledgebase/articles/{id} `Knowledgebase/DeleteKnowledgebaseArticle` admin Removes an article, every translation of it and its header image. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the article removed. Errors 3 not_found404No such article. vetoed422A hook refused the delete. 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/knowledgebase/articles/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/articles/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/articles/' . $id); 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 // The slug goes as well: old links to that address land on a not-found page. Api::Knowledgebase()->UpdateKnowledgebaseArticle(['id' => $id, 'status' => 'draft'] + $article); // moving it to a draft rather than removing keeps the address ``` ### Pitfalls > **The update rewrites the translation set** > > The update body is the **whole article**. Sending one language alone leaves the others unsaved and they go. Take the full set from the read endpoint first, change it there, and send all of it back. > **A missing language stops the save outright** > > A title is needed in every live language, and one missing gives `422` while **no language is saved**. Articles that exist carry no title in a language added later, so the first edit meets this error. Take the language list from the reference endpoint and build the body from it. > **The slug is unique per language** > > Using one slug on two articles gives `route_in_use`. The check is **per language**, so a slug taken in English can be free in another. During a bulk import the clash appears in one language alone and stops the whole save. > **A private article is member-only rather than hidden** > > The private field limits an article to **signed-in clients** and it still shows in the panel and in search. Move the state to a draft when you mean to take content out of sight entirely. > **Removing breaks the old links** > > Removing an article takes the slug with it and outside links to that address land on a not-found page. Moving one that search engines know to a **draft** is often better. The content goes out of sight and the address stays. ### Related Articles - [Help Categories](https://dev.wisecp.com/en/help-categories) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) ## Help Categories https://dev.wisecp.com/en/help-categories The five endpoints that open, read, update and remove help categories. ### Overview Categories group the help articles and **build a tree**. Every category can hang from another, and the ones at the root carry a parent of zero. They follow the same contract as the articles. A title is wanted in every live language and a slug is optional, unique per language. An update writes the whole record. One thing differs: a category's search engine fields are kept while **indexing is on**. Fields filled in while it is off are never saved. ### Reference #### Listing the Categories get/api/v1/admin/knowledgebase/categories `Knowledgebase/GetKnowledgebaseCategories` admin Returns the help categories. Query 3 searchstringSearches the title. pageintWhich page. limitintRecords per page. 100 at the most. Response fields data[] — 5 + meta — 3 idintThe category id. titlestringThe title. In the panel's current language. routestringThe address slug. statusstringWhether the category is on. iconstringThe icon class. totalintHow many categories there are. It comes back under meta. pageintThe page you are on. limitintThe page size. 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/knowledgebase/categories' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/knowledgebase/categories', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const byId = Object.fromEntries(data.map((c) => [c.id, c.title])); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/categories'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The listing holds NO parent id: building the tree wants the detail of every category. $rows = Api::Knowledgebase()->GetKnowledgebaseCategories()['data']; foreach ($rows as $c) $parent[$c['id']] = Api::Knowledgebase()->GetKnowledgebaseCategory(['cid' => $c['id']])['data']['parent_id']; ``` #### Opening a Category post/api/v1/admin/knowledgebase/categories `Knowledgebase/CreateKnowledgebaseCategory` admin every language needed Opens a new help category. Body 6 translationsobjectreq translations.The content per language. A title is needed in every live language. titlestringThe category title. It is needed in every live language. routestringThe address slug. Left empty it comes from the title. sub_titlestringThe subtitle. contentstringThe category description. seo_titlestringThe search engine title. The search engine fields are kept while indexing is on. seo_keywordsstringThe search engine keywords. seo_descriptionstringThe search engine description. parent_idintThe parent category. Zero means the root. statusstringWhether the category is on. rankintWhere it sits in the listing. iconstringThe icon class. seo_indexboolWhether the search engine fields are kept and indexed. Response fields 201 — data — 7 dataobjectThe category made. Same shape as the read endpoint, and the new id comes under meta as well. Errors 5 title_required422The title is missing in one of the live languages. route_in_use422The slug is taken by another category in that language. invalid_parent422A category cannot be its own parent. vetoed422A hook refused the save. 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/knowledgebase/categories' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"parent_id":0,"icon":"bi bi-book","translations":{"en":{"title":"Billing"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/knowledgebase/categories', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ parent_id: 0, icon: 'bi bi-book', seo_index: true, translations: { en: { title: 'Billing', sub_title: 'Invoices and payments' } }, }), }); const { data, meta } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/categories'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'parent_id' => 0, 'translations' => ['en' => ['title' => 'Billing']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The search engine fields are not kept while indexing is OFF: filling them in loses them. Api::Knowledgebase()->CreateKnowledgebaseCategory([ 'seo_index' => true, 'translations' => ['en' => ['title' => 'Billing', 'seo_title' => 'Billing help']], ]); ``` #### Reading a Category get/api/v1/admin/knowledgebase/categories/{cid} `Knowledgebase/GetKnowledgebaseCategory` admin Returns a category with all of its languages. Response fields data — 7 idintThe category id. parent_idintThe parent category. statusstringWhether the category is on. rankintWhere it sits in the listing. iconstringThe icon class. seo_indexboolWhether search engine indexing is on. translationsobject translations.The content per language. titlestringThe category title. It is needed in every live language. routestringThe address slug. Left empty it comes from the title. sub_titlestringThe subtitle. contentstringThe category description. seo_titlestringThe search engine title. The search engine fields are kept while indexing is on. seo_keywordsstringThe search engine keywords. seo_descriptionstringThe search engine description. Errors 2 not_found404No such category. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/knowledgebase/categories/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/categories/${cid}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/categories/' . $cid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The parent id comes HERE alone; call this for every category while building the tree. $c = Api::Knowledgebase()->GetKnowledgebaseCategory(['cid' => $cid])['data']; $isRoot = (int) $c['parent_id'] === 0; ``` #### Updating a Category patch/api/v1/admin/knowledgebase/categories/{cid} `Knowledgebase/UpdateKnowledgebaseCategory` admin a full write Rewrites the category in full. Body 6 translationsobjectreq translations.The content per language. The set is rewritten and a language you leave out is lost. titlestringThe category title. It is needed in every live language. routestringThe address slug. Left empty it comes from the title. sub_titlestringThe subtitle. contentstringThe category description. seo_titlestringThe search engine title. The search engine fields are kept while indexing is on. seo_keywordsstringThe search engine keywords. seo_descriptionstringThe search engine description. parent_idintThe parent category. Zero means the root. statusstringWhether the category is on. rankintWhere it sits in the listing. iconstringThe icon class. seo_indexboolWhether the search engine fields are kept and indexed. Response fields data — 7 dataobjectThe category as it now stands. Same shape as the read endpoint. Errors 6 not_found404No such category. title_required422The title is missing in one of the live languages. route_in_use422The slug is taken by another category in that language. invalid_parent422A category cannot be its own parent. vetoed422A hook refused the save. 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/knowledgebase/categories/3' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"inactive","translations":{"en":{"title":"Billing"}}}' ``` ```javascript const cur = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/categories/${cid}`, { headers: { Authorization: `Bearer ${apiKey}` }, }).then((r) => r.json()); const res = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/categories/${cid}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ...cur.data, rank: 2 }), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/categories/' . $cid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($category), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Closing a category DOES NOT remove its articles, and a client can no longer reach them through it. $c = Api::Knowledgebase()->GetKnowledgebaseCategory(['cid' => $cid])['data']; $c['status'] = 'inactive'; Api::Knowledgebase()->UpdateKnowledgebaseCategory($c + ['cid' => $cid]); ``` #### Removing a Category delete/api/v1/admin/knowledgebase/categories/{cid} `Knowledgebase/DeleteKnowledgebaseCategory` admin Removes a category and every translation of it. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the category removed. Errors 3 not_found404No such category. vetoed422A hook refused the delete. 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/knowledgebase/categories/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/knowledgebase/categories/${cid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/knowledgebase/categories/' . $cid); 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 // The articles inside STAY and fall out of any category; move them elsewhere first. $arts = Api::Knowledgebase()->GetKnowledgebaseArticles()['data']; foreach ($arts as $a) { /* move the ones whose category is $cid */ } Api::Knowledgebase()->DeleteKnowledgebaseCategory(['cid' => $cid]); ``` ### Pitfalls > **The listing is not enough to build the tree** > > The category listing **does not carry** the parent id; it gives the id, title, slug, state and icon alone. Building the tree wants each category read on its own, which on a large knowledge base means one call per category. > **The update rewrites the translation set** > > As with the articles the update body is the **whole category**. Read the current state first and write over it, even to change the order alone. The languages and fields you leave out go empty. > **The search engine fields are not saved while indexing is off** > > The search engine title, keywords and description stay only while **indexing is on**. Filling them in while it is off raises no error and the values are not saved, showing empty on the next read. Turn indexing on first. > **Removing a category orphans its articles** > > Removing a category **does not remove** the articles inside. They fall out of any category, and a client cannot reach them from the category listing. The articles are still found by address and by search while the navigation breaks. Move them first. > **The self-parent check looks one step deep** > > A category cannot be its own parent and that is checked. Longer loops carry **no such guard**: A under B with B under A passes. Take care not to build a cycle while rearranging the tree. ### Related Articles - [Help Articles](https://dev.wisecp.com/en/help-articles) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) # API / Admin API / Languages ## Language Packages https://dev.wisecp.com/en/language-packages The seven endpoints that see, copy, set up and remove the installed languages. ### Overview A language lives in three places at once in WISECP: the **package file** (name, country, order, state), the **translation files** and the **language tables** holding product names, category titles and notification templates. This article manages the first and the whole. A new language is never born empty; it is set up by **copying** one that is installed. The copy takes both the table rows and the file tree, which makes it heavy work. One language is the **default** and that one is privileged: it cannot be closed, cannot be removed, and carries no language prefix in its addresses. Moving the default swaps those three behaviours between two languages. ### Reference #### Listing the Installed Languages get/api/v1/admin/languages `Languages/GetLanguages` admin Returns every language on the installation, the closed ones included. Response fields data[] — 15 + meta — 2 keystringThe language key. This is what the addresses carry. namestringThe language name in English. show_namestringThe name shown to a client. codestringThe language code. code_hyphenstringThe language and country code together. country_idintThe country id. Look to the reference endpoints for its name. country_codestringThe country code. statusboolWhether the language is on. localboolWhether it is the default. One language on the installation carries this. rtlboolWhether the language reads right to left. rankintWhere it sits in the listing. permalinkboolWhether permalinks are supported. prefixstringWhere the address prefix stands. Off on the default language and on for the rest. copiedstringThe name of the language it was copied from. created_atstringWhen it was made. countintHow many languages there are. It comes back under meta. defaultstringThe default language key. 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/languages' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/languages', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); const live = data.filter((l) => l.status); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The closed ones come too; filter on status for the languages open to a client. $r = Api::Languages()->GetLanguages(); $live = array_values(array_filter($r['data'], fn ($l) => $l['status'])); $def = $r['meta']['default']; ``` #### Listing What Can Be Downloaded get/api/v1/admin/languages/available `Languages/GetAvailableLanguages` admin a remote service Returns the languages the WISECP translation service offers. Response fields data[] + meta — 1 dataobject[]The language definitions the remote service returns. An empty list comes back when the service cannot be reached. countintHow many came back. It comes back under meta. 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/languages/available' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/languages/available', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); if (meta.count === 0) showRetry(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/available'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // AN EMPTY LIST means one of two things: none exist or the remote service failed. They look alike. $out = Api::Languages()->GetAvailableLanguages(); if (! $out['data']) $logger->warning('empty language list — check the service is reachable'); ``` #### Reading One Language get/api/v1/admin/languages/{key} `Languages/GetLanguage` admin Returns one language package. Response fields data — 15 dataobjectThe language package. Same shape as an item in the listing. Errors 2 language_not_found404No such language. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/languages/de' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The key is not the language CODE: a second country under one code takes the 'en-gb' form. $pkg = Api::Languages()->GetLanguage(['key' => $key])['data']; ``` #### Making a Language by Copying post/api/v1/admin/languages `Languages/CreateLanguage` admin heavy work Sets up a new language by copying one that is installed. Body 6 copy_languagestringreqThe key of the language to copy. It has to be installed. languagestringreqThe new language code. It needs letters; digits alone are turned down. country_idintreqThe country id. show_namestringreqThe name to show a client. rankintWhere it sits in the listing. rtlintSend 1 when it reads right to left. Response fields 201 — data — 15 dataobjectThe language package set up. Read its key here; it can differ from the code you sent. Errors 7 country_required422The country id is missing. language_required422The language code is empty or all digits. copy_language_invalid422The source language is not installed. show_name_required422The display name is empty. language_exists422That language and country pair is already installed. copy_tables_failed500The language table rows could not be copied. 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/languages' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"copy_language":"en","language":"de","country_id":276,"show_name":"Deutsch"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/languages', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ copy_language: 'en', language: 'de', country_id: 276, show_name: 'Deutsch', rank: 2, }), }); const { data } = await res.json(); const realKey = data.key; // 'de' olmayabilir ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'copy_language' => 'en', 'language' => 'de', 'country_id' => 276, 'show_name' => 'Deutsch', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // When the code you send IS already installed the key becomes 'code-country'; read it, do not assume. $pkg = Api::Languages()->CreateLanguage([ 'copy_language' => 'en', 'language' => 'en', 'country_id' => 826, 'show_name' => 'English (UK)', ])['data']; $key = $pkg['key']; // 'en-gb' ``` #### Changing a Language's Settings patch/api/v1/admin/languages/{key} `Languages/UpdateLanguage` admin promoting is heavy Changes the fields you send and can promote the language to default. Body 5 show_namestringThe name shown to a client. It cannot be empty once sent. rankintWhere it sits in the listing. rtlintSend 1 when it reads right to left. statusintWhether the language is on. It is ignored on the default language. localintSend 1 to make this the default. It changes the address prefix of two languages at once. Response fields data — 15 dataobjectThe language package as it now stands. Errors 3 language_not_found404No such language. show_name_required422The name you sent is empty. 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/languages/de' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"show_name":"Deutsch","rank":2}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ show_name: 'Deutsch', rank: 2 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['show_name' => 'Deutsch', 'rank' => 2]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On the DEFAULT language status is IGNORED: the answer is 200 while the language stays on. $pkg = Api::Languages()->UpdateLanguage(['key' => $key, 'status' => 0])['data']; if ($pkg['status']) $logger->info('the default language cannot be closed — nothing changed'); ``` #### Turning a Language On and Off put/api/v1/admin/languages/{key}/status `Languages/SetLanguageStatus` admin Opens a language to clients or closes it. Body 1 statusintreqThe new state: 0 for off and 1 for on. Response fields data — 2 keystringThe language key. statusboolWhere it now stands. Errors 5 language_not_found404No such language. invalid_status422The state is neither 0 nor 1. language_is_default422The default language cannot be closed. no_change422The language already sits in the state you asked for. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/languages/de/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":1}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 1 }), }); if (res.status === 422) { /* zaten aciksa buraya duser */ } ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Writing the same state again is an ERROR; read the current one first during a bulk sync. $pkg = Api::Languages()->GetLanguage(['key' => $key])['data']; if ($pkg['status'] !== $wanted) Api::Languages()->SetLanguageStatus(['key' => $key, 'status' => (int) $wanted]); ``` #### Removing a Language delete/api/v1/admin/languages/{key} `Languages/DeleteLanguage` admin cannot be undone Removes a language, its translations and every row belonging to it. Response fields data — 2 deletedboolWhether the delete ran. keystringThe key of the language removed. Errors 4 language_not_found404No such language. language_is_default422The default language cannot be removed. remove_tables_failed500The language table rows could not be removed. 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/languages/de' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key); 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 // The product and category names IN THAT LANGUAGE go as well; export and keep them first. $dump = Api::Languages()->ExportLanguageTranslations(['key' => $key])['data']; file_put_contents("backup-$key.json", json_encode($dump)); Api::Languages()->DeleteLanguage(['key' => $key]); ``` ### Pitfalls > **The key made is not always the code you sent** > > When the language code you send is **already installed**, the new key takes the `code-country` form. Setting up a second English makes `en-gb` rather than `en`. Read the key from the answer, since every later call wants it. > **Moving the default changes the addresses of two languages** > > Making a language the default **opens** the prefix on the old default and **closes** it on the new one. The public addresses of both languages change and the old links shift. The job also rewrites the settings file and forces the new language open. Do not run it unplanned on a live installation. > **Two paths handle one state differently** > > The status endpoint turns down closing the default language with `422`. The settings endpoint **ignores** the same request quietly and answers `200` while the language stays open. A script mixing the two believes it closed something. Look at the state in the answer. > **Writing the same state gives an error** > > The status endpoint answers `422` with `no_change` when the state you ask for is the one already there. The endpoint is not repeatable: a sync loop setting every language to open errors on the ones that are open. Read the current state before writing. > **Removing takes more than the translations** > > Removing a language takes the translation files, the notification templates and the **language table rows**: the product names, category titles and page content in that language go with it. The only way back is a backup. Export the translations first. > **An empty download list can mean a failure** > > The downloadable languages come from a remote service. When it cannot be reached the endpoint raises no error and returns an **empty list**. That makes "no languages" and "the service failed" look alike. Leave a way to retry before showing an empty result as "nothing to pick". ### Related Articles - [Translation Strings](https://dev.wisecp.com/en/translation-strings) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) ## Translation Strings https://dev.wisecp.com/en/translation-strings The five endpoints that search, edit, remove and move translation strings. ### Overview Translation strings live in **files** rather than the database. Every string has a **full key**, and that key also says which file holds it, as in `needs/button-save`. The head of the key places a string in one of three sets: the client side, the admin panel and the system text left over. A search result comes back grouped under those three. There are two ways to write, and they **differ on purpose**. The bulk edit is strict: seeing one key it does not know, it writes nothing. The import is forgiving: it skips what it does not know and writes the rest. ### Reference #### Searching the Translations get/api/v1/admin/languages/{key}/translations `Languages/GetLanguageTranslations` admin 100 matches at most Returns the translation strings carrying your text, in groups. Query 1 searchstringreqThe text to look for. Three characters at the least, and the case matters. Response fields data — 3 + meta — 3 clientobject clientThe matches on the client side. totalintHow many matched in the group. dataobjectA map pairing the full key with its value. adminobjectThe matches in the admin panel. systemobjectEvery other match. Error messages and shared text. totalintHow many were gathered. It stops at a hundred, and the true total can be larger. searchstringThe text you looked for. truncatedboolWhether the hundred limit was reached. Errors 3 language_not_found404No such language. search_too_short422The search text is under three characters. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/languages/de/translations?search=Save' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL(`https://panel.example.com/api/v1/admin/languages/${key}/translations`); url.searchParams.set('search', term); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const { data, meta } = await res.json(); if (meta.truncated) narrowTheSearch(); ``` ```php $qs = http_build_query(['search' => $term]); $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key . '/translations?' . $qs); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The search is CASE SENSITIVE: 'save' and 'Save' do not give the same result. $r = Api::Languages()->GetLanguageTranslations(['key' => $key], ['search' => 'Save']); if ($r['meta']['truncated']) $logger->info('the result stopped at a hundred — narrow the search'); ``` #### Editing Translations in Bulk put/api/v1/admin/languages/{key}/translations `Languages/UpdateLanguageTranslations` admin strict Writes the values of the keys you give and leaves the rest alone. Body 1 translationsobjectreqA map pairing the full key with its new value. One invalid key turns the whole request down. Response fields data — 2 updatedintHow many keys were written. languagestringThe language key. Errors 4 language_not_found404No such language. translations_required422The map is empty. invalid_translation_key422A key points at a file that does not exist. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/languages/de/translations' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"translations":{"needs/button-save":"Speichern"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}/translations`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ translations: { 'needs/button-save': 'Speichern', 'admin/services/page-list': 'Dienste', }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key . '/translations'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'translations' => ['needs/button-save' => 'Speichern'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // ONE invalid key turns the whole batch down; pick the import endpoint for bulk loading. Api::Languages()->UpdateLanguageTranslations([ 'key' => $key, 'translations' => ['needs/button-save' => 'Speichern'], ]); ``` #### Removing One Translation delete/api/v1/admin/languages/{key}/translations `Languages/DeleteLanguageTranslation` admin Takes one translation string out of its file. Body 1 keystringreqThe full translation key to remove. Do not mix it up with the language key in the address. Response fields data — 3 deletedboolWhether the delete ran. keystringThe translation key removed. languagestringThe language key. Errors 5 language_not_found404No such language. key_required422No translation key was given. invalid_translation_key422The translation key is not valid. translation_file_not_found404The file the key points at does not exist. 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/languages/de/translations' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"key":"needs/button-save"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${lang}/translations`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ key: 'needs/button-save' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $lang . '/translations'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['key' => 'needs/button-save']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A screen asking for a removed key prints EMPTY text; consider correcting rather than clearing it. Api::Languages()->UpdateLanguageTranslations([ 'key' => $lang, 'translations' => ['needs/button-save' => 'Speichern'], ]); ``` #### Exporting the Translations get/api/v1/admin/languages/{key}/translations/export `Languages/ExportLanguageTranslations` admin Returns every translation string of a language as one flat map. Response fields data + meta — 2 dataobjectA flat map pairing the full key with its value. It can go straight into the import endpoint. countintHow many strings there are. It comes back under meta. languagestringThe language key. Errors 2 language_not_found404No such language. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/languages/de/translations/export' \ -H "Authorization: Bearer $API_KEY" > de.json ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}/translations/export`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); console.log(`${meta.count} dize`); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key . '/translations/export'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The output is JSON and NOT a spreadsheet; the sheet format the panel offers is absent here. $dump = Api::Languages()->ExportLanguageTranslations(['key' => $key])['data']; file_put_contents("$key.json", json_encode($dump, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); ``` #### Importing the Translations post/api/v1/admin/languages/{key}/translations/import `Languages/ImportLanguageTranslations` admin forgiving Writes a translation map in bulk and skips the keys it does not know. Body 1 translationsobjectreqA map pairing the full key with its value. The same shape the export produces. Response fields data — 4 languagestringThe language key. translationsintHow many keys you sent. applied_filesintHow many files were written. skipped_filesarrayThe keys skipped for want of a target file. This is the one sign of a quiet loss. Errors 3 language_not_found404No such language. translations_required422The map is empty. 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/languages/de/translations/import' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"translations":{"needs/button-save":"Speichern"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/languages/${key}/translations/import`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ translations: map }), }); const { data } = await res.json(); if (data.skipped_files.length) reportSkipped(data.skipped_files); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/languages/' . $key . '/translations/import'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['translations' => $map]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A key it does not know is SKIPPED quietly; without counting them a missing translation goes unseen. $out = Api::Languages()->ImportLanguageTranslations(['key' => $key, 'translations' => $map])['data']; if ($out['skipped_files']) $logger->warning('skipped key', $out['skipped_files']); ``` ### Pitfalls > **Bulk edit and import meet one error differently** > > The bulk edit turns down the **whole batch** over one invalid key and writes nothing. The import skips that key quietly and writes the rest. Pick the first for careful editing and the second when loading an outside file. > **Skipped keys show in the answer alone** > > The import lists what it skipped under `skipped_files` and says so nowhere else. A script that does not read the answer **never learns** it left part of the translations unwritten. Check that field on every run. > **The search count is not a total** > > The search stops at the hundredth match and the `total` field gives what was **gathered** rather than what exists. A true `truncated` means there is more and how much stays unknown. Do not tie that number to a progress readout. > **The search is case sensitive** > > The search text is matched as it stands: looking for `save` does not find the string `Save`. Search each spelling of a word on its own when replacing text across the installation. > **Two different keys in one request** > > On the removal endpoint the address carries the **language key** and the body the **translation key**. Both go by the name `key`. Writing the language key into the body gives an invalid translation key error, which is not the language going missing. > **The export does not give a spreadsheet** > > The spreadsheet output and the remote pull that the panel offers are **absent** here; the export returns a flat map and the import expects the same map. The two mirror each other, so a language exported from one installation loads into another as it stands. ### Related Articles - [Language Packages](https://dev.wisecp.com/en/language-packages) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) # API / Admin API / Modules ## Module Catalogue https://dev.wisecp.com/en/module-catalogue The five endpoints managing which modules exist and which ones run. ### Overview Everywhere WISECP speaks to the outside world is a module: taking payment, registering domains, sending mail and messages, checking for fraud, and the product types. This article answers **which modules exist and which are running**. Six groups exist and **they are not picked the same way**. The payment and product groups run several modules at once, while the mail, domain and message groups pick one. The fraud group turns each module on by itself. That difference reaches the endpoints: the group-wide pick sits on one and the per-module switch on another. Which applies is decided by the group. ### Reference #### Listing a Group's Modules get/api/v1/admin/modules/{group} `Modules/GetModules` admin Returns the modules in a group along with the group's live picks. Query 2 statusstringThe filter: `active` or `passive`. searchstringSearches the module name and key. Response fields data[] — 8 + meta — 6 keystringThe module key. This is what the addresses carry. namestringThe module name. descriptionstringA short description. authorstringWho wrote it. versionstringIts version. activeboolWhether the installation uses it. premiumboolWhether it is a paid module. logostringThe full address of its logo. countintHow many came back. It comes back under meta. groupstringThe group key. activestring[]The module keys the installation uses. card_storage_modulestringThe payment module keeping cards. It comes on the payment group alone. default_modulestringThe group's single pick. It comes on the mail, message and domain groups. intl_modulestringThe module for messages abroad. It comes on the message group alone. Errors 2 unknown_group404No such module group. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/modules/payment?status=active' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/modules/payment?status=active', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); console.log(meta.active, meta.card_storage_module); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/payment?status=active'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Six groups exist: payment, registrars, mail, sms, fraud, product. Anything else gives 404. foreach (['payment', 'registrars', 'mail', 'sms', 'fraud', 'product'] as $g) $all[$g] = Api::Modules()->GetModules(['group' => $g])['meta']['active']; ``` #### Reading One Module get/api/v1/admin/modules/{group}/{module} `Modules/GetModule` admin Returns a module's summary, its fields and what it can do. Response fields data — 11 keystringThe module key. This is what the addresses carry. namestringThe module name. descriptionstringA short description. authorstringWho wrote it. versionstringIts version. activeboolWhether the installation uses it. premiumboolWhether it is a paid module. logostringThe full address of its logo. fieldsobject fieldsThe configuration fields. A password field has its value masked. typestringThe field type. namestringThe field label. descriptionstringWhat the field is for. valuestringIts current value. optionsarrayThe choices on a field that has them. checkedboolWhether a tick field is ticked. capabilitiesobjectWhat the module supports: configuration fields, a connection test and keeping records. payment_optionsobjectThe payment settings. They come on the payment group alone. Errors 3 unknown_group404No such module group. module_not_found404No such module. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/modules/payment/Stripe' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); if (data.capabilities.has_test_connection) showTestButton(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Build a form from the capabilities first: not every module has fields or a connection test. $m = Api::Modules()->GetModule(['group' => $group, 'module' => $key])['data']; if (! $m['capabilities']['has_config_fields']) $form = null; ``` #### Choosing a Group's Live Modules put/api/v1/admin/modules/{group}/activation `Modules/UpdateModuleActivation` admin the set is replaced Writes which modules a group uses. Body 8 modulesstring[]The keys to make live. On the payment and product groups; the list you send replaces what was there. card_storage_modulestringThe payment module to keep cards. It joins the live list when it is not in it. modulestringThe single pick. On the mail, domain and message groups; sending it empty clears the pick. module_intlstringThe module for messages abroad. intl_sms_serviceintThe channel for messages abroad. sms_api_serviceintThe channel for the message interface. turkey_sms_serviceintThe channel for messages inside Turkey. It applies when the installation's default language is Turkish. Response fields data — 6 groupstringThe group key. activestring[]The keys now live. activatedstring[]What this call turned on. deactivatedstring[]What this call turned off. card_storage_modulestringThe payment module keeping cards. modulestringThe single module picked. Errors 5 unknown_group404No such module group. module_not_found422One of the keys you sent is absent from that group. activation_not_supported422The group does not take a group-wide pick. blocked_by_gate422A hook refused to turn it on. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/modules/payment/activation' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"modules":["Free","Stripe"],"card_storage_module":"Stripe"}' ``` ```javascript const now = await fetch('https://panel.example.com/api/v1/admin/modules/payment', { headers: { Authorization: `Bearer ${apiKey}` }, }).then((r) => r.json()); const res = await fetch('https://panel.example.com/api/v1/admin/modules/payment/activation', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ modules: [...now.meta.active, 'Stripe'] }), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/payment/activation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['modules' => $keys]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list REPLACES the set: sending one key turns every other payment module off. $live = Api::Modules()->GetModules(['group' => 'payment'])['meta']['active']; Api::Modules()->UpdateModuleActivation([ 'group' => 'payment', 'modules' => array_values(array_unique([...$live, 'Stripe'])), ]); ``` #### Turning One Module On and Off put/api/v1/admin/modules/{group}/{module}/status `Modules/SetModuleStatus` admin two groups Turns a fraud or product module on and off one at a time. Body 1 enabledintreqThe new state: 1 for on and 0 for off. Response fields data — 11 dataobjectThe module read afresh. Same shape as the read endpoint. Errors 6 unknown_group404No such module group. enabled_required422The state field is missing. not_supported422The group does not take a per-module switch. module_error422The module refused to open. Usually a missing credential. blocked_by_gate422A hook refused to turn it on. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/modules/fraud/MaxMind/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":1}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: 1 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On a fraud module OPENING checks the credentials first; write the settings BEFORE that. Api::Modules()->UpdateModuleSettings([ 'group' => 'fraud', 'module' => $key, 'settings' => ['license_key' => $lic], ]); Api::Modules()->SetModuleStatus(['group' => 'fraud', 'module' => $key, 'enabled' => 1]); ``` #### Removing a Module delete/api/v1/admin/modules/{group}/{module} `Modules/DeleteModule` admin it deletes files Takes a module's files off the server. Response fields data — 3 deletedboolWhether the delete ran. groupstringThe group key. keystringThe key of the module removed. Errors 5 unknown_group404No such module group. module_not_found404No such module. blocked_by_gate422A hook refused the delete. removal_failed422The files could not be removed. What is left needs clearing by hand. 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/modules/payment/Stripe' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key); 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 // It takes the settings and the credentials too; export the configuration and keep it first. $cfg = Api::Modules()->GetModuleConfig(['group' => $group, 'module' => $key])['data']; file_put_contents("$key.json", json_encode($cfg)); Api::Modules()->DeleteModule(['group' => $group, 'module' => $key]); ``` ### Pitfalls > **The group pick replaces the set** > > On the payment and product groups the list you send **replaces** the old one. Sending only the module you meant to add turns the others off, and clients stop seeing those payment methods. Read the list first and add to it. > **Not every group opens from the same endpoint** > > The fraud modules **refuse** the group-pick endpoint and answer `activation_not_supported`; they open one at a time. The product group takes both roads. Sort the group first when writing a general management tool. > **Picking a card keeper also turns it on** > > Naming the payment module that keeps cards **adds it to the live list** even when it was not there. Writing one field can open a payment method to clients. Read the live list in the answer and compare it against the set you expected. > **Opening wants a credential** > > Opening a fraud module checks its credentials, and the call comes back with `module_error` when one is missing. Order matters during setup: **write the settings first** and open afterwards. The other way round fails, and the error comes from the module itself. > **Removing takes the files off the server** > > The removal endpoint deletes the module's directory, and the settings and credentials go with it. When the files come away only in part the answer is `removal_failed` and a **half-removed directory** stays behind. Export the configuration before removing. ### Related Articles - [Module Settings and Calls](https://dev.wisecp.com/en/module-settings-and-calls) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) ## Module Settings and Calls https://dev.wisecp.com/en/module-settings-and-calls The six endpoints covering one module's settings, test, methods and records. ### Overview This article gathers the endpoints reaching **inside** a single module: reading and writing its settings, testing its connection, calling its own methods and seeing the records it keeps. There are **two ways** to write settings and the difference matters. The settings endpoint follows the road the panel saves by, so the module's filters, validation and hooks all run. The configuration endpoint writes straight to the file. Secrets never leave the server. Every password-like value comes back as a mask, and sending that same mask back means "leave this field alone". ### Reference #### Reading the Raw Configuration get/api/v1/admin/modules/{group}/{module}/config `Modules/GetModuleConfig` admin Returns a module's settings on disk as they stand. Response fields data — 4 groupstringThe group key. keystringThe module key. statusboolWhether the module is on. settingsobjectThe raw settings. A value under a secret-looking key comes back masked. Errors 3 unknown_group404No such module group. module_not_found404No such module. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/modules/mail/Mailjet/config' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}/config`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const masked = Object.entries(data.settings).filter(([, v]) => v === '**********'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key . '/config'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Secrets come back MASKED: this output is NOT a backup, and the keys do not travel to another installation. $cfg = Api::Modules()->GetModuleConfig(['group' => $group, 'module' => $key])['data']; ``` #### Writing the Raw Configuration put/api/v1/admin/modules/{group}/{module}/config `Modules/SaveModuleConfig` admin no validation Merges the keys you send into the module's settings file. Body 2 settingsobjectThe keys to merge in. Sending the mask back keeps the secret. statusintThe module state. It is written on the product and fraud groups. Response fields data — 4 dataobjectThe configuration read afresh. Same shape as the read endpoint. Errors 4 unknown_group404No such module group. module_not_found404No such module. settings_required422Neither settings nor a state was sent. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/modules/mail/Mailjet/config' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"settings":{"from_name":"Support"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}/config`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ settings: { from_name: 'Support' } }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key . '/config'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['settings' => ['from_name' => 'Support']]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint writes STRAIGHT to the file: the module's own validation and hooks DO NOT run. // Pick the settings endpoint for daily work; this one is for recovery and migration. Api::Modules()->SaveModuleConfig([ 'group' => $group, 'module' => $key, 'settings' => ['from_name' => 'Support'], ]); ``` #### Saving the Settings put/api/v1/admin/modules/{group}/{module}/settings `Modules/UpdateModuleSettings` admin the module validates Saves the settings through the module's own validation. Body 7 settingsobjectThe module's configuration fields. Sending the mask back keeps the secret. statusintThe module state. It applies on the payment and fraud groups. commission_ratestringThe rate added for paying this way. Payment alone. force_convert_tointThe currency number amounts turn into. Payment alone. accepted_countriesarrayOpen to these countries alone. Payment alone. unaccepted_countriesarrayClosed to these countries. Payment alone. change_subscription_feeintWhether changing a subscription carries a fee. Payment alone. Response fields data — 11 dataobjectThe module read afresh. Same shape as the read endpoint in the catalogue article. Errors 4 unknown_group404No such module group. module_not_found404No such module. module_error422The module turned the settings down. The message comes from the module. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/modules/payment/Stripe/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"settings":{"api_key":"sk_live_123"},"commission_rate":"2.5"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}/settings`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ settings: { api_key: 'sk_live_123' }, commission_rate: '2.5', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key . '/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['settings' => ['api_key' => $liveKey]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The same road the panel saves by: the module's filters, validation and hooks DO run. Api::Modules()->UpdateModuleSettings([ 'group' => 'payment', 'module' => 'Stripe', 'settings' => ['api_key' => $liveKey], 'commission_rate' => '2.5', ]); ``` #### Testing the Connection post/api/v1/admin/modules/{group}/{module}/test-connection `Modules/TestModuleConnection` admin Tries whether the module reaches the service behind it. Body 1 settingsobjectThe settings to test with. A field you leave out comes from the saved configuration. Response fields data — 3 groupstringThe group key. keystringThe module key. connectedboolWhether the connection was made. Errors 4 unknown_group404No such module group. module_not_found404No such module. module_error422The test failed or the module offers none. 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/modules/payment/Stripe/test-connection' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"settings":{"api_key":"sk_test_123"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}/test-connection`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ settings: { api_key: candidate } }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key . '/test-connection'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['settings' => ['api_key' => $candidate]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The test SAVES NOTHING: try a new key here first and write it with the settings endpoint when it passes. $out = Api::Modules()->TestModuleConnection([ 'group' => $group, 'module' => $key, 'settings' => ['api_key' => $candidate], ])['data']; if ($out['connected']) Api::Modules()->UpdateModuleSettings([ 'group' => $group, 'module' => $key, 'settings' => ['api_key' => $candidate], ]); ``` #### Running a Module Method post/api/v1/admin/modules/{group}/{module}/methods/{method} `Modules/RunModuleMethod` admin module specific Calls a module's own panel method and returns what it gives. Body — *objectA free body belonging to the module. It is handed to the method as it stands. Response fields data — 4 groupstringThe group key. keystringThe module key. methodstringThe method that ran. resultstringWhat the method gave. Text or an object, and the markup the panel shows can come as well. Errors 5 unknown_group404No such module group. module_not_found404No such module. method_required422The method name is empty. method_error422The method was not found or returned an error. 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/modules/product/SampleProduct/methods/crud-list' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"page":1}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/${group}/${key}/methods/${method}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ page: 1 }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/' . $group . '/' . $key . '/methods/' . $method); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['page' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The result can be the PANEL's own output; its shape is the module's choice and not an API contract. $out = Api::Modules()->RunModuleMethod([ 'group' => $group, 'module' => $key, 'method' => $method, 'page' => 1, ])['data']; $isMarkup = is_string($out['result']); ``` #### Reading the Fraud Records get/api/v1/admin/modules/{group}/{module}/records `Modules/GetFraudRecords` admin fraud alone Returns what a fraud module flagged. Query 3 pageintWhich page. limitintRecords per page. 100 at the most. searchstringSearches the records. Response fields data[] — 7 + meta — 4 idintThe record id. user_idintThe id of the client flagged. user_full_namestringThe client's name. user_company_namestringThe client's company. messagestringThe reason the module wrote. ipstringThe address the action came from. created_atstringWhen the record was written. countintHow many came back. It comes back under meta. totalintHow many there are. pageintThe page you are on. limitintThe page size. Errors 4 unknown_group404No such module group. module_not_found404No such module. not_supported422A group other than fraud, or a module keeping no records. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/modules/fraud/MaxMind/records?limit=50' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/modules/fraud/${key}/records`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); const flagged = new Set(data.map((r) => r.user_id)); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/modules/fraud/' . $key . '/records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Keeping records is the module's choice: calling without seeing it in the capabilities gives 422. $m = Api::Modules()->GetModule(['group' => 'fraud', 'module' => $key])['data']; if ($m['capabilities']['has_records']) $rows = Api::Modules()->GetFraudRecords(['group' => 'fraud', 'module' => $key])['data']; ``` ### Pitfalls > **The configuration endpoint skips the module's validation** > > The configuration endpoint writes settings **straight to the file**: the module's field filters, checks and save hooks never run. A value that is not valid goes in without an error and breaks on first real use. Pick the settings endpoint for daily work; the configuration one is for recovery and migration. > **A masked secret is not a backup** > > The read endpoints return secret-looking values as a mask and never give the real one. Exporting a module's configuration and moving it to **another installation** carries no keys; the fields fill with masks there and the module fails. Move the secrets separately. > **The test saves nothing** > > The connection test uses the settings you send for that call alone and **writes nothing** even when it passes. Testing a new key and forgetting to save means the configuration you saw working never existed. Write it with the settings endpoint afterwards. > **The method result has no contract** > > The method endpoint calls the module's panel method and hands the result over as it stands. That result can be an object or the **markup the panel would show**. The module decides the shape and a version can change it, so do not build an integration that parses it. > **Records exist on some fraud modules alone** > > The records endpoint answers `not_supported` outside the fraud group, and not every module in that group keeps records either. Look at the capabilities in the module detail before calling: when it is absent there the endpoint gives an **error** rather than an empty list. ### Related Articles - [Module Catalogue](https://dev.wisecp.com/en/module-catalogue) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) # API / Admin API / Notifications ## Managing Notification Templates https://dev.wisecp.com/en/managing-notification-templates The five endpoints that list, add, read, edit and remove notification templates. ### Overview A notification template lives in two places. Its **behaviour** sits in the settings file: whether it is on, who it reaches and by which channel. Its **text** sits in separate files per language: the subject, the e-mail body and the message. That split reaches the endpoints. The listing gives the behaviour alone, and seeing the text wants one template read on its own. Templates fall into groups and a group brings its own rules: attaching the invoice document means something in the invoice group and is ignored in the others. ### Reference #### Listing the Templates get/api/v1/admin/notifications/templates `Notifications/GetNotificationTemplates` admin Returns every notification template under its group. Response fields data[] — 3 groupstringThe group key. namestringThe group's translated name. templatesarray templates[]The templates in the group. groupstringThe group it sits in. keystringThe template key. namestringIts translated name. statusintWhether the template is on. customboolWhether it was added by hand. user_mailintWhether the client gets an e-mail. admin_mailintWhether the staff get an e-mail. user_smsintWhether the client gets a message. admin_smsintWhether the staff get a message. send_pdfintWhether the invoice document is attached. It comes empty outside the invoice group. emailsstringExtra e-mail recipients. phonesstringExtra phone recipients. departmentsint[]The department ids tied to it. variablesstringThe variables the template can use. 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/notifications/templates' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/notifications/templates', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const flat = data.flatMap((g) => g.templates); const off = flat.filter((t) => ! t.status); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/templates'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list is GROUPED and carries NO content: the subject and body come on the detail endpoint. $groups = Api::Notifications()->GetNotificationTemplates()['data']; $flat = array_merge(...array_column($groups, 'templates')); ``` #### Adding a Template post/api/v1/admin/notifications/templates `Notifications/CreateNotificationTemplate` admin Opens a new template under a group. Body 2 groupstringreqThe group key. keystringreqThe template key. A slash, dot or comma becomes a hyphen. Response fields 201 — data — 14 dataobjectThe template made. Same shape as a template in the listing. Errors 4 group_required422No group was given. key_required422No key was given. already_exists422A template with that group and key exists. 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/notifications/templates' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"group":"account","key":"welcome-message"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/notifications/templates', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ group: 'account', key: 'welcome-message' }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/templates'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['group' => 'account', 'key' => 'welcome-message']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A new template is born EMPTY and the core never sends it by itself; you write the content and the trigger. Api::Notifications()->CreateNotificationTemplate(['group' => 'account', 'key' => 'welcome-message']); Api::Notifications()->UpdateNotificationTemplate([ 'group' => 'account', 'key' => 'welcome-message', 'contents' => ['en' => ['subject' => 'Welcome', 'mail_content' => $html]], ]); ``` #### Reading a Template get/api/v1/admin/notifications/templates/{group}/{key} `Notifications/GetNotificationTemplate` admin Returns a template's settings and its text in every language. Response fields data — 15 groupstringThe group it sits in. keystringThe template key. namestringIts translated name. statusintWhether the template is on. customboolWhether it was added by hand. user_mailintWhether the client gets an e-mail. admin_mailintWhether the staff get an e-mail. user_smsintWhether the client gets a message. admin_smsintWhether the staff get a message. send_pdfintWhether the invoice document is attached. It comes empty outside the invoice group. emailsstringExtra e-mail recipients. phonesstringExtra phone recipients. departmentsint[]The department ids tied to it. variablesstringThe variables the template can use. contentsobject contents.The text per language. subjectstringThe e-mail subject. mail_contentstringThe e-mail body. sms_contentstringThe message text. Errors 2 not_found404No such template. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/notifications/templates/invoice/invoice-created' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/notifications/templates/${group}/${key}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const missing = langs.filter((l) => ! data.contents[l]?.subject); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/templates/' . $group . '/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The variables you may use are written here; another one written into a template goes out as TEXT. $t = Api::Notifications()->GetNotificationTemplate(['group' => $g, 'key' => $k])['data']; $allowed = $t['variables']; ``` #### Updating a Template patch/api/v1/admin/notifications/templates/{group}/{key} `Notifications/UpdateNotificationTemplate` admin Writes the settings and text you send and leaves the rest alone. Body 10 statusintWhether the template is on. user_mailintWhether the client gets an e-mail. admin_mailintWhether the staff get an e-mail. user_smsintWhether the client gets a message. admin_smsintWhether the staff get a message. send_pdfintWhether the invoice document is attached. It is ignored outside the invoice group. emailsstringExtra e-mail recipients. phonesstringExtra phone recipients. departmentsint[]The department ids. The list replaces rather than adds. contentsobject contents.The text per language. subjectstringThe e-mail subject. mail_contentstringThe e-mail body. sms_contentstringThe message text. Response fields data — 15 dataobjectThe template as it now stands. Same shape as the read endpoint. Errors 3 not_found404No such template. config_write_failed422The settings file could not be written. 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/notifications/templates/invoice/invoice-created' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":1,"user_mail":1,"departments":[1,2]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/notifications/templates/${group}/${key}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 1, contents: { en: { subject: 'Your invoice', mail_content: html } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/templates/' . $group . '/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The department list REPLACES what was there; read it first to add one. $t = Api::Notifications()->GetNotificationTemplate(['group' => $g, 'key' => $k])['data']; $t['departments'][] = $newDid; Api::Notifications()->UpdateNotificationTemplate([ 'group' => $g, 'key' => $k, 'departments' => $t['departments'], ]); ``` #### Removing a Template delete/api/v1/admin/notifications/templates/{group}/{key} `Notifications/DeleteNotificationTemplate` admin Removes a template and its text in every language. Response fields data — 3 deletedboolWhether the delete ran. groupstringThe group key. keystringThe key of the template removed. Errors 2 not_found404No such template. 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/notifications/templates/account/welcome-message' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/notifications/templates/${group}/${key}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/templates/' . $group . '/' . $key); 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 // Removing one of the CORE's own templates silences that event entirely; turning the state off is enough. Api::Notifications()->UpdateNotificationTemplate(['group' => $g, 'key' => $k, 'status' => 0]); ``` ### Pitfalls > **The department list replaces what was there** > > The department list you send on an update **replaces** the one there. Sending only the department you meant to add takes the others out and they stop getting the notification. Read it first and merge the list. > **An unknown variable goes out as text** > > The variables a template may use are written on its own record. Writing one that is not in that list **raises no error**; the notification goes out and the client sees the raw braces. Read the list allowed before writing the text. > **A new template is never sent by itself** > > A template added by hand is a record and nothing more: **no event** in the core fires it. A module or a hook has to call it for anything to go out. Turning the template on produces no message on its own. > **Two templates have their state tied to the sign-up setting** > > The on and off state of the e-mail and phone verification templates moves together with the **sign-up verification setting**. Closing the template closes the verification step in the sign-up flow as well. Changing it as though it were a display setting drops verification for new members. > **The document attachment falls away quietly outside its group** > > The setting that attaches the invoice document works in the invoice group alone. Sending it on another group's template **raises no error**: the value is ignored and comes back empty on the read. That is not the save failing. ### Related Articles - [Notification Layout](https://dev.wisecp.com/en/notification-layout) - [Staff Departments](https://dev.wisecp.com/en/staff-departments) - [Language Packages](https://dev.wisecp.com/en/language-packages) ## Notification Layout https://dev.wisecp.com/en/notification-layout The four endpoints managing the frame and logos every notification shares. ### Overview Every notification goes out inside a shared **frame**: a heading at the top, the template's own body in the middle and a sign-off at the bottom. This article manages that frame and the logos inside it. The frame is kept per language, since its words and its direction follow the language. The template engine is one choice for the **whole installation** and decides which syntax the variables use. The write call checks the frame before saving it: an unknown variable or broken syntax is turned down. A broken frame never takes every notification down with it. ### Reference #### Reading the Layout get/api/v1/admin/notifications/settings `Notifications/GetNotificationSettings` admin Returns the frame every notification shares, along with the logos. Response fields data — 3 enginestringThe template engine. It decides which syntax the variables use. layoutobject layout.The frame pieces per language. headerstringThe top piece. contentstringThe middle piece the body lands in. footerstringThe bottom piece. logosobjectThe addresses of the top and bottom logo. Empty when none was uploaded. 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/notifications/settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/notifications/settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const syntax = data.engine; // degiskenlerin yazimi buna bagli ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing the engine changes the variable syntax in EVERY template; read which one you are on first. $cfg = Api::Notifications()->GetNotificationSettings()['data']; $engine = $cfg['engine']; ``` #### Writing the Layout put/api/v1/admin/notifications/settings `Notifications/UpdateNotificationSettings` admin the syntax is checked Changes the template engine and the frame pieces. Body 2 enginestringThe template engine: none, Smarty or Twig. layoutobject layout.The frame pieces per language. Every language sent is checked for its variables and syntax. headerstringThe top piece. contentstringThe middle piece the body lands in. footerstringThe bottom piece. Response fields data — 3 dataobjectThe layout as it now stands. Same shape as the read endpoint. Errors 3 invalid_variable422An unknown variable was used. invalid_syntax422The template syntax is wrong. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/notifications/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"engine":"twig","layout":{"en":{"header":"
    ","content":"{{ content }}","footer":"
    "}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/notifications/settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ engine: 'twig', layout: { en: { header, content: '{{ content }}', footer } }, }), }); if (res.status === 422) showSyntaxError(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['engine' => 'twig', 'layout' => $layout]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The check looks at the frame pieces ALONE: the templates' own bodies go unchecked when the engine changes. Api::Notifications()->UpdateNotificationSettings([ 'engine' => 'twig', 'layout' => $layout, ]); ``` #### Uploading a Logo post/api/v1/admin/notifications/logos/{type} `Notifications/UploadNotificationLogo` admin Uploads the top or bottom logo of the notifications. Body 1 filestringreqThe image. It goes as encoded content or as a remote address. Response fields data — 2 typestringThe logo kind. logostringThe full address of the logo uploaded. Errors 5 invalid_type422The logo kind is neither top nor bottom. file_required422No file was given. file_invalid422The file could not be read. upload_failed422The upload 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/notifications/logos/header' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"file":"data:image/png;base64,iVBORw0KGgo..."}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/notifications/logos/${type}`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ file: dataUri }), }); const { data } = await res.json(); ``` ```php $uri = 'data:image/png;base64,' . base64_encode(file_get_contents($path)); $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/logos/' . $type); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['file' => $uri]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A new logo REMOVES the old one and there is no way back; download and keep the current file first. $cur = Api::Notifications()->GetNotificationSettings()['data']['logos']['header'] ?? ''; if ($cur) file_put_contents('logo-backup.png', file_get_contents($cur)); Api::Notifications()->UploadNotificationLogo(['type' => 'header', 'file' => $uri]); ``` #### Removing a Logo delete/api/v1/admin/notifications/logos/{type} `Notifications/DeleteNotificationLogo` admin Removes the top or bottom logo. Response fields data — 2 typestringThe logo kind. deletedboolWhether the delete ran. Errors 2 invalid_type422The logo kind is neither top nor bottom. 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/notifications/logos/header' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/notifications/logos/${type}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/notifications/logos/' . $type); 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 // Removing the logo DOES NOT remove the image tag in the layout: the frame can show a broken image. Api::Notifications()->DeleteNotificationLogo(['type' => 'header']); // clear the line in the layout as well ``` ### Pitfalls > **Changing the engine reaches every template** > > The template engine is one setting for the whole installation, and changing it changes the variable syntax of **every notification**. The write call checks the frame you send and **never checks** the templates' own bodies. Confirm the template text matches the new syntax before switching. > **A new logo removes the old one** > > Uploading a logo **takes the old file** off the server and no version history is kept. Uploading the wrong image loses the previous one for good. Download the current logo from its address and keep it before uploading. > **Removing a logo does not fix the frame** > > Removing a logo deletes the file and the setting while **leaving the image tag** in the frame. A frame still pointing at that address shows a broken image in the e-mail the client receives. Edit the frame after removing the logo. > **The frame is per language and the engine is not** > > You write the frame pieces per language while the engine is one installation-wide setting. Writing one language's frame for the new engine and leaving the others behind shows raw variable text **in the ones left**. Move every language together when switching engines. > **The check covers the languages sent alone** > > The write call checks the joined frame of every language you send in the body. The languages you leave out **go unchecked** and stay broken where they were. Send every language when you want the whole installation verified. ### Related Articles - [Notification Templates](https://dev.wisecp.com/en/managing-notification-templates) - [Language Packages](https://dev.wisecp.com/en/language-packages) # API / Admin API / Orders ## Placing an Order https://dev.wisecp.com/en/placing-an-order The five endpoints that prepare a basket and build the order. ### Overview Building an order is not one call. Every line in the basket rests on a product's price, its add-ons and its questions, while a domain line has to have been asked about at the registrar. This article covers that preparation and the creation at the end. The order of work: build the line from the product information, ask about the domain when there is one, check any coupon you mean to use, then create the order. Each step feeds the next. The create call does more than open a record: it **brings the services into being** and raises the invoice when asked. Undoing it costs, so do not skip the preparation. ### Reference #### Product Information for the Order Form get/api/v1/admin/orders/product-info `Orders/GetProductInfo` admin Returns a product's prices, add-ons, meters and questions. Query 3 product_idintreqThe product id. client_idintThe client id. The live service counts are worked out for this client. langstringThe language the names come back in. Response fields data — 9 productobject productThe product itself. idintThe product id. typestringThe product type. titlestringThe product name. modulestringThe provider module behind it. stockstringThe stock left. Empty when there is no limit. additional_taxobjectThe additional tax definition. taxexemptintWhether it is tax exempt. optionsobjectThe product and module options. What is inside changes with the module and the type. categoryintThe category id. groupstringThe group key. pricesarray prices[]The prices per period. idintThe price record id. cidintThe currency number. periodstringThe period unit. timeintThe period multiplier. 3 with a month unit means three months. cyclestringThe cycle name worked out from the two above. amountstringThe selling price. Text with four decimals. setupstringThe set-up fee. coststringThe cost. cost_cidintThe cost currency number. promotionstringThe promotional price. promotion_statusintWhether the promotion is on. statusintWhether the price is on. discountstringThe discount value. rankintIts order. addonsarray addons[]The add-ons tied to the product. idintThe add-on id. namestringIts name. descriptionstringIts description. field_typestringThe input type: a list, a quantity, a box or a button. statusstringWhether it is on. categoryintThe category id. mcategorystringThe module category. rankintIts order. taxexemptintWhether it is tax exempt. override_usrcurrencyintWhether it overrides the client currency. product_id_linkintThe product id it links to. product_type_linkstringThe product type it links to. icon_typestringThe icon type. iconstringThe icon value. list_templateintThe listing template number. propertiesobjectThe rules for the input type: visibility, buying more than one, whether it is required, and the quantity limits. optionsarrayThe choices and their prices. A price comes either in plain fields or per currency. requirementsarrayThe questions belonging to the add-on. The same shape as the questions below. addon_active_servicesintHow many live records the client has of this add-on. metricsarray metrics[]The meters charged by use. idintThe meter id. owner_idintThe product it belongs to. typestringThe meter key. schemestringHow it is charged. labelstringThe name shown. unitstringIts unit. max_valueintIts ceiling. includedstringHow much the package includes. sort_orderintIts order. pricingobjectThe tiered prices. Every tier carries a start and an end, and the prices sit under the currency CODE. requirementsarray requirements[]The fields asked of the client at order time. idintThe question id. namestringThe question name. descriptionstringIts description. field_typestringThe input type. statusstringWhether it is on. categoryintThe category id. mcategorystringThe module category. rankintIts order. module_co_namesobjectThe field name it maps to per module. propertiesobjectThe rules. Whether it is required is written here. optionsarrayThe choices on a field that has them. product_active_servicesintHow many live services the client has of this product. category_active_servicesintHow many live services in this category. group_active_servicesintHow many live services in this group. total_active_servicesintHow many live services the client has in all. Errors 3 invalid_product422No product id was given. not_found404No such product. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/orders/product-info?product_id=12&client_id=94' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/orders/product-info'); url.searchParams.set('product_id', productId); url.searchParams.set('client_id', clientId); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const { data } = await res.json(); const required = data.requirements.filter((r) => r.properties.compulsory); ``` ```php $qs = http_build_query(['product_id' => $productId, 'client_id' => $clientId]); $ch = curl_init('https://panel.example.com/api/v1/admin/orders/product-info?' . $qs); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The required questions are written HERE; fill this list before building the order. $info = Api::Orders()->GetProductInfo([], ['product_id' => $pid, 'client_id' => $cid])['data']; $must = array_filter($info['requirements'], fn ($r) => $r['properties']['compulsory'] ?? false); ``` #### Asking Whether a Domain Is Free post/api/v1/admin/orders/check-domain `Orders/CheckDomain` admin it asks the registrar Returns whether a domain can be taken and what the extension costs. Body 2 domainstringreqThe full domain name. client_idintThe client id. It is needed for the live service counts. Response fields data — 9 domainstringThe domain asked about. sldstringThe body of the name. tldstringIts extension. availableboolWhether it can be registered. The registrar is asked live. tld_infoobject tld_infoThe extension's settings. idintThe extension id. namestringThe extension. min_yearsintThe shortest term. max_yearsintThe longest term. dns_manageintWhether name management is offered. forwardingintWhether forwarding is offered. whois_privacyintWhether registration privacy is offered. epp_codeintWhether a transfer needs a code. modulestringThe registrar module behind it. pricesobject prices.[]The register, transfer and renewal prices. Each is a list of price rows, one per term. idintThe price record id. cidintThe currency number. periodstringThe period unit. timeintThe period multiplier. 3 with a month unit means three months. amountstringThe selling price. Text with four decimals. setupstringThe set-up fee. coststringThe cost. cost_cidintThe cost currency number. promotionstringThe promotional price. promotion_statusintWhether the promotion is on. statusintWhether the price is on. discountstringThe discount value. rankintIts order. addonsobjectThe extension's add-on prices. The price rows come in the shape above. domain_active_servicesintHow many live services the client has on this extension. total_active_servicesintHow many live services the client has in all. Errors 3 invalid_domain422The domain is missing or not valid. tld_not_found422The extension is not defined on the installation. 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/orders/check-domain' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"domain":"example.com","client_id":94}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/orders/check-domain', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ domain, client_id: clientId }), }); const { data } = await res.json(); if (! data.available) suggestAlternatives(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/check-domain'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['domain' => $domain, 'client_id' => $cid]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Whether a transfer needs a code depends on the extension; learn it here BEFORE ordering. $d = Api::Orders()->CheckDomain(['domain' => $domain, 'client_id' => $cid])['data']; $needsCode = (int) $d['tld_info']['epp_code'] === 1; ``` #### Listing the Coupons get/api/v1/admin/orders/coupons `Orders/GetCoupons` admin Returns the coupons an order can take, along with their rules. Query 2 searchstringSearches the coupon code. client_idintThe client id. The reasons a coupon is closed fill only with this. Response fields data[] — 22 idintThe coupon id. codestringThe coupon code. typestringThe discount kind: a rate or a fixed amount. ratefloatThe discount percentage. amountfloatThe fixed discount amount. currency_idintThe currency number of the fixed amount. pservicesstringThe scope string. Empty means it applies to every service. min_amountfloatThe least the basket must hold. min_amount_currencyintThe currency number of that least amount. recurringintWhether it holds on renewals too. recurring_numintFor how many renewals. taxfreeintWhether it applies before tax. use_mergeintWhether it joins another coupon. auto_applyintWhether it applies by itself. maxusesintThe use limit. Zero means no limit. usesintHow often it was used. validity_cyclesstring[]The billing cycles it holds for. required_productsstringThe products the basket must hold. required_product_cyclesstring[]The cycles of those products. statusstringWhether the coupon is on. disabledboolWhether it cannot be used in this context. disabled_reasonsstring[]The reasons it cannot be used. Expired, over the limit, under the least amount and the like. notesstringThe coupon note. 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/orders/coupons?client_id=94' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/orders/coupons?client_id=' + clientId, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const usable = data.filter((c) => ! c.disabled); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/coupons?client_id=' . $cid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // WITHOUT a client id the 'disabled' field stays thin and an unusable coupon looks open. $rows = Api::Orders()->GetCoupons([], ['client_id' => $cid])['data']; $usable = array_filter($rows, fn ($c) => ! $c['disabled']); ``` #### Checking the Coupons post/api/v1/admin/orders/validate-coupons `Orders/ValidateCoupons` admin Says whether the coupons picked hold for this basket. Body 3 coupon_idsint[]reqThe coupon ids to try. client_idintThe client id. cartobject cartThe basket context. The least-amount and product conditions are checked from here. itemsarrayThe basket lines. Each carries a kind, group, product, category, extension, cycle and amount. subtotalfloatThe basket subtotal. user_currencyintThe client's currency number. is_dealerboolWhether the client is a dealer. Response fields data — 3 keptint[]The coupon ids found good. invalidarrayThe ones turned down. Each carries an id, a code and a reason. auto_apply_candidatesarrayThe ones that could apply by themselves. Errors 1 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/orders/validate-coupons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"coupon_ids":[19],"client_id":94,"cart":{"subtotal":100,"user_currency":4}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/orders/validate-coupons', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ coupon_ids: picked, client_id: clientId, cart: { items, subtotal, user_currency: currencyId, is_dealer: false }, }), }); const { data } = await res.json(); data.invalid.forEach((c) => showReason(c.code, c.reason)); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/validate-coupons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'coupon_ids' => $picked, 'client_id' => $cid, 'cart' => $cart, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // WITHOUT the basket the least-amount and product conditions go unchecked; a coupon passes here and fails at ordering. $out = Api::Orders()->ValidateCoupons([ 'coupon_ids' => $picked, 'client_id' => $cid, 'cart' => $cart, ])['data']; $use = $out['kept']; ``` #### Creating the Order post/api/v1/admin/orders `Orders/CreateOrder` admin it makes services Builds the order from the basket lines and brings the services into being. Body 11 client_idintreqThe client the order belongs to. productsarrayreq products[]The basket lines. A product line and a domain line carry different fields. groupstringThe line's group. On a domain line this value marks it as one. product_idintThe product id. billing_cyclestringThe billing cycle. quantityintHow many. price_overridefloatSetting the price by hand. currencyintThe currency number. addonsobjectThe add-ons picked. addons_qtyobjectThe add-on quantities. metricsobjectThe meters switched on. requirementsobjectThe answers to the questions. requirement_filesobjectThe content for questions wanting a file. It goes as encoded content or as an address. domain_namestringThe domain name. domain_actionstringA registration or a transfer. domain_periodintFor how many years. domain_addonsstring[]The domain add-ons. epp_codestringThe transfer code. statusstringThe state to start in. It is worked out again from the services made. payment_methodstringThe payment module. promo_codesint[]The coupon ids to apply. tax_exemptionboolWhether it is tax exempt. billing_profile_idintThe billing address profile. The tax rate comes from here. generate_invoiceboolWhether an invoice is raised too. invoice_statusstringThe invoice state. send_notificationboolWhether the client is told. notesstringThe order note. Response fields 201 — data dataobjectThe order made. Same shape as the read endpoint in the order records article. Errors 6 invalid_client422No client id was given. no_products422The basket holds no valid line. not_found404No such client. file_upload_failed422A question's file could not be read. create_failed500The order could not be built. 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/orders' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"client_id":94,"generate_invoice":true,"products":[{"group":"hosting","product_id":12,"billing_cycle":"monthly","quantity":1}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/orders', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: clientId, status: 'waiting', generate_invoice: true, invoice_status: 'unpaid', products: [{ group: 'hosting', product_id: 12, billing_cycle: 'monthly', quantity: 1, requirements: answers, }], }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'client_id' => $cid, 'generate_invoice' => true, 'products' => $lines, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The state you send is NOT kept: it is worked out again from the services made, so read the answer. $order = Api::Orders()->CreateOrder([ 'client_id' => $cid, 'status' => 'waiting', 'products' => $lines, ])['data']; $real = $order['status']; ``` ### Pitfalls > **The state you send is not kept** > > Even with a state named in the body, the create works the state out again **from the services born**. An order sent as waiting can come back active. Read what happened from the state in the answer rather than assuming what you sent. > **The coupon list comes thin without a client** > > The reasons a coupon cannot be used fill only when a client id is sent. Without one only the basic state shows, and a coupon **closed to that client looks open**. Pass the basket to the checking endpoint as well for the basket conditions. > **Checking without a basket checks less** > > The checking endpoint reads the least amount, the products needed and the cycle match **from the basket**. Leaving it out leaves those three unchecked: the coupon looks good here and fails while the order is built. The basket you check and the basket you order with should be the same. > **A transfer code is not wanted on every extension** > > Whether a domain transfer wants a code is written **in the extension's settings** and comes back with the availability answer. Leaving the code out where it is wanted still builds the order and never starts the transfer. Read the extension information before preparing the line. > **The required questions show in the product information alone** > > Which questions a product makes required is written under the rules in the product information. The create call **may not catch a missing answer** and the service reaches the provider short of data. Filter the required list while building the line. > **The availability answer is a snapshot** > > The domain question goes live to the registrar and the answer is that moment's state. Between the question and the order someone else can take the name, and then the order builds while the registration fails. Ask again right before ordering on a basket that sat a while. ### Related Articles - [Order Records](https://dev.wisecp.com/en/order-records) - [Discount Coupons](https://dev.wisecp.com/en/discount-coupons) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) ## Order Records https://dev.wisecp.com/en/order-records The six endpoints that read, move and remove orders already placed. ### Overview An order is the **record** of a purchase and a service is the **thing** born from it. The two live apart: removing an order does not kill the service, and closing a service does not change the order. The detail endpoint shows that split plainly. The lines are the order's own and do not move, while the service list comes back **live on every read**, its states taken from the service record. Tax and discounts are kept the same way: the rates from order time are frozen inside the record. Working them out again at today's setting misreads a past bill. ### Reference #### Listing the Orders get/api/v1/admin/orders `Orders/GetOrders` admin Returns the orders page by page, with the client and invoice summary. Query 9 pageintWhich page. limitintRecords per page. A value out of range counts as 25. searchstringSearches the order number, the client details and the address. statusstringThe order state filter: waiting, in process, active or cancelled. groupstringThe product group filter. It searches inside the order lines. paymentstringThe invoice state filter: complete, incomplete or unknown. client_idintThe client id. numberintThe order number. It takes a partial match. ipstringThe address the order came from. It takes a partial match. Response fields data[] — 13 + meta — 4 idintThe order id. order_numberstringThe order number people see. statusstringWhere the order stands. amountfloatThe order amount. currency_idintThe currency number of the amount. payment_methodstringThe payment module. It comes empty when none was picked. invoice_idintThe invoice tied to it. Zero means there is none. invoice_statusstringWhere the invoice stands. item_countintHow many lines the order holds. has_active_moduleboolWhether it holds a service tied to a provider. ipstringThe address the order came from. created_atstringWhen it was made. clientobjectThe client summary. It carries the id, name, company and e-mail. totalintHow many orders there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. 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/orders?status=waiting&limit=50' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/orders?status=waiting', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); const unpaid = data.filter((o) => o.invoice_status === 'unpaid'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders?status=waiting'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A listed order HAS the amount and NOT the lines: lines and services come on the detail endpoint alone. $rows = Api::Orders()->GetOrders([], ['status' => 'waiting'])['data']; foreach ($rows as $o) $detail[$o['id']] = Api::Orders()->GetOrder(['id' => $o['id']])['data']; ``` #### Reading an Order in Full get/api/v1/admin/orders/{id} `Orders/GetOrder` admin Returns an order's tax, discounts, lines and the services it brought into being. Response fields data — 17 idintThe order id. order_numberstringThe order number people see. statusstringWhere the order stands. amountfloatThe order amount. currency_idintThe currency number. payment_methodstringThe payment module. tax_typestringWhether tax sits inside the price or beside it. taxesobject taxesThe tax snapshot. It keeps the rate and amount as they were at order time. typestringWhether tax sits inside the price or beside it. ratefloatThe rate applied. system_ratefloatThe rate the system set. amountfloatThe tax amount. additionalobjectThe additional taxes and their total. exemptionintWhether an exemption was applied. discountsobject discountsThe discount snapshot. resellerobjectThe dealer discount: its total, its lines and its groups. couponobjectThe coupon discount: its total and its lines. totalfloatThe discount in all. detailsobject detailsThe extra information from order time. subtotalfloatThe subtotal. display_subtotalfloatThe subtotal shown. taxable_subtotalfloatThe subtotal taxed. billing_profile_idintThe billing address profile. send_notificationintWhether the client was told. generate_invoiceintWhether an invoice was raised. invoice_statusstringThe invoice state. promo_codesarrayThe coupon ids applied. created_bystringWho opened the order: a member of staff or the client. admin_idintThe id of the staff who opened it. notesstringThe order note. ipstringThe address the order came from. affiliate_idintThe partner credited. Zero means none. created_atstringWhen it was made. clientobjectThe client summary. It carries the id, name, company, e-mail and language. invoiceobjectThe invoice tied to it. It carries the id, number, state and total. itemsarray items[]The raw order lines. typestringThe line kind: a product or a domain. product_idintThe product id. product_namestringThe product name. product_typestringThe product type. domainstringThe domain name. tldstringThe extension. sldstringThe body of the name. billing_cyclestringThe billing cycle. periodintThe term. On a domain line it is the number of years. pricefloatThe unit price. quantityintHow many. allow_qtyintWhether more than one is allowed. actionstringThe domain action: a registration or a transfer. requirementsarrayThe answers the client gave. Each carries the question id, name, type, value and module mapping. addonsarrayThe add-ons picked. servicesint[]The service ids born from this line. invoice_item_idintThe invoice line tied to it. servicesarray services[]The services born from the order. Their states are read live from the service record. idintThe service id. existsboolWhether the service still stands. False means the line never became one or the service was removed. namestringThe service name. typestringThe service type. product_idintThe product id. statusstringThe service's live state. amountfloatThe service amount. total_amountfloatThe total with the add-ons. currency_idintThe currency number. periodstringThe period unit. period_timeintThe period multiplier. cyclestringThe billing cycle. modulestringThe provider module. has_requirementsboolWhether it carries answers. optionsobjectThe service options. addonsarrayThe service's add-ons. Each carries the choice, quantity, state and amount. Errors 2 not_found404No such order. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/orders/92' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/orders/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const live = data.services.filter((s) => s.exists); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The tax and discount are a snapshot of ORDER TIME; do not work them out again at today's rate. $o = Api::Orders()->GetOrder(['id' => $id])['data']; $rateThen = $o['taxes']['rate'] ?? 0; ``` #### Updating the Order Envelope patch/api/v1/admin/orders/{id} `Orders/UpdateOrder` admin two fields Changes an order's note and which partner is credited. Body 2 notesstringThe order note. affiliate_idintThe partner credited. Zero takes the credit away. Response fields data — 17 dataobjectThe order as it now stands. Same shape as the read endpoint. Errors 2 not_found404No such order. 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/orders/92' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"notes":"Expedite provisioning","affiliate_id":5}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/orders/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notes: 'Expedite provisioning' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['notes' => $note]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The note and the partner change alone; lines, prices and the client CANNOT be edited here. Api::Orders()->UpdateOrder(['id' => $id, 'notes' => $note, 'affiliate_id' => $aff]); ``` #### Changing the Order State put/api/v1/admin/orders/{id}/status `Orders/UpdateOrderStatus` admin it reaches the services Changes an order's state and carries it through to its services. Body 2 statusstringreqThe new state: waiting, in process, active or cancelled. apply_on_moduleboolWhether the change reaches the provider too. It starts real work on the server. Response fields data — 5 idintThe order id. statusstringThe new state. old_statusstringThe state before. services_updatedboolWhether it reached the services. A move back to waiting does not reach them. applied_on_moduleboolWhether it was applied at the provider. Errors 5 not_found404No such order. invalid_status422The state is none of the four values. blocked_by_gate422A hook refused the change. status_change_failed500The state could not be changed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/orders/92/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"active","apply_on_module":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/orders/${id}/status`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'active', apply_on_module: false }), }); const { data } = await res.json(); if (data.services_updated) refreshServices(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/' . $id . '/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 'active', 'apply_on_module' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // apply_on_module starts real work AT THE PROVIDER: active builds and cancelled shuts down. Leave it false first. Api::Orders()->UpdateOrderStatus([ 'id' => $id, 'status' => 'active', 'apply_on_module' => false, ]); ``` #### Removing an Order delete/api/v1/admin/orders/{id} `Orders/DeleteOrder` admin Removes the order record while the services it made stay where they are. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the order removed. Errors 3 not_found404No such order. blocked_by_gate422A hook refused the delete. 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/orders/92' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/orders/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/' . $id); 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 // The services ARE NOT removed: the order goes, they stay, and which order made them is lost. $o = Api::Orders()->GetOrder(['id' => $id])['data']; foreach ($o['services'] as $s) if ($s['exists']) $orphans[] = $s['id']; Api::Orders()->DeleteOrder(['id' => $id]); ``` #### Removing One of an Order's Services delete/api/v1/admin/orders/{id}/services/{sid} `Orders/DeleteOrderService` admin Removes a service the order brought into being. Body 1 apply_on_moduleboolWhether it is cancelled at the provider too. Response fields data — 4 deletedboolWhether the delete ran. service_idintThe id of the service removed. order_idintThe order id. applied_on_moduleboolWhether it was applied at the provider. Errors 2 not_found404No such order or service, or the service belongs to another order. 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/orders/92/services/561' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"apply_on_module":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/orders/${id}/services/${sid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ apply_on_module: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/orders/' . $id . '/services/' . $sid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['apply_on_module' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A service belonging to another order gives 404; take the id from the service list in the detail. $o = Api::Orders()->GetOrder(['id' => $id])['data']; $mine = array_column($o['services'], 'id'); if (in_array($sid, $mine, true)) Api::Orders()->DeleteOrderService(['id' => $id, 'sid' => $sid, 'apply_on_module' => false]); ``` ### Pitfalls > **Removing an order does not remove the service** > > The order record goes and the services it made **stay where they are**, still billing. The one link left breaks as well: which order made a service can no longer be traced. Remove the services first when you mean to undo a purchase entirely. > **Applying at the provider starts real work** > > Turning on the provider option during a state change starts **real work on the server**: active builds and cancelled shuts an account down. Leaving it on during a bulk correction reaches client servers all at once. Try it off first and look at the result. > **Tax and discount are a frozen snapshot** > > The tax rate, the additional taxes and the coupon discount in the detail were worked out **at order time** and stand that way in the record. Two figures disagree when today's tax setting has moved, and that is historical accuracy rather than a fault. Use the record's own rate in a report. > **The envelope update does not reprice an order** > > The update endpoint writes the note and the partner credit alone. Lines, quantities, prices, cycles and the client **cannot be changed** here. The way to handle an order built wrong is to cancel it and build another rather than to edit it. > **A service is removed from its own order alone** > > The service removal endpoint checks that the service truly belongs to that order, and one from another order gets `404`. That gate stops a wrong id from **removing the wrong service**. Take the id from the list in the detail. > **The listing holds no lines and the detail does** > > The listing gives a summary for each order: the amount, how many lines and the client. The lines themselves, the answers to the questions and the services born come on the **detail endpoint**. Showing lines in a listing screen means one more call per row. ### Related Articles - [Placing an Order](https://dev.wisecp.com/en/placing-an-order) - [Reference Lists](https://dev.wisecp.com/en/reference-lists) # API / Admin API / Products ## Product Endpoints https://dev.wisecp.com/en/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 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. statusstring`active` 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. statusstring`active` 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 JavaScript PHP (HTTP) PHP (Internal) ```bash curl -G 'https://panel.example.com/api/v1/admin/products' \ -H "Authorization: Bearer $API_KEY" \ -d type=hosting \ -d limit=50 ``` ```javascript 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(); ``` ```php $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); ``` ```php $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 200 ```json { "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_required422`name` 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 JavaScript PHP (HTTP) PHP (Internal) ```bash 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"}' ``` ```javascript 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(); ``` ```php $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); ``` ```php // 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}`. statusstring`active` or `inactive`. visibilitystring`visible` 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}`. langsobject 8 fieldsA 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 JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/15' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $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); ``` ```php $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 statusstring`active` 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`. langsobject 8 fieldsA 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 JavaScript PHP (HTTP) PHP (Internal) ```bash 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"}}' ``` ```javascript 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(); ``` ```php $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); ``` ```php // 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. actionstringrequired`active` or `inactive`. Response fields data — 2 updatedint[]The ids whose status changed. actionstringThe status that was applied. Errors 2 ids_required422`ids` was empty. invalid_action422The action is neither of the two values. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash 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"}' ``` ```javascript 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(); ``` ```php $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); ``` ```php $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 JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/admin/products/2030' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/2030', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $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); ``` ```php $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 JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/types' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/types', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $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); ``` ```php $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 JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/groups' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/groups', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $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); ``` ```php // 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 JavaScript PHP (HTTP) PHP (Internal) ```bash curl -G 'https://panel.example.com/api/v1/admin/products/categories' \ -H "Authorization: Bearer $API_KEY" \ -d type=hosting ``` ```javascript 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(); ``` ```php $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); ``` ```php $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. ### Related Articles - [Product Categories](https://dev.wisecp.com/en/product-categories) - [Add-on Definitions](https://dev.wisecp.com/en/addon-definitions) - [Product Media](https://dev.wisecp.com/en/product-media) ## Product Media https://dev.wisecp.com/en/product-media The eleven endpoints that manage a product's gallery, order image and delivery file. ### Overview A product has three separate visual assets and they should not be confused. The **gallery** holds several images and can be ordered; the **order image** is a single one shown on the order screen; the **delivery file** is not an image at all, it is what a software product gives the client. None of the upload endpoints expect a multipart form. You send the file as a base64 data URI or hand over a link that can be fetched, and the server pulls it itself. ### Reference #### Listing the Gallery get/api/v1/admin/products/{id}/images `Products/GetProductImages` admin ordered Returns the product's gallery images in their order. Response fields data[] — 7 idintImage id. typestringThe image role: `photo`, `header-background`, `cover` or `mockup`. sizestringA human-readable file size. namestringThe stored file name. urlstringThe public address. titlestringThe image title. sort_orderintIts place in the gallery. Errors 2 not_found404No such product. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/15/images' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/images', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/images'); 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()->GetProductImages(['id' => 15]); ``` Response 200 ```json { "data": [ { "id": 90, "type": "photo", "size": "120 KB", "name": "ab12.jpg", "url": "https://example.com/uploads/ab12.jpg", "title": "Screenshot", "sort_order": 1 } ] } ``` #### Uploading to the Gallery post/api/v1/admin/products/{id}/images `Products/UploadProductImage` admin 201 Adds an image to the gallery. You send the file as base64 or hand over an address. Body 3 imagestringrequiredThe image. A base64 data URI or a link that can be fetched. titlestringThe image title. typestringThe image role: `photo`, `header-background`, `cover` or `mockup`. Defaults to `photo`. Response fields data — 5 idintId of the new image. typestringThe image role it was stored under. titlestringThe image title. sort_orderintIts place in the gallery. urlstringThe public address. Errors 5 not_found404No such product. 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/15/images' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"data:image/png;base64,iVBORw0KGgo...","title":"Screenshot"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/images', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'data:image/png;base64,iVBORw0KGgo...', title: 'Screenshot', type: 'photo', }), }); const body = await res.json(); ``` ```php $data = base64_encode(file_get_contents('screenshot.png')); $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/images'); 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/png;base64,' . $data, 'title' => 'Screenshot', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A new image lands at the END of the gallery; ordering is a separate call. $image = Api::Products()->UploadProductImage([ 'id' => 15, 'image' => 'data:image/png;base64,' . base64_encode($bytes), 'title' => 'Screenshot', ]); ``` #### Updating an Image patch/api/v1/admin/products/{id}/images/{image_id} `Products/UpdateProductImage` admin type moves the file Changes an image's title or role. Changing the role moves the file between folders. Body 2 titlestringThe new title. If you send it, it cannot be empty. typestringThe new role: `photo`, `header-background`, `cover` or `mockup`. Response fields data — 2 updatedboolWhether the update succeeded. idintImage id. Errors 4 not_found404No such product or image. title_required422A title was sent but it was empty. invalid_type422The role is not one of the allowed values. 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/15/images/91' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Dashboard","type":"cover"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/images/91', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Dashboard', type: 'cover' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/images/91'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'title' => 'Dashboard', 'type' => 'cover', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing the role moves the file: the old address stops working. $response = Api::Products()->UpdateProductImage([ 'id' => 15, 'image_id' => 91, 'type' => 'cover', ]); ``` #### Deleting an Image delete/api/v1/admin/products/{id}/images/{image_id} `Products/DeleteProductImage` admin Deletes a single image from the gallery. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted image. Errors 2 not_found404No such product or image. 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/15/images/91' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/images/91', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/images/91'); 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()->DeleteProductImage([ 'id' => 15, 'image_id' => 91, ]); ``` #### Clearing the Gallery delete/api/v1/admin/products/{id}/images `Products/ClearProductImages` admin cannot be undone Deletes every gallery image the product has. Response fields data — 1 clearedintHow many images were deleted. Errors 2 not_found404No such product. 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/15/images' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/images', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/images'); 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 // The address is the SAME as deleting one image; only the image id is missing. $response = Api::Products()->ClearProductImages(['id' => 15]); ``` #### Ordering the Gallery put/api/v1/admin/products/{id}/images/order `Products/ReorderProductImages` admin full list Sets the gallery order from the list of ids you give. Body 1 image_idsint[]requiredThe image ids, in the order you want. Response fields data[] — 7 idintImage id. typestringThe image role: `photo`, `header-background`, `cover` or `mockup`. sizestringA human-readable file size. namestringThe stored file name. urlstringThe public address. titlestringThe image title. sort_orderintIts place in the gallery. Errors 3 not_found404No such product. ids_required422`image_ids` was empty. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/15/images/order' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image_ids":[92,90,91]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/images/order', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image_ids: [92, 90, 91] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/images/order'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['image_ids' => [92, 90, 91]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Even to move one image to the front, send the WHOLE list. $ids = array_column(Api::Products()->GetProductImages(['id' => 15])['data'], 'id'); array_unshift($ids, array_pop($ids)); $response = Api::Products()->ReorderProductImages([ 'id' => 15, 'image_ids' => $ids, ]); ``` #### Reading the Delivery File get/api/v1/admin/products/{id}/delivery-file `Products/GetProductDeliveryFile` admin software products Returns the file a software product hands to the client. Response fields data — 2 namestringThe stored file name. sizestringA human-readable file size. Errors 2 not_found404No such product. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/15/delivery-file' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/delivery-file', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); // With no file uploaded, data comes back null. if (body.data === null) return; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/delivery-file'); 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()->GetProductDeliveryFile(['id' => 15]); $file = $response['data'] ?? null; ``` #### Uploading the Delivery File put/api/v1/admin/products/{id}/delivery-file `Products/UploadProductDeliveryFile` admin extension limits Uploads the delivery file. The previous one is removed; a product carries one at a time. Body 1 filestringrequiredThe file. A base64 data URI or a link that can be fetched. Executable page extensions are refused. Response fields data — 2 namestringThe stored file name. sizestringA human-readable file size. Errors 5 not_found404No such product. 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 PUT 'https://panel.example.com/api/v1/admin/products/15/delivery-file' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"file":"https://cdn.example.com/setup-v2.zip"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/delivery-file', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ file: 'https://cdn.example.com/setup-v2.zip' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/delivery-file'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'file' => 'https://cdn.example.com/setup-v2.zip', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Uploading a new version removes the old one - you may want a copy first. $response = Api::Products()->UploadProductDeliveryFile([ 'id' => 15, 'file' => 'https://cdn.example.com/setup-v2.zip', ]); ``` #### Deleting the Delivery File delete/api/v1/admin/products/{id}/delivery-file `Products/DeleteProductDeliveryFile` admin Removes the delivery file. Response fields data — 2 deletedboolWhether the delete succeeded. idintProduct id. Errors 2 not_found404No such product. 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/15/delivery-file' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/delivery-file', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/delivery-file'); 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()->DeleteProductDeliveryFile(['id' => 15]); ``` #### Setting the Order Image put/api/v1/admin/products/{id}/order-image `Products/SetProductOrderImage` admin a single image Sets the image shown on the order screen. A product carries one of these. Body 1 imagestringrequiredThe image. A base64 data URI or a link that can be fetched. Response fields data — 1 urlstringThe image's public address. Errors 5 not_found404No such product. 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 PUT 'https://panel.example.com/api/v1/admin/products/15/order-image' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://cdn.example.com/plan.png"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/order-image', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://cdn.example.com/plan.png' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/order-image'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'image' => 'https://cdn.example.com/plan.png', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Products()->SetProductOrderImage([ 'id' => 15, 'image' => 'https://cdn.example.com/plan.png', ]); ``` #### Deleting the Order Image delete/api/v1/admin/products/{id}/order-image `Products/DeleteProductOrderImage` admin Removes the order image. Response fields data — 2 deletedboolWhether the delete succeeded. idintProduct id. Errors 2 not_found404No such product. 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/15/order-image' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/15/order-image', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/15/order-image'); 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()->DeleteProductOrderImage(['id' => 15]); ``` ### Pitfalls > **One address deletes one image or the whole gallery** > > Clearing the gallery and deleting one image use the **same address**; the only difference is the image id at the end. Code that builds that id from an empty variable wipes the entire gallery instead of the one image it meant to remove. > **Ordering takes the full list** > > The ordering endpoint takes the list you give as the order, and images missing from it end up in an undefined place. Even to move one image to the front you read the gallery first and send every id. A newly uploaded image always lands at the end. > **Changing the role moves the file** > > Changing an image's role moves the file between folders, so the **old address stops working**. If you cached that address on your side, read it back after the update. > **The delivery file is single and extension-limited** > > Uploading a new delivery file **removes** the previous one; a product cannot hold two versions at once. Executable page extensions are also refused, so there is no way to hand the client a file that would run on the server. ### Related Articles - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) - [Product Categories](https://dev.wisecp.com/en/product-categories) ## Product Categories https://dev.wisecp.com/en/product-categories The six endpoints that read, open, edit and delete product categories inside a group and manage their images. ### Overview A category is the container that gathers products inside a group. The **flat list** of categories comes from the lookup in the product endpoints article. The six here read, open, edit and delete one category, and manage its images. Categories and special groups live in the same table and return the **same schema**. Read `is_category` to see which one you have. ### Reference #### Category Detail get/api/v1/admin/products/categories/{id} `Products/GetProductCategory` admin Returns one category with all of its settings and language content. Response fields data — 18 idintCategory id. kindstringThe group type it belongs to. kind_idintSpecial group id. 0 in a fixed group. parent_idintId of the parent category. 0 means top level. is_categoryboolWhether this row is a category or a top-level group. group_keystring | nullThe group key on top-level groups. statusstring`active` ya da `inactive`. visibilitystring`visible` ya da `invisible`. rankintThe display order. icon_typestring`font` ya da `image`. iconstringThe icon class or the name of the uploaded image. colorstringThe category colour. list_templateintId of the list template. upgradingboolWhether upgrading is allowed. seo_indexboolWhether search engines may index it. enabled_payment_gatewaysarrayRestricts payment to these gateways. disabled_payment_gatewaysarraySwitches these gateways off. langsobject 7 fieldsA map from language code to a content object. titlestringThe category title. routestringThe URL slug. Derived from the title when you leave it out. sub_titlestringA sub-title. contentstringThe category text. seo_titlestringThe search title. seo_keywordsstringThe search keywords. seo_descriptionstringThe search description. Errors 2 not_found404No such category. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/categories/18' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/categories/18', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/categories/18'); 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()->GetProductCategory(['id' => 18]); // is_category tells you whether the row is a category or a top-level group. $isCategory = $response['data']['is_category']; ``` #### Creating a Category post/api/v1/admin/products/categories `Products/CreateProductCategory` admin 201 Opens a category inside a group. A single field decides which group it lands in. Body 12 groupstringrequiredThe group context: `hosting`, `server`, `software`, or the id-suffixed key for a special group. It comes from the groups lookup. titleobjectrequiredA map from language code to title. The current language needs one. parent_idintId of the parent category. rankintThe display order. statusstring`active` ya da `inactive`. sub_titlestringA sub-title. contentstringThe category text. routestringThe URL slug. icon_typestring`font` ya da `image`. iconstringThe icon class. colorstringThe category colour. seo_titlestringThe search title; keywords and description are separate fields. Response fields data dataobjectThe category created, returned with `201`. The same schema as the category detail above. Errors 5 title_required422There is no title in the current language. group_required422`group` was empty. route_exists422The slug is already used by another record. 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/categories' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"group":"hosting","title":{"en":"Reseller Hosting"},"rank":2}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/categories', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ group: 'hosting', title: { en: 'Reseller Hosting' }, rank: 2, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/categories'); 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', 'title' => ['en' => 'Reseller Hosting'], 'rank' => 2, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // For a special group the key carries the id as a suffix; read it from the groups lookup. $response = Api::Products()->CreateProductCategory([ 'group' => 'special-5', 'title' => ['en' => 'Wildcard Certificates'], ]); ``` #### Updating a Category patch/api/v1/admin/products/categories/{id} `Products/UpdateProductCategory` admin partial-safe Applies the fields you send. The group a category belongs to cannot be changed here. Body 11 titleobjectA map from language code to title. parent_idintId of the parent category. rankintThe display order. statusstring`active` ya da `inactive`. sub_titlestringA sub-title. contentstringThe category text. routestringThe URL slug. icon_typestring`font` ya da `image`. iconstringThe icon class. colorstringThe category colour. seo_titlestringThe search title; keywords and description are separate fields. Response fields data dataobjectThe category as it now stands, returned with `200`. The same schema as the category detail above. Errors 4 not_found404No such category. route_exists422The slug is already used by another record. update_failed422The update was refused. 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/categories/18' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"inactive","rank":5}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/categories/18', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'inactive', rank: 5 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/categories/18'); 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', 'rank' => 5]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Products()->UpdateProductCategory([ 'id' => 18, 'status' => 'inactive', 'rank' => 5, ]); ``` #### Deleting a Category delete/api/v1/admin/products/categories/{id} `Products/DeleteProductCategory` admin cannot be undone Deletes the category. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted category. Errors 3 not_found404No such category. blocked_by_gate422The `gate:product.group_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/categories/18' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/categories/18', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/categories/18'); 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()->DeleteProductCategory(['id' => 18]); ``` #### Uploading a Category Image post/api/v1/admin/products/categories/{id}/image `Products/UploadCategoryImage` admin two slots Uploads the category's icon or its header background. Body 2 imagestringrequiredThe image. A base64 data URI or a link that can be fetched. typestringWhich slot to fill: `icon` or `header-background`. Defaults to `icon`. Response fields data — 2 typestringThe slot that was written: `icon` or `header-background`. urlstringPublic URL of the stored image. The file name is randomised. ——A slot holds one image: a new upload replaces the previous one and deletes its file. Errors 5 not_found404No such category. 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/categories/18/image' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://cdn.example.com/icon.png","type":"icon"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/categories/18/image', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://cdn.example.com/icon.png', type: 'icon', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/categories/18/image'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'image' => 'https://cdn.example.com/icon.png', 'type' => 'icon', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Uploading an icon image also sets the icon type to image; the font icon no longer applies. $response = Api::Products()->UploadCategoryImage([ 'id' => 18, 'image' => 'https://cdn.example.com/icon.png', 'type' => 'icon', ]); ``` #### Deleting a Category Image delete/api/v1/admin/products/categories/{id}/image `Products/DeleteProductCategoryImage` admin Removes the category's icon or its header background. Query parameters 1 typestringWhich slot to empty: `icon` or `header-background`. Defaults to `icon`. Response fields data — 3 deletedboolWhether the delete succeeded. idintCategory id. typestringThe slot that was emptied. Errors 2 not_found404No such category. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE -G 'https://panel.example.com/api/v1/admin/products/categories/18/image' \ -H "Authorization: Bearer $API_KEY" \ -d type=header-background ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/categories/18/image'); url.searchParams.set('type', 'header-background'); const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/categories/18/image?' . http_build_query(['type' => 'header-background']); $ch = curl_init($url); 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()->DeleteProductCategoryImage(['id' => 18], [ 'type' => 'header-background', ]); ``` ### Pitfalls > **The group context is given only when creating** > > The `group` field sets which group a category belongs to, and it works at creation only. It **cannot be changed later**: the update body has no such field. Moving a category to another group means opening a new one and deleting the old. > **The slug is unique across records** > > A clashing slug is refused on both create and update. The clash is looked for across records, not among sibling categories alone. A same-named category in another group stops you too, so a slug built from a title can collide where you did not expect it. > **An uploaded icon overrides the font icon** > > Uploading into the icon slot switches the icon type to image. The font icon you set before is stored but not shown. Going back to it means deleting the image and writing the icon type again. ### Related Articles - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) - [Special Groups](https://dev.wisecp.com/en/special-groups) ## Special Groups https://dev.wisecp.com/en/special-groups The seven endpoints that manage the special groups holding product families outside the fixed types. ### Overview A special group holds together a family of products that falls outside the fixed types. SSL certificates are the canonical case: products with no type of their own live inside a special group. A group is not only a container but a storefront setting. Payment gateways, the list template and the upgrade permission are set at group level and reach every product inside. ### Reference #### Listing the Groups get/api/v1/admin/products/special-groups `Products/GetProductGroupsList` admin paged Returns the top-level special product groups. Query parameters 3 searchstringSearches the group title. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 8 idintGroup id. keystringThe group key. This is the value you use when opening a category. titlestringThe group title in the current language. routestringThe URL slug. statusstring`active` ya da `inactive`. product_countintHow many products are in the group. service_countintHow many services are in the group. This is the number to read before deleting. created_atstringWhen it was created. 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 'https://panel.example.com/api/v1/admin/products/special-groups' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/special-groups', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/special-groups'); 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()->GetProductGroupsList(); ``` Response 200 ```json { "data": [ { "id": 5, "key": "special-5", "title": "SSL Certificates", "route": "ssl-certificates", "status": "active", "product_count": 8, "service_count": 40, "created_at": "2026-01-01 10:00:00" } ], "meta": { "total": 3, "page": 1, "limit": 25, "next_page": 0 } } ``` #### Group Detail get/api/v1/admin/products/special-groups/{id} `Products/GetProductGroup` admin Returns one group with all of its settings and language content. Response fields data — 18 idintGroup id. kindstringThe group type key. kind_idintId of the parent group. Zero at top level. parent_idintId of the parent category. is_categoryboolWhether this row is a category or a top-level group. group_keystring | nullThe group key on top-level groups. statusstring`active` ya da `inactive`. visibilitystring`visible` ya da `invisible`. rankintThe display order. icon_typestring`font` ya da `image`. iconstringThe icon class or the name of the uploaded image. colorstringThe colour of the group card. list_templateintId of the list template. upgradingboolWhether upgrading inside the group is allowed. seo_indexboolWhether search engines may index it. enabled_payment_gatewaysarrayRestricts payment to these gateways. disabled_payment_gatewaysarraySwitches these gateways off. langsobject 7 fieldsA map from language code to a content object. titlestringThe group title. routestringThe URL slug. sub_titlestringA sub-title. contentstringThe group text. seo_titlestringThe search title. seo_keywordsstringThe search keywords. seo_descriptionstringThe search description. Errors 2 not_found404No such group. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/special-groups/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/special-groups/5', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/special-groups/5'); 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()->GetProductGroup(['id' => 5]); ``` #### Creating a Group post/api/v1/admin/products/special-groups `Products/CreateProductGroup` admin 201 Opens a top-level special group. Body 14 titleobjectrequiredA map from language code to title. The current language needs one. sub_titleobjectA map from language code to sub-title. contentobjectA map from language code to group text. routeobjectA map from language code to slug. Left empty, it is built from the title. statusstring`active` ya da `inactive`. hiddenboolHides the group from the storefront. rankintThe display order. icon_typestring`font` ya da `image`. iconstringThe icon class. colorstringThe card colour. Six digits without the hash. list_templateintId of the list template. upgradingboolAllows upgrading inside the group. seo_indexboolOpens it to search engines. seo_titleobjectA map from language code to search title. Keywords and description are separate fields. Response fields data dataobjectThe group that was created, returned with `201`. Same shape as the group detail schema. Errors 4 title_required422There is no title in the current language. route_exists422The slug clashes with an existing address. 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/special-groups' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":{"en":"SSL Certificates"},"status":"active","icon_type":"font","icon":"fa-lock","color":"3366ff"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/special-groups', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: { en: 'SSL Certificates' }, status: 'active', icon_type: 'font', icon: 'fa-lock', color: '3366ff', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/special-groups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'title' => ['en' => 'SSL Certificates'], 'status' => 'active', 'icon_type' => 'font', 'icon' => 'fa-lock', 'color' => '3366ff', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The new group's key is what you use when opening products and categories inside it. $group = Api::Products()->CreateProductGroup([ 'title' => ['en' => 'SSL Certificates'], ]); Api::Products()->CreateProduct([ 'type' => 'special', 'group_id' => $group['data']['id'], 'name' => 'Wildcard SSL', ]); ``` #### Updating a Group patch/api/v1/admin/products/special-groups/{id} `Products/UpdateProductGroup` admin partial-safe Applies the fields you send and leaves the rest as they were. Body 14 titleobjectrequiredA map from language code to title. The current language needs one. sub_titleobjectA map from language code to sub-title. contentobjectA map from language code to group text. routeobjectA map from language code to slug. Left empty, it is built from the title. statusstring`active` ya da `inactive`. hiddenboolHides the group from the storefront. rankintThe display order. icon_typestring`font` ya da `image`. iconstringThe icon class. colorstringThe card colour. Six digits without the hash. list_templateintId of the list template. upgradingboolAllows upgrading inside the group. seo_indexboolOpens it to search engines. seo_titleobjectA map from language code to search title. Keywords and description are separate fields. Response fields data dataobjectThe group in its up-to-date state. Same shape as the group detail schema. Errors 4 not_found404No such group. route_exists422The slug clashes with an existing address. update_failed422The update was refused. 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/special-groups/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"inactive","color":"ff0000"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/special-groups/5', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'inactive', color: 'ff0000' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/special-groups/5'); 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', 'color' => 'ff0000', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The FAQ, columns and payment gateways are NOT edited here; they are preserved. $response = Api::Products()->UpdateProductGroup([ 'id' => 5, 'status' => 'inactive', ]); ``` #### Deleting a Group delete/api/v1/admin/products/special-groups/{id} `Products/DeleteProductGroup` admin takes its contents Deletes the group. Its products, sub-categories and images go with it. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted group. Errors 3 not_found404No such group. blocked_by_gate422The `gate:product.group_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/special-groups/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/special-groups/5', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/special-groups/5'); 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 // The delete takes everything in the group - count the contents first. $group = Api::Products()->GetProductGroupsList()['data'][0]; if ($group['product_count'] > 0 || $group['service_count'] > 0) { return; } Api::Products()->DeleteProductGroup(['id' => $group['id']]); ``` #### Uploading a Group Image post/api/v1/admin/products/special-groups/{id}/image `Products/UploadGroupImage` admin two slots Uploads the group's icon or its header background. Body 2 imagestringrequiredThe image. A base64 data URI or a link that can be fetched. typestringWhich slot to fill: `icon` or `header-background`. Defaults to `icon`. Response fields data — 2 typestringThe slot that was written: `icon` or `header-background`. Compare it with what you sent to catch a silent fallback. urlstringPublic URL of the stored image. The file name is randomised. ——Each slot holds one image: an upload replaces the previous one and deletes its file. `header-background` images are resized to the configured dimensions. Errors 5 not_found404No such group. 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/special-groups/5/image' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://cdn.example.com/ssl.png","type":"icon"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/special-groups/5/image', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://cdn.example.com/ssl.png', type: 'icon', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/special-groups/5/image'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'image' => 'https://cdn.example.com/ssl.png', 'type' => 'icon', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Products()->UploadGroupImage([ 'id' => 5, 'image' => 'https://cdn.example.com/ssl.png', 'type' => 'icon', ]); ``` #### Deleting a Group Image delete/api/v1/admin/products/special-groups/{id}/image `Products/DeleteProductGroupImage` admin Removes the group's icon or its header background. Query parameters 1 typestringWhich slot to empty: `icon` or `header-background`. Defaults to `icon`. Response fields data — 3 deletedboolWhether the delete succeeded. idintGroup id. typestringThe slot that was emptied. Errors 2 not_found404No such group. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE -G 'https://panel.example.com/api/v1/admin/products/special-groups/5/image' \ -H "Authorization: Bearer $API_KEY" \ -d type=icon ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/special-groups/5/image'); url.searchParams.set('type', 'icon'); const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/special-groups/5/image?' . http_build_query(['type' => 'icon']); $ch = curl_init($url); 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()->DeleteProductGroupImage(['id' => 5], ['type' => 'icon']); ``` ### Pitfalls > **Deleting takes the contents with it** > > Deleting a group also deletes the products, sub-categories and images inside it. The `product_count` and `service_count` fields on the list exist for exactly this; read both before you delete. > **The rich fields are not edited here** > > The FAQ, the column layout, the operator notes and the payment gateway lists are not in the update body. They keep their current values, so leaving them out does not wipe them. They are managed from the panel's own screens. > **The colour goes in without a hash** > > The colour field wants the six digits without a leading hash. Sending it with one can leave the value unreadable, so read the detail back after writing to see what was stored. ### Related Articles - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) - [Product Categories](https://dev.wisecp.com/en/product-categories) ## Add-on Definitions https://dev.wisecp.com/en/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) ## Product Requirements https://dev.wisecp.com/en/product-requirements The eleven endpoints that manage the fields a product asks at order time, their rules, icons and categories. ### Overview A requirement is a field the product asks the client to fill at order time: a hostname, a domain, a licence key, an install script. One thing separates it from an add-on: it has **no price**. An add-on is an option being sold, a requirement is a question being asked. What matters is where the answer goes. The `module_co_names` mapping says which field in which module the client's answer lands in. Without a mapping the answer is stored but never used when the service is provisioned. ### Reference #### Listing the Requirements get/api/v1/admin/products/requirements `Products/GetProductRequirements` admin paged Returns the requirement definitions in the catalogue. Query parameters 5 groupstringFilters by the main group key. categoryintFilters by requirement category. searchstringSearches the requirement name. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 7 idintRequirement id. namestringThe requirement name in the current language. descriptionstringThe description. groupstringThe main group key. categoryintId of the requirement category. statusstring`active` or `inactive`. rankintThe display order. 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/requirements' \ -H "Authorization: Bearer $API_KEY" \ -d group=server ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/requirements'); url.searchParams.set('group', 'server'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/requirements?' . http_build_query(['group' => 'server']); $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()->GetProductRequirements([], ['group' => 'server']); ``` #### Requirement Detail get/api/v1/admin/products/requirements/{id} `Products/GetProductRequirement` admin module mapping Returns one requirement with its rules, module mapping and options in every language. Response fields data — 9 idintRequirement id. groupstringThe main group key. categoryintId of the requirement category. statusstring`active` or `inactive`. rankintThe display order. module_co_namesobjectA map from module name to the field name in that module. An empty value means unmapped: the answer is stored but never reaches the module. typestringThe field type the client sees: `text`, `textarea`, `select`, `radio`, `checkbox` or `file`. propertiesobjectThe rules for the type: whether it is compulsory, the maximum file size, the allowed extensions. langsobject 3 fieldsA map from language code to a content object. namestringThe requirement name. descriptionstringThe description the client sees. optionsobject[] 3 fieldsThe options in this language. Filled only on the choice types; empty on the rest. idintOption id. namestringThe option's name in that language. mkeystringWhat the option maps to in the module. It can be left empty. Errors 2 not_found404No such requirement. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/requirements/42' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirements/42', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirements/42'); 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()->GetProductRequirement(['id' => 42]); // An empty mapping means the answer NEVER reaches the module. $mapped = ($response['data']['module_co_names']['HetznerCloud'] ?? '') !== ''; ``` Response 200 ```json { "data": { "id": 42, "group": "server", "category": 297, "status": "active", "rank": 0, "module_co_names": { "HetznerCloud": "user_data" }, "type": "textarea", "properties": { "compulsory": false }, "langs": { "en": { "name": "User Data", "description": "Cloud-init data run at first boot.", "options": [] } } } } ``` #### Creating a Requirement post/api/v1/admin/products/requirements `Products/CreateProductRequirement` admin 201 Opens a new requirement definition in the catalogue. Body 12 groupstringrequiredThe main group key. It comes from the groups lookup. categoryintrequiredId of the requirement category. nameobjectrequiredA map from language code to name. The current language needs one. descriptionobjectA map from language code to description. typestringField type: `text`, `textarea`, `select`, `radio`, `checkbox` or `file`. Defaults to `text`. statusstring`active` or `inactive`. rankintThe display order. compulsoryboolMakes the field compulsory. Left empty, the order cannot go through. max_file_sizeintThe maximum file size. Meaningful only on the file type. allowed_extensionsstringThe allowed file extensions. Meaningful only on the file type. module_co_namesobjectA map from module name to the field name in that module. optionsobjectA map from option key to a `{name, mkey}` object. Choice types only; sending it replaces the whole set. Response fields data — 9 dataobjectThe requirement created. 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/requirements' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"group":"server","category":4,"type":"text","name":{"en":"Server Hostname"},"compulsory":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirements', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ group: 'server', category: 4, type: 'text', name: { en: 'Server Hostname' }, compulsory: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirements'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'group' => 'server', 'category' => 4, 'type' => 'text', 'name' => ['en' => 'Server Hostname'], 'compulsory' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Give the mapping too, or the answer lands nowhere in the module. $response = Api::Products()->CreateProductRequirement([ 'group' => 'server', 'category' => 4, 'type' => 'text', 'name' => ['en' => 'Server Hostname'], 'module_co_names' => ['HetznerCloud' => 'hostname'], ]); ``` #### Updating a Requirement patch/api/v1/admin/products/requirements/{id} `Products/UpdateProductRequirement` admin options are all-or-nothing Applies the fields you send and leaves the rest as they were. If you send the options, the set is replaced as a whole. Body 12 groupstringThe main group key. It comes from the groups lookup. categoryintId of the requirement category. nameobjectA map from language code to name. Merged language by language. descriptionobjectA map from language code to description. typestringField type: `text`, `textarea`, `select`, `radio`, `checkbox` or `file`. Leave it out and the stored type stays. statusstring`active` or `inactive`. rankintThe display order. compulsoryboolMakes the field compulsory. Left empty, the order cannot go through. max_file_sizeintThe maximum file size. Meaningful only on the file type. allowed_extensionsstringThe allowed file extensions. Meaningful only on the file type. module_co_namesobjectA map from module name to the field name in that module. optionsobjectA map from option key to a `{name, mkey}` object. Choice types only; sending it replaces the whole set. Response fields data — 9 dataobjectThe requirement as it now stands. Same shape as the detail endpoint. Errors 3 not_found404No such requirement. update_failed422The update was refused. 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/requirements/7' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"inactive","compulsory":false}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirements/7', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'inactive', compulsory: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirements/7'); 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', 'compulsory' => false, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Even to change one option, send the WHOLE set. $req = Api::Products()->GetProductRequirement(['id' => 7])['data']; $options = $req['langs']['en']['options']; // ... change $options ... Api::Products()->UpdateProductRequirement([ 'id' => 7, 'options' => $options, ]); ``` #### Deleting a Requirement delete/api/v1/admin/products/requirements/{id} `Products/DeleteProductRequirement` admin cannot be undone Deletes the requirement definition. Its language records go too. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted requirement. Errors 3 not_found404No such requirement. blocked_by_gate422The `gate:product.requirement_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/requirements/7' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirements/7', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirements/7'); 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()->DeleteProductRequirement(['id' => 7]); ``` #### Uploading a Requirement Icon post/api/v1/admin/products/requirements/{id}/icon `Products/UploadRequirementIcon` admin SVG Uploads the requirement 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 requirement. 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/requirements/7/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/requirements/7/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/requirements/7/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()->UploadRequirementIcon([ 'id' => 7, 'image' => 'data:image/svg+xml;base64,' . base64_encode($svg), ]); ``` #### Deleting a Requirement Icon delete/api/v1/admin/products/requirements/{id}/icon `Products/DeleteProductRequirementIcon` admin Removes the uploaded icon image. Response fields data — 2 deletedboolWhether the delete succeeded. idintRequirement id. Errors 2 not_found404No such requirement. 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/requirements/7/icon' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirements/7/icon', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirements/7/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()->DeleteProductRequirementIcon(['id' => 7]); ``` #### Listing the Categories get/api/v1/admin/products/requirement-categories `Products/GetRequirementCategories` admin Returns the requirement 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/requirement-categories' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirement-categories', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirement-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()->GetRequirementCategories(); ``` #### Creating a Category post/api/v1/admin/products/requirement-categories `Products/CreateRequirementCategory` admin 201 Opens a requirement 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. Only these two come back; read the row from the category listing 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/requirement-categories' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Server Info","rank":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirement-categories', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Server Info', rank: 1 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirement-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' => 'Server Info', 'rank' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $category = Api::Products()->CreateRequirementCategory(['title' => 'Server Info']); Api::Products()->CreateProductRequirement([ 'group' => 'server', 'category' => $category['data']['id'], 'name' => ['en' => 'Server Hostname'], ]); ``` #### Updating a Category patch/api/v1/admin/products/requirement-categories/{id} `Products/UpdateRequirementCategory` 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. parent_idintId of the parent category. rankintThe display order. 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/requirement-categories/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Server Details"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirement-categories/4', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Server Details' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirement-categories/4'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['title' => 'Server Details']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Products()->UpdateRequirementCategory([ 'id' => 4, 'title' => 'Server Details', ]); ``` #### Deleting a Category delete/api/v1/admin/products/requirement-categories/{id} `Products/DeleteRequirementCategory` admin Deletes the requirement 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/requirement-categories/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/requirement-categories/4', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/requirement-categories/4'); 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()->DeleteRequirementCategory(['id' => 4]); ``` ### Pitfalls > **An unmapped requirement never reaches the module** > > Asking the client is not enough. With no field name for that module in `module_co_names`, the answer sits in the record and is **never used** at provisioning. No error is raised either: the server comes up with the wrong name and nothing says why. Give the mapping when you open the requirement. > **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. Leaving the field out keeps them. > **Some fields only work on their own type** > > The maximum file size and the allowed extensions mean something only on the file type, and options only on the choice types. A rule sent to the wrong type raises no error; it is stored quietly and never applied. > **A compulsory field stops the order** > > An order cannot go through while a requirement marked compulsory is left empty. Adding a compulsory requirement to a product that is already selling stops purchases of it there and then, so make sure the question can actually be answered on the storefront first. ### Related Articles - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) - [Add-on Definitions](https://dev.wisecp.com/en/addon-definitions) ## Domain Extensions https://dev.wisecp.com/en/domain-extensions The ten endpoints that manage the domain extensions you sell, their prices and their registration documents. ### Overview These endpoints run the domain extensions you sell: which ones are open, which registrar they are attached to, what they cost, and which documents are asked for at registration. An extension appears in the path **by its own name**, not by an id. The price table has three levels: operation type, then year, then currency. The same extension can be priced one way for a five-year registration and another for a one-year renewal. ### Reference #### Listing the Extensions get/api/v1/admin/products/domain/tlds `Products/GetDomainTlds` admin paged Returns the domain extensions the installation offers. Query parameters 3 searchstringSearches the extension. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 7 idintId of the extension record. extensionstringThe extension itself. This is the path segment on the other endpoints, not the id. statusstring`active` or `inactive`. rankintThe display order. modulestringThe registrar module attached. `none` when there is none, and then registration cannot run automatically. auto_pricingboolWhether automatic pricing is on. featuresobject 4 fieldsWhat the extension supports. dns_manageboolThe client can manage DNS records. forwardingboolDomain forwarding is available. whois_privacyboolThe registrant details can be hidden. epp_codeboolA transfer code can be obtained. 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/domain/tlds' \ -H "Authorization: Bearer $API_KEY" \ -d limit=100 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/domain/tlds'); url.searchParams.set('limit', '100'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/domain/tlds?' . http_build_query(['limit' => 100]); $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()->GetDomainTlds([], ['limit' => 100]); ``` #### Extension Detail get/api/v1/admin/products/domain/tlds/{tld} `Products/GetDomainTld` admin addressed by extension Returns one extension with its costs and the whole price table. Response fields data — 13 idintId of the extension record. extensionstringThe extension itself. statusstring`active` or `inactive`. rankintThe display order. modulestringThe registrar module attached. auto_pricingboolWhether automatic pricing is on. featuresobject 4 fieldsWhat the extension supports. dns_manageboolThe client can manage DNS records. forwardingboolDomain forwarding is available. whois_privacyboolThe registrant details can be hidden. epp_codeboolA transfer code can be obtained. min_yearsintThe shortest registration in years. max_yearsintThe longest registration in years. register_costfloatWhat registration costs you at the registrar. renewal_costfloatWhat renewal costs you at the registrar. transfer_costfloatWhat a transfer costs you at the registrar. pricingobjectA three-level map: operation type, then year, then currency code. The innermost object carries `amount`, `status`, `promotion` and `promotion_status`. Errors 2 not_found404No such extension. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/domain/tlds/com' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/com', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/com'); 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()->GetDomainTld(['tld' => 'com']); // The price table has three levels: type, year, currency. $oneYear = $response['data']['pricing']['register']['1']['USD']['amount'] ?? null; ``` Response 200 ```json { "data": { "id": 3, "extension": "com", "status": "active", "module": "Enom", "auto_pricing": false, "features": { "dns_manage": true, "forwarding": false, "whois_privacy": true, "epp_code": true }, "min_years": 1, "max_years": 10, "register_cost": 8.5, "renewal_cost": 9, "transfer_cost": 8.5, "pricing": { "register": { "1": { "USD": { "amount": 12, "status": true, "promotion": 0, "promotion_status": false } } } } } } ``` #### Adding an Extension post/api/v1/admin/products/domain/tlds `Products/CreateDomainTld` admin 201 Adds a new extension. If you like, the starting prices and the logo go in the same request. Body 7 extensionstringrequiredThe extension. A leading dot is dropped. registrarstringName of the registrar module. Defaults to none attached. statusstring`active` or `inactive`. display_orderintThe display order. featuresobject 4 fieldsWhat the extension supports. dns_manageboolThe client can manage DNS records. forwardingboolDomain forwarding is available. whois_privacyboolThe registrant details can be hidden. epp_codeboolA transfer code can be obtained. pricingobjectA map from currency code to a `{register, transfer, renewal}` object. There is no year breakdown here; the detailed table goes in through the pricing endpoint. logostringThe extension logo. A base64 data URI or a link that can be fetched. Response fields data — 13 dataobjectThe extension created. Same shape as the detail endpoint, but a fresh extension holds no prices yet, so `pricing` comes back empty. Errors 4 extension_required422`extension` was empty. extension_exists422This extension already exists. 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/domain/tlds' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"extension":"com","registrar":"Enom","status":"active","features":{"dns_manage":true,"whois_privacy":true},"pricing":{"USD":{"register":12,"transfer":12,"renewal":14}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ extension: 'com', registrar: 'Enom', status: 'active', features: { dns_manage: true, whois_privacy: true }, pricing: { USD: { register: 12, transfer: 12, renewal: 14 } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'extension' => 'com', 'registrar' => 'Enom', 'status' => 'active', 'features' => ['dns_manage' => true, 'whois_privacy' => true], 'pricing' => ['USD' => ['register' => 12, 'transfer' => 12, 'renewal' => 14]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The price here is a one-year starting point; use the pricing endpoint for the year breakdown. $response = Api::Products()->CreateDomainTld([ 'extension' => 'com', 'registrar' => 'Enom', 'pricing' => ['USD' => ['register' => 12, 'renewal' => 14]], ]); ``` #### Deleting an Extension delete/api/v1/admin/products/domain/tlds/{tld} `Products/DeleteDomainTld` admin prices go too Deletes the extension and its prices. Response fields data — 2 deletedboolWhether the delete succeeded. extensionstringThe extension that was deleted. Errors 3 not_found404No such extension. blocked_by_gate422The `gate:domain.tld_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/domain/tlds/com' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/com', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/com'); 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()->DeleteDomainTld(['tld' => 'com']); ``` #### Bulk Action post/api/v1/admin/products/domain/tlds/bulk `Products/BulkDomainTlds` admin two different bodies Switches several extensions on or off, deletes them, or changes their settings. Body 3 actionstringrequiredThe action: `enable`, `disable`, `delete` or `change`. extensionsstring[]The extensions to touch. Needed by the first three actions. changesobject[]A per-extension list of changes. Each element carries `extension` and the fields to change: the features, the module, the status, the order. Only on the `change` action. Response fields data — 2 actionstringThe action that was applied. appliedstring[]The extensions the action actually reached. Errors 1 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/domain/tlds/bulk' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"action":"disable","extensions":["net","org"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/bulk', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'disable', extensions: ['net', 'org'] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/bulk'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'action' => 'disable', 'extensions' => ['net', 'org'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The 'change' action wants a list of changes, not a list of extensions. $response = Api::Products()->BulkDomainTlds([ 'action' => 'change', 'changes' => [ ['extension' => 'net', 'whois_privacy' => true], ['extension' => 'org', 'status' => 'inactive'], ], ]); ``` #### Setting the Pricing put/api/v1/admin/products/domain/tlds/{tld}/pricing `Products/SetDomainTldPricing` admin three-level table Writes the extension's registration, transfer and renewal prices, broken down by year and currency. Body 2 auto_pricingboolTurns automatic pricing on or off. pricingobjectrequiredA three-level map: operation type (`register`, `transfer`, `renewal`), then year, then currency code. The innermost object carries `status`, `fee`, `promotion_status` and `promotion`. Response fields data — 13 dataobjectThe extension as it now stands. Same shape as the detail endpoint: prices come back nested type, year, currency, so read the whole `pricing` map rather than assuming the shape you sent survived. Errors 3 not_found404No such extension. update_failed422The pricing could not be stored. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/domain/tlds/com/pricing' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"auto_pricing":false,"pricing":{"register":{"1":{"USD":{"status":true,"fee":12,"promotion_status":false,"promotion":0}}}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/com/pricing', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ auto_pricing: false, pricing: { register: { 1: { USD: { status: true, fee: 12, promotion_status: false, promotion: 0 } }, }, }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/com/pricing'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'auto_pricing' => false, 'pricing' => [ 'register' => [ 1 => ['USD' => ['status' => true, 'fee' => 12]], ], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Reading calls the field 'amount', writing calls it 'fee' - they hold the same value. $current = Api::Products()->GetDomainTld(['tld' => 'com'])['data']['pricing']; $amount = $current['register']['1']['USD']['amount']; Api::Products()->SetDomainTldPricing([ 'tld' => 'com', 'pricing' => [ 'register' => [1 => ['USD' => ['status' => true, 'fee' => $amount + 1]]], ], ]); ``` #### Extensions That Ask for Documents get/api/v1/admin/products/domain/docs `Products/GetDomainDocsList` admin Returns the extensions that ask for documents at registration. Query parameters 1 searchstringSearches the extension. Response fields data[] — 1 tldstringThe extension itself. 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/domain/docs' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/docs', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/docs'); 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()->GetDomainDocsList(); ``` #### Reading the Document Set get/api/v1/admin/products/domain/tlds/{tld}/docs `Products/GetDomainTldDocs` admin Returns the documents an extension asks for at registration. Response fields data — 3 tldstringThe extension itself. descriptionobjectA map from language code to description. Shown to the client above the document set. docsobject[] 6 fieldsThe documents asked for. idintDocument id. typestring`text`, `file` or `select`. sortnumintThe sort number. statusstring`active` or `inactive`. namesobjectA map from language code to document name. optionsarrayThe values offered on the choice type. Errors 2 not_found404No such extension. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs'); 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()->GetDomainTldDocs(['tld' => 'us']); ``` #### Writing the Document Set put/api/v1/admin/products/domain/tlds/{tld}/docs `Products/SetDomainTldDocs` admin full replacement Writes the document set. What you send becomes the truth; a document missing from it is deleted. Body 2 descriptionobjectA map from language code to description. docsobjectrequiredA map from document key to a document object. Each carries a type, a name per language, and rules for the type: allowed extensions, maximum size, choice values. Give a new document a key you made up; for an existing one use the record's id. Response fields data — 3 dataobjectThe document set as it now stands. Same shape as the read endpoint, so the keys you made up come back as record ids. Errors 3 not_found404No such extension. update_failed422The set could not be stored. A document name may be empty, an extension may be duplicated, or nothing may have changed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"description":{"en":"Provide a valid ID."},"docs":{"n1":{"type":"text","name":{"en":"Registrant ID"}}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ description: { en: 'Provide a valid ID.' }, docs: { n1: { type: 'text', name: { en: 'Registrant ID' } }, }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'description' => ['en' => 'Provide a valid ID.'], 'docs' => [ 'n1' => ['type' => 'text', 'name' => ['en' => 'Registrant ID']], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD a document, send the existing set too, or the earlier ones are deleted. $existing = Api::Products()->GetDomainTldDocs(['tld' => 'us'])['data']['docs']; $docs = []; foreach ($existing as $doc) $docs[$doc['id']] = ['type' => $doc['type'], 'name' => $doc['names']]; $docs['n1'] = ['type' => 'file', 'name' => ['en' => 'Passport scan']]; Api::Products()->SetDomainTldDocs(['tld' => 'us', 'docs' => $docs]); ``` #### Deleting the Document Set delete/api/v1/admin/products/domain/tlds/{tld}/docs `Products/DeleteDomainTldDocs` admin Deletes the extension's whole document set. Response fields data — 2 deletedboolWhether the delete succeeded. tldstringThe extension itself. Errors 2 not_found404There is no document set for this extension. 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/domain/tlds/us/docs' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/tlds/us/docs'); 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()->DeleteDomainTldDocs(['tld' => 'us']); ``` ### Pitfalls > **Reading says amount, writing says fee** > > In the price table the same value is called `amount` when you read it and `fee` when you write it. That means the table you read from the detail cannot go straight back: you have to rename the field first. > **The document set is replaced in full** > > The document write does not merge. To add one document you have to send the whole existing set as well, or the earlier ones are deleted and that extension stops asking for anything. Give a new document a key you made up, and use the record's id for an existing one. > **The bulk endpoint expects two different bodies** > > Enabling, disabling and deleting want a list of extensions, while changing settings wants a per-extension list of changes. Sending the wrong field quietly leaves the action empty: the returned list comes back empty and no error is raised. Check what was applied from that list. > **An extension without a module runs by hand** > > With no registrar module attached the extension can still be sold, but registration, transfer and renewal do not run automatically; an operator does them by hand. Check the module is attached before opening the extension. ### Related Articles - [Domain Pricing and Settings](https://dev.wisecp.com/en/domain-pricing-settings) - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) ## Domain Pricing and Settings https://dev.wisecp.com/en/domain-pricing-settings The seven endpoints behind the overall rules of selling domains: recovery, add-on fees, automatic pricing and premium. ### Overview These endpoints look not at one extension but at the **overall rules** of selling domains: how long and at what cost an expired domain can be recovered, what DNS management or WHOIS privacy costs, whether prices are set automatically, and whether premium domains are sold at all. An extension's own price and features live elsewhere; the settings here work above them. The one exception is the recovery fee: name an extension and it is written for that one only. ### Reference #### A WHOIS Query get/api/v1/admin/products/domain/whois `Products/GetDomainWhois` admin goes outside Runs a WHOIS query for a domain and returns the raw output. Query parameters 1 domainstringrequiredThe domain to look up, extension included. Response fields data — 3 domainstringThe domain that was looked up. availableboolWhether the domain is free. outputstringThe raw text from the registry. The format differs between registries; do not rely on parsing it. Errors 4 domain_required422`domain` was empty. invalid_domain422The domain could not be read. whois_failed422The WHOIS data could not be fetched. The remote server may be unreachable or rate limiting you. 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/domain/whois' \ -H "Authorization: Bearer $API_KEY" \ -d domain=example.com ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/domain/whois'); url.searchParams.set('domain', 'example.com'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/domain/whois?' . http_build_query(['domain' => 'example.com']); $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 // The query goes out to a remote server: it can be slow and it can be rate limited. $response = Api::Products()->GetDomainWhois([], ['domain' => 'example.com']); $free = $response['data']['available'] ?? false; ``` #### Grace and Redemption Fees put/api/v1/admin/products/domain/grace-redemption `Products/SetDomainGraceRedemption` admin global or per extension Sets how long an expired domain can be recovered and what that costs. Body 3 extensionstringThe extension. Left empty, the setting becomes the default for every extension. graceobject 2 fieldsThe grace period: its length and a fee per currency. The window in which the domain can be taken back before it is released. durationintHow many days the period lasts. feesobject 2 fieldsA map from currency code to a fee object. feefloatThe fee amount. statusboolWhether the fee applies. Switched off, the amount is stored but not charged. redemptionobject 2 fieldsThe redemption period: its length and a fee per currency. The window after grace ends, usually an expensive one. durationintHow many days the period lasts. feesobject 2 fieldsA map from currency code to a fee object. feefloatThe fee amount. statusboolWhether the fee applies. Switched off, the amount is stored but not charged. Response fields data — 1 extensionstring | nullThe extension the setting was applied to. Empty on the global default. Errors 2 not_found404The extension you gave does not exist. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/domain/grace-redemption' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"grace":{"duration":30,"fees":{"USD":{"fee":0,"status":false}}},"redemption":{"duration":30,"fees":{"USD":{"fee":80,"status":true}}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/grace-redemption', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ grace: { duration: 30, fees: { USD: { fee: 0, status: false } } }, redemption: { duration: 30, fees: { USD: { fee: 80, status: true } } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/grace-redemption'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'redemption' => [ 'duration' => 30, 'fees' => ['USD' => ['fee' => 80, 'status' => true]], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Giving an extension writes the setting for THAT one only; the global default stays. Api::Products()->SetDomainGraceRedemption([ 'extension' => 'com', 'redemption' => [ 'duration' => 30, 'fees' => ['USD' => ['fee' => 80, 'status' => true]], ], ]); ``` #### Domain Add-on Fees put/api/v1/admin/products/domain/addon-pricing `Products/SetDomainAddonPricing` admin every extension Sets the fee for DNS management, forwarding, WHOIS privacy and the transfer code. Body 2 typestringrequiredThe feature to price: `dns_manage`, `forwarding`, `whois_privacy` or `epp_code`. feesobjectrequired 2 fieldsA map from currency code to a fee object. feefloatThe fee amount. statusboolWhether the fee applies. Switched off, the amount is stored but not charged. Response fields data — 1 typestringThe feature whose fee was written. Errors 2 invalid_type422The feature is not one of the four values. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/domain/addon-pricing' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"whois_privacy","fees":{"USD":{"fee":5,"status":true}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/addon-pricing', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'whois_privacy', fees: { USD: { fee: 5, status: true } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/addon-pricing'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'whois_privacy', 'fees' => ['USD' => ['fee' => 5, 'status' => true]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The fee is the same on every extension; which extension OFFERS it lives on the TLD record. Api::Products()->SetDomainAddonPricing([ 'type' => 'whois_privacy', 'fees' => ['USD' => ['fee' => 5, 'status' => true]], ]); ``` #### The Automatic Pricing State get/api/v1/admin/products/domain/auto-pricing `Products/GetDomainAutoPricing` admin Returns whether automatic pricing is on and what the profit margin is. Response fields data — 2 statusboolWhether automatic pricing is on. profit_ratefloatThe profit margin added on top of cost, as a percentage. 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/domain/auto-pricing' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/auto-pricing', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/auto-pricing'); 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()->GetDomainAutoPricing(); ``` #### Setting Automatic Pricing put/api/v1/admin/products/domain/auto-pricing `Products/SetDomainAutoPricing` admin two branches Turns automatic pricing on or off, or changes the margin and reprices. Body 3 updatestringrequiredWhich branch runs: `status` or `pricing`. statusboolTurns automatic pricing on or off. Only on the `status` branch. ratefloatThe profit margin as a percentage. Only on the `pricing` branch; writing it reprices every auto-priced extension at once. Response fields data — 2 dataobjectThe automatic pricing settings as they now stand. Same shape as the read endpoint. Errors 3 invalid_update422`update` is neither of the two values. update_failed422The setting could not be stored. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/domain/auto-pricing' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"update":"pricing","rate":20}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/auto-pricing', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ update: 'pricing', rate: 20 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/auto-pricing'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['update' => 'pricing', 'rate' => 20]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Writing the margin reprices EVERY auto-priced extension there and then. Api::Products()->SetDomainAutoPricing([ 'update' => 'pricing', 'rate' => 20, ]); ``` #### Premium Domain Settings get/api/v1/admin/products/domain/premium `Products/GetDomainPremium` admin Returns whether premium domain sales are on and what the price tiers are. Response fields data — 2 statusboolWhether premium domain sales are on. pricingobject[] 2 fieldsThe price tiers. amountfloatThe amount the tier starts at. markupfloatThe markup applied in that tier. 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/domain/premium' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/premium', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/premium'); 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()->GetDomainPremium(); ``` #### Writing the Premium Settings put/api/v1/admin/products/domain/premium `Products/SetDomainPremium` admin two branches Turns premium sales on or off, or writes the price tiers. Body 3 updatestringrequiredWhich branch runs: `status` or `pricing`. statusboolTurns premium sales on or off. Only on the `status` branch. feesobject[] 2 fieldsThe price tiers. Only on the `pricing` branch; the list is written whole. amountfloatThe amount the tier starts at. markupfloatThe markup applied in that tier. Response fields data — 2 dataobjectThe premium settings as they now stand. Same shape as the read endpoint. Errors 2 invalid_update422`update` is neither of the two values. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/domain/premium' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"update":"pricing","fees":[{"amount":100,"markup":10},{"amount":1000,"markup":5}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/domain/premium', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ update: 'pricing', fees: [ { amount: 100, markup: 10 }, { amount: 1000, markup: 5 }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/domain/premium'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'update' => 'pricing', 'fees' => [ ['amount' => 100, 'markup' => 10], ['amount' => 1000, 'markup' => 5], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The tier list is written whole: a missing tier is deleted. Api::Products()->SetDomainPremium([ 'update' => 'pricing', 'fees' => [ ['amount' => 100, 'markup' => 10], ['amount' => 1000, 'markup' => 5], ], ]); ``` ### Pitfalls > **Writing the margin changes prices right away** > > Writing the profit margin does not only store a setting: **every extension** with automatic pricing on is repriced at the same moment. The change reaches the storefront immediately, with no cron to wait for. Move the margin in small steps and read the result from the extension detail. > **Two endpoints pick a single branch** > > The automatic pricing and premium endpoints run a single branch chosen by `update`. A field sent on the wrong branch is ignored without a word: sending the margin on the status branch changes nothing and raises no error. Always confirm through the read endpoint. > **The fee and the offer live apart** > > The add-on fee is written here, shared by every extension. Whether an extension offers that feature at all sits on its own record. A feature you priced but no extension offers is never paid for, so check both together. > **The WHOIS query goes outside** > > The query goes to a remote server: it can be slow, it can hit a rate limit, and the text that comes back is formatted differently by every registry. If you are writing a bulk check, set a timeout and use the `available` field rather than parsing the raw output. ### Related Articles - [Domain Extensions](https://dev.wisecp.com/en/domain-extensions) - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) ## Provisioning Servers https://dev.wisecp.com/en/provisioning-servers The eleven endpoints that define and test the servers services run on, and import existing accounts. ### Overview A server is where services actually get provisioned. These endpoints define one, test its connection, set what shows in the panel, and move accounts already sitting on the server into WISECP. Credentials go one way: you write the password and the access key but **no endpoint reads them back**. The detail only shows whether they are set. ### Reference #### Listing the Servers get/api/v1/admin/products/servers `Products/GetServers` admin paged Returns the servers services are provisioned on. Query parameters 4 searchstringSearches the name and the address. groupintFilters by server group. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 6 idintServer id. namestringThe server name. typestringThe server module attached. ipstringThe server address. statusstring`active` ya da `inactive`. max_accountsintThe most accounts that can be provisioned. 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 'https://panel.example.com/api/v1/admin/products/servers' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers'); 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()->GetServers(); ``` #### Server Detail get/api/v1/admin/products/servers/{id} `Products/GetServer` admin no secrets returned Returns all of a server's settings. The password and access key never come back. Response fields data — 16 idintServer id. namestringThe server name. typestringThe server module attached. ipstringThe server address. usernamestringThe connection username. has_passwordboolWhether a password is set. The password itself is never returned. has_access_hashboolWhether an access key is set. portintThe connection port. secureboolWhether the connection is encrypted. nameserversstring[]The server's nameservers. Up to four; empty ones are dropped. max_accountsintThe most accounts that can be provisioned. full_alertintThe threshold at which the capacity warning fires. costobject 2 fieldsWhat the server costs you. pricefloatWhat the server costs you. currency_idintCurrency id. statusstring`active` ya da `inactive`. fieldsobjectThe module's own settings. The fields depend entirely on the module; the ones marked secret come back masked with asterisks. disabled_featuresobject | nullThe features switched off in the panel. Empty when none are. Errors 2 not_found404No such server. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/servers/34' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/34', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/34'); 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()->GetServer(['id' => 34]); // The password itself is absent; only whether one is set. $ready = $response['data']['has_password'] || $response['data']['has_access_hash']; ``` Response 200 ```json { "data": { "id": 34, "name": "srv1.example.com", "type": "cPanel", "ip": "192.0.2.10", "username": "root", "has_password": true, "has_access_hash": false, "port": 2087, "secure": true, "nameservers": ["ns1.example.com", "ns2.example.com"], "max_accounts": 200, "full_alert": 0, "cost": { "price": 0, "currency_id": 147 }, "status": "active", "fields": { "api_url": "https://panel.example.com/" }, "disabled_features": null } } ``` #### Adding a Server post/api/v1/admin/products/servers `Products/CreateServer` admin 201 Adds a new provisioning server. Body 13 typestringrequiredName of the server module. namestringrequiredThe server name. ipstringrequiredThe server address. usernamestringrequiredThe connection username. passwordstringThe password. Either this or an access key is needed; it is stored encrypted. access_hashstringThe access key. It stands in for the password. portintThe connection port. secureboolEncrypts the connection. max_accountsintThe most accounts that can be provisioned. Defaults to 200. full_alertintThe threshold at which the capacity warning fires. nameserversstring[]The nameservers. Up to four. costobject 2 fieldsWhat the server costs you. pricefloatWhat the server costs you. currency_idintCurrency id. fieldsobjectThe module's own settings. Response fields 201 — data dataobjectThe server created. Same shape as the detail endpoint, so no secret comes back: the password and the access key are reported as `has_password` and `has_access_hash`, and module fields marked secret are masked. Errors 6 name_required422The server name was empty. type_required422The server type was empty. ip_required422The address was empty. username_required422The username was empty. credentials_required422Neither a password nor an access key was given. 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/servers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"cPanel","name":"srv1.example.com","ip":"192.0.2.10","username":"root","password":"secret","port":2087,"secure":true,"nameservers":["ns1.example.com","ns2.example.com"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'cPanel', name: 'srv1.example.com', ip: '192.0.2.10', username: 'root', password: 'secret', port: 2087, secure: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'cPanel', 'name' => 'srv1.example.com', 'ip' => '192.0.2.10', 'username' => 'root', 'password' => $secret, 'port' => 2087, 'secure' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Try the connection with the same body first: the test writes nothing. $test = Api::Products()->TestServerConnection([ 'type' => 'cPanel', 'ip' => '192.0.2.10', 'username' => 'root', 'password' => $secret, ]); if ($test['data']['connected'] ?? false) { Api::Products()->CreateServer([ 'type' => 'cPanel', 'name' => 'srv1.example.com', 'ip' => '192.0.2.10', 'username' => 'root', 'password' => $secret, ]); } ``` #### Updating a Server patch/api/v1/admin/products/servers/{id} `Products/UpdateServer` admin a type change migrates Applies the fields you send. Leave the password out and the current one is kept. Body 13 typestringrequiredName of the server module. namestringrequiredThe server name. ipstringrequiredThe server address. usernamestringrequiredThe connection username. passwordstringThe password. Either this or an access key is needed; it is stored encrypted. access_hashstringThe access key. It stands in for the password. portintThe connection port. secureboolEncrypts the connection. max_accountsintThe most accounts that can be provisioned. Defaults to 200. full_alertintThe threshold at which the capacity warning fires. nameserversstring[]The nameservers. Up to four. costobject 2 fieldsWhat the server costs you. pricefloatWhat the server costs you. currency_idintCurrency id. fieldsobjectThe module's own settings. Response fields data dataobjectThe server as it now stands. Same shape as the detail endpoint and equally secret-free. Errors 7 not_found404No such server. name_required422The server name was empty. type_required422The server type was empty. ip_required422The address was empty. username_required422The username was empty. credentials_required422Neither a password nor an access key was given. 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/servers/34' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"max_accounts":300,"secure":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/34', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ max_accounts: 300, secure: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/34'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['max_accounts' => 300, 'secure' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Sending back the masked password from the detail is harmless: the current one is kept. Api::Products()->UpdateServer([ 'id' => 34, 'max_accounts' => 300, ]); ``` #### Deleting a Server delete/api/v1/admin/products/servers/{id} `Products/DeleteServer` admin refused while in use Deletes the server. The delete is refused while it still carries live services. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted server. Errors 4 not_found404No such server. server_in_use422The server still carries live services. blocked_by_gate422The `gate:product.server_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/servers/34' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/34', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/34'); 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()->DeleteServer(['id' => 34]); ``` #### Changing Status in Bulk post/api/v1/admin/products/servers/bulk `Products/BulkServers` admin all or nothing Changes several servers' status. The whole list is validated before anything is written. Body 2 idsint[]requiredThe server ids. actionstringrequired`active` ya da `inactive`. Response fields data — 2 updatedint[]The ids whose status changed. actionstringThe status that was applied. Errors 4 ids_required422`ids` was empty. invalid_action422The action is neither of the two values. server_in_use422A server is still tied to products or live services. The error detail names which server and which tie blocked it. 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/servers/bulk' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[34,35],"action":"inactive"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/bulk', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [34, 35], action: 'inactive' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/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' => [34, 35], 'action' => 'inactive', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // If one server is refused, NONE of them change; there is no half-applied state. $response = Api::Products()->BulkServers([ 'ids' => [34, 35], 'action' => 'inactive', ]); ``` #### Testing the Connection post/api/v1/admin/products/servers/test-connection `Products/TestServerConnection` admin writes nothing Tries to reach the server with the details you give, and does nothing else. Body 13 typestringrequiredName of the server module. namestringrequiredThe server name. ipstringrequiredThe server address. usernamestringrequiredThe connection username. passwordstringThe password. Either this or an access key is needed; it is stored encrypted. access_hashstringThe access key. It stands in for the password. portintThe connection port. secureboolEncrypts the connection. max_accountsintThe most accounts that can be provisioned. Defaults to 200. full_alertintThe threshold at which the capacity warning fires. nameserversstring[]The nameservers. Up to four. costobject 2 fieldsWhat the server costs you. pricefloatWhat the server costs you. currency_idintCurrency id. fieldsobjectThe module's own settings. Response fields data — 2 connectedboolWhether the server answered. auto_fillobjectSettings the module read from the server and suggests. Not every module returns them. Errors 4 type_required422The server type was empty. ip_required422The address was empty. invalid_type422The server module was not found. test_failed422The connection could not be made. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/admin/products/servers/test-connection' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"cPanel","ip":"192.0.2.10","username":"root","password":"secret","port":2087}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/test-connection', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'cPanel', ip: '192.0.2.10', username: 'root', password: secret, port: 2087, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/test-connection'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'cPanel', 'ip' => '192.0.2.10', 'username' => 'root', 'password' => $secret, 'port' => 2087, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The test does NOT try an existing server; it tries the details in the body. // To check a stored server you send its details again. $test = Api::Products()->TestServerConnection([ 'type' => 'cPanel', 'ip' => '192.0.2.10', 'username' => 'root', 'password' => $secret, ]); ``` #### Switching Panel Features Off patch/api/v1/admin/products/servers/{id}/preferences `Products/SetServerPreferences` admin Sets which tools and cards stay hidden from the client on this server's services. Body 1 disabled_featuresobjectrequiredWhat to switch off: `tools` holds tool names, `cards` card names, and `card_items` the rows to hide per card. Response fields data — 2 idintServer id. disabled_featuresobjectThe setting that was stored. Errors 2 not_found404No such server. 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/servers/34/preferences' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"disabled_features":{"tools":["backup"],"cards":[],"card_items":{}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/34/preferences', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ disabled_features: { tools: ['backup'], cards: [], card_items: {} }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/34/preferences'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'disabled_features' => [ 'tools' => ['backup'], 'cards' => [], 'card_items' => new stdClass(), ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Products()->SetServerPreferences([ 'id' => 34, 'disabled_features' => [ 'tools' => ['backup'], 'cards' => [], 'card_items' => [], ], ]); ``` #### Signing In to the Server Panel post/api/v1/admin/products/servers/{id}/sso `Products/GetServerSso` admin module dependent Produces a sign-in link for the server's own control panel. Body — ——No body is needed, send an empty one. The server comes from the path and the credentials from its stored record. Response fields data — 1 login_urlstringThe sign-in link carrying the session. Errors 4 not_found404No such server. not_supported422The module does not support panel sign-in. sso_failed422The sign-in link could not be produced. 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/servers/34/sso' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/34/sso', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/34/sso'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The link opens the server's ADMIN panel, not a client account. $response = Api::Products()->GetServerSso(['id' => 34]); $url = $response['data']['login_url'] ?? null; ``` #### Listing the Importable Accounts get/api/v1/admin/products/servers/{id}/importable `Products/GetServerImportable` admin schema comes from the module Returns the accounts that exist on the server but have no counterpart in WISECP. Query parameters 3 searchstringSearches the accounts. pageintThe page. Works only on modules that can paginate. limitintThe page size. Maximum 200. Response fields data[] data[]objectEach row is the raw account record the module read from the server. The fields depend on the module; you send this object back untouched when importing. Meta 4 totalintHow many accounts were found. methodstringThe listing method the module used. pageintThe page you are on. Present on the paginated method. limitintThe page size. Errors 5 not_found404No such server. module_not_found422The server module was not found. import_not_supported422The module cannot list accounts. list_failed422The accounts could not be listed. 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/servers/34/importable' \ -H "Authorization: Bearer $API_KEY" \ -d limit=200 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/servers/34/importable'); url.searchParams.set('limit', '200'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/servers/34/importable?' . http_build_query(['limit' => 200]); $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 $accounts = Api::Products()->GetServerImportable(['id' => 34], ['limit' => 200]); // 'method' names the listing path used; whether paging works depends on it. $paged = ($accounts['meta']['method'] ?? '') === 'list'; ``` #### Importing the Accounts post/api/v1/admin/products/servers/{id}/import `Products/ImportServerAccounts` admin 201 Turns the server's accounts into WISECP services. Each row is tied to a client and a product. Body 1 itemsobject[]requiredThe rows to import. Each carries the raw account record from the listing endpoint, a client id, a product id, a price id and a start date; the end date is optional. Response fields data — 1 importedobject[]The services created. Each element carries a service id and a name. Errors 5 not_found404No such server. items_required422`items` was empty. no_valid_items422No row had a complete mapping. Rows missing a client, product or price id are dropped without a word. import_failed422The import 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/servers/34/import' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "items": [ { "info": { "username": "acct1", "domain": "client-domain.example" }, "user_id": 42, "product_id": 15, "price_id": 88, "start": "2026-01-01 00:00:00" } ] }' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/servers/34/import', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ items: [ { info: account, user_id: 42, product_id: 15, price_id: 88, start: '2026-01-01 00:00:00', }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/servers/34/import'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'items' => [[ 'info' => $account, 'user_id' => 42, 'product_id' => 15, 'price_id' => 88, 'start' => '2026-01-01 00:00:00', ]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // 'info' goes back exactly as the listing endpoint gave it; do not reshape it. $accounts = Api::Products()->GetServerImportable(['id' => 34])['data']; $items = []; foreach ($accounts as $account) $items[] = [ 'info' => $account, 'user_id' => 42, 'product_id' => 15, 'price_id' => 88, 'start' => '2026-01-01 00:00:00', ]; $done = Api::Products()->ImportServerAccounts(['id' => 34, 'items' => $items]); // Compare what you sent with what came back: rows with a gap were dropped. $dropped = count($items) - count($done['data']['imported']); ``` ### Pitfalls > **Secrets cannot be read back** > > The password and the access key appear in no response; the detail only shows whether they are set. The module's secret-marked fields come back masked with asterisks. Sending that mask back on an update is harmless: the current value is kept and the mask is not stored. > **The connection test does not try a stored server** > > The test endpoint tries the details in your body and **writes nothing**. To check whether a stored server still answers you have to send its details again, and since you cannot read the password back, you must be holding it on your side. > **The bulk action never half-applies** > > The bulk status change validates the whole list first and writes afterwards. If a server is still tied to a product or a live service the request is refused and **no server** changes. The error detail names which server and which tie blocked it. > **A row with a gap is dropped silently on import** > > Rows missing a client, product or price id are skipped and do not appear in the response. If all of them are missing you get `no_valid_items`, but if only some are, the request looks successful. Compare how many rows you sent with how many services came back. ### Related Articles - [Server Groups](https://dev.wisecp.com/en/server-groups) - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) ## Server Groups https://dev.wisecp.com/en/server-groups The five endpoints that manage the server groups letting products provision into a pool. ### Overview A server group lets a product be provisioned into a **pool** rather than onto one server. The product points at the group, and the fill strategy decides which server a new service lands on. The group's type is not a field of its own: it comes from the members. That is why only servers on the **same type and the same module** can go into one group; a mixed list is refused outright. ### Reference #### Listing the Groups get/api/v1/admin/products/server-groups `Products/GetServerGroups` admin paged Returns the server groups. Query parameters 3 searchstringSearches the group name. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 6 idintGroup id. namestringThe group name. typestring`hosting` or `server`. It comes from the members; you do not set it. fill_typeintThe fill strategy deciding which server a new service lands on. server_idsint[]Ids of the servers in the group. server_countintHow many servers are in the group. 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 'https://panel.example.com/api/v1/admin/products/server-groups' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/server-groups', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/server-groups'); 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()->GetServerGroups(); ``` Response 200 ```json { "data": [ { "id": 2, "name": "Shared Pool", "type": "hosting", "fill_type": 1, "server_ids": [34, 35], "server_count": 2 } ], "meta": { "total": 1, "page": 1, "limit": 25, "next_page": 0 } } ``` #### Group Detail get/api/v1/admin/products/server-groups/{id} `Products/GetServerGroup` admin Returns one server group. The schema is the same as a list item. Response fields data — 6 idintGroup id. namestringThe group name. typestring`hosting` or `server`. It comes from the members; you do not set it. fill_typeintThe fill strategy deciding which server a new service lands on. server_idsint[]Ids of the servers in the group. server_countintHow many servers are in the group. Errors 2 not_found404No such server group. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/server-groups/2' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/server-groups/2', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/server-groups/2'); 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()->GetServerGroup(['id' => 2]); ``` #### Creating a Group post/api/v1/admin/products/server-groups `Products/CreateServerGroup` admin 201 Opens a server group. Every member has to share the same type and the same module. Body 3 namestringrequiredThe group name. serversint[]requiredIds of the servers to put in. At least one, and all on the same type and module. fill_typeintThe fill strategy. Defaults to 1. Response fields data — 6 idintId of the new group. namestringThe group name. typestring`hosting` or `server`. It comes from the members, so it is never part of the request. fill_typeintThe fill strategy deciding which server a new service lands on. server_idsint[]Ids of the servers in the group. server_countintHow many servers are in the group. Errors 6 name_required422`name` was empty. servers_required422No server was given. invalid_server422One of the servers you gave does not exist. no_capacity422One of the servers has no account capacity. mixed_servers422The servers do not share a type and a module. 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/server-groups' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Shared Pool","servers":[34,35],"fill_type":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/server-groups', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Shared Pool', servers: [34, 35], fill_type: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/server-groups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Shared Pool', 'servers' => [34, 35], 'fill_type' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Filter out type and module mismatches yourself: a mixed list is refused outright. $servers = Api::Products()->GetServers([], ['limit' => 100])['data']; $cpanel = array_column( array_filter($servers, fn (array $s): bool => $s['type'] === 'cPanel'), 'id', ); Api::Products()->CreateServerGroup([ 'name' => 'Shared Pool', 'servers' => $cpanel, ]); ``` #### Updating a Group patch/api/v1/admin/products/server-groups/{id} `Products/UpdateServerGroup` admin the member list is written whole Applies the fields you send. Send the member list and the group becomes exactly that list. Body 3 namestringThe group name. If you send it, it cannot be empty. serversint[]The complete set of servers. A server missing from the list leaves the group. fill_typeintThe fill strategy. Response fields data — 6 idintGroup id. namestringThe group name. typestring`hosting` or `server`. It comes from the members; you do not set it. fill_typeintThe fill strategy deciding which server a new service lands on. server_idsint[]Ids of the servers in the group. The up-to-date list, after your change. server_countintHow many servers are in the group. Errors 6 not_found404No such server group. name_required422The group name you sent was empty. invalid_server422One of the servers you gave does not exist. no_capacity422One of the servers has no account capacity. mixed_servers422The servers do not share a type and a module. 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/server-groups/2' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"servers":[34,35,36]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/server-groups/2', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ servers: [34, 35, 36] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/server-groups/2'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['servers' => [34, 35, 36]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD a server send the current list too, or the others leave the group. $group = Api::Products()->GetServerGroup(['id' => 2])['data']; Api::Products()->UpdateServerGroup([ 'id' => 2, 'servers' => [...$group['server_ids'], 36], ]); ``` #### Deleting a Group delete/api/v1/admin/products/server-groups/{id} `Products/DeleteServerGroup` admin refused while in use Deletes the server group. The delete is refused while a product still points at it. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted group. Errors 4 not_found404No such server group. group_in_use422The group is still used by products. blocked_by_gate422The `gate:product.server_group_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/server-groups/2' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/server-groups/2', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/server-groups/2'); 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 // Deleting the group does not delete its members: the servers stay, only the grouping goes. $response = Api::Products()->DeleteServerGroup(['id' => 2]); ``` ### Pitfalls > **A mixed list is refused outright** > > Because the group takes its type from its members, servers on different types or different modules cannot sit together. Send such a list and **none** of them is added; the request comes back as `mixed_servers`. Filter the list on your side first. > **The member list is written whole** > > Sending a server list on an update makes the group **exactly** that list; servers missing from it leave. To add one server, read the current list first, append to it, and send all of it back. > **A server with no capacity cannot join** > > A server with no account capacity cannot be a member, and the request comes back as `no_capacity`. The fill strategy decides by capacity, so this is not a formality but a working condition. ### Related Articles - [Provisioning Servers](https://dev.wisecp.com/en/provisioning-servers) - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) ## International SMS https://dev.wisecp.com/en/international-sms The fifteen endpoints that manage international SMS sender name applications, country prices and module settings. ### Overview Some countries insist the SMS sender name is approved in advance. The client applies with documents and you approve or refuse; the first six endpoints run that flow. The rest is about the selling: which module sends, the cost and price per country, the margin, and clearing old records. Prices are kept in a single **primary currency**, and automatic pricing does not run until it is set. ### Reference #### Listing the Applications get/api/v1/admin/products/sms/intl-origins `Products/GetSmsIntlOrigins` admin paged Returns the sender name applications clients opened, one per country. Query parameters 3 searchstringSearches the records. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 11 idintRecord id. origin_idintId of the domestic sender name it comes from. origin_namestringThe sender name. country_codestringThe country code. Two letters, lower case. statusstring`waiting` is awaiting review, `active` was approved, `inactive` was refused. status_messagestringThe message left on the decision. clientobject 3 fieldsThe client who opened the request. idintClient id. full_namestringFirst and last name. company_namestringCompany name. attachmentsarray 4 fieldsThe documents the client uploaded. sizeintThe file size in bytes. file_namestringThe original name the client uploaded. namestringThe name stored on the server. file_pathstringWhere the file is kept. created_atstring | nullWhen the request was opened. approved_datestring | nullWhen it was approved. rejected_datestring | nullWhen it was refused. 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 'https://panel.example.com/api/v1/admin/products/sms/intl-origins' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-origins', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-origins'); 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()->GetSmsIntlOrigins(); $waiting = array_filter( $response['data'], fn (array $r): bool => $r['status'] === 'waiting', ); ``` Response 200 ```json { "data": [ { "id": 9, "origin_id": 1, "origin_name": "ACME", "country_code": "sv", "status": "waiting", "status_message": "", "client": { "id": 2, "full_name": "John Doe", "company_name": "" }, "attachments": [ { "size": 255435, "file_name": "licence.jpg", "name": "215d8151c44f2d058eee5e5.jpg", "file_path": "215d8151c44f2d058eee5e5.jpg" } ], "created_at": "2026-03-09 11:52:45", "approved_date": null, "rejected_date": null } ], "meta": { "total": 2, "page": 1, "limit": 25, "next_page": 0 } } ``` #### Application Detail get/api/v1/admin/products/sms/intl-origins/{id} `Products/GetSmsIntlOrigin` admin Returns one application. The schema is the same as a list item. Response fields data — 11 idintRecord id. origin_idintId of the domestic sender name it comes from. origin_namestringThe sender name. country_codestringThe country code. Two letters, lower case. statusstring`waiting` is awaiting review, `active` was approved, `inactive` was refused. status_messagestringThe message left on the decision. clientobject 3 fieldsThe client who opened the request. idintClient id. full_namestringFirst and last name. company_namestringCompany name. attachmentsarray 4 fieldsThe documents the client uploaded. sizeintThe file size in bytes. file_namestringThe original name the client uploaded. namestringThe name stored on the server. file_pathstringWhere the file is kept. created_atstring | nullWhen the request was opened. approved_datestring | nullWhen it was approved. rejected_datestring | nullWhen it was refused. Errors 2 not_found404No such record. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/sms/intl-origins/9' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-origins/9', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-origins/9'); 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()->GetSmsIntlOrigin(['id' => 9]); ``` #### Settling an Application put/api/v1/admin/products/sms/intl-origins/{id}/status `Products/SetSmsIntlOriginStatus` admin Approves or refuses the application. Body 2 actionstringrequired`active` approves, `inactive` refuses. reasonstringThe message to leave on the decision. Canned texts come from the reasons endpoint. Response fields data dataobjectThe application as it now stands. Same shape as the detail schema. Of the two decision dates, the one that no longer applies is cleared. Errors 3 not_found404No such record. invalid_action422The action is neither `active` nor `inactive`. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/sms/intl-origins/9/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"action":"inactive","reason":"Documents missing"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-origins/9/status', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ action: 'inactive', reason: 'Documents missing' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-origins/9/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'action' => 'inactive', 'reason' => 'Documents missing', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Take the canned reason from the template list so the client always sees the same text. $reasons = Api::Products()->GetSmsReasons([], ['group' => 'international-sms'])['data']; Api::Products()->SetSmsIntlOriginStatus([ 'id' => 9, 'action' => 'inactive', 'reason' => $reasons['en'][0]['description'] ?? '', ]); ``` #### Settling in Bulk post/api/v1/admin/products/sms/intl-origins/bulk `Products/BulkSmsIntlOrigins` admin no reason Approves or refuses several applications in one call. Body 2 idsint[]requiredThe application ids. actionstringrequired`active` or `inactive`. Response fields data — 2 updatedint[]The ids whose status changed. actionstringThe status that was applied. Errors 3 ids_required422`ids` was empty. invalid_action422The action is neither `active` nor `inactive`. 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/sms/intl-origins/bulk' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[9,10],"action":"active"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-origins/bulk', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [9, 10], action: 'active' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-origins/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' => [9, 10], 'action' => 'active']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The bulk endpoint has NO reason field: settle one by one when a refusal message is needed. Api::Products()->BulkSmsIntlOrigins([ 'ids' => [9, 10], 'action' => 'active', ]); ``` #### Deleting an Application delete/api/v1/admin/products/sms/intl-origins/{id} `Products/DeleteSmsIntlOrigin` admin documents go too Deletes the application and the documents uploaded with it. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted application. Errors 2 not_found404No such record. 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/sms/intl-origins/9' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-origins/9', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-origins/9'); 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()->DeleteSmsIntlOrigin(['id' => 9]); ``` #### A Delivery Report get/api/v1/admin/products/sms/intl-reports/{id} `Products/GetSmsIntlReport` admin asks the module Asks the provider for the live delivery state of a sent message. Response fields data — 5 modulestringThe SMS module that produced the report. sendingarrayRecipients still in flight. The row shape belongs to the module, so it differs from one provider to the next. deliveredarrayRecipients confirmed delivered. failedarrayRecipients that failed. total_recipientsintThe count the module reports. Read this one rather than the lengths of the three arrays; they can differ. Errors 5 not_found404No such report. no_module422There is no SMS module for this report. not_supported422The module does not support delivery reports. report_failed422The provider did not answer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/products/sms/intl-reports/501' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-reports/501', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-reports/501'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The report is produced by asking the provider: it can be slow and depends on module support. $response = Api::Products()->GetSmsIntlReport(['id' => 501]); ``` #### Listing the Refusal Reasons get/api/v1/admin/products/sms/reasons `Products/GetSmsReasons` admin Returns the canned texts used when refusing an application, one set per language. Query parameters 1 groupstringWhich channel: `sms` or `international-sms`. It defaults to `sms`, so forgetting the parameter gives you the domestic list. Response fields data dataobjectA map from language code to a list of reasons. Each carries a title and a description. 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/sms/reasons' \ -H "Authorization: Bearer $API_KEY" \ -d group=international-sms ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/sms/reasons'); url.searchParams.set('group', 'international-sms'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/sms/reasons?' . http_build_query(['group' => 'international-sms']); $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()->GetSmsReasons([], ['group' => 'international-sms']); ``` #### Adding a Refusal Reason post/api/v1/admin/products/sms/reasons `Products/AddSmsReason` admin 201 Adds a canned refusal text to one language. Body 2 langstringrequiredWhich language the text goes into. reasonstringrequiredThe reason text. The title is built from its first fifty characters. Query parameters 1 groupstringWhich channel: `sms` or `international-sms`. It defaults to `sms`, so forgetting the parameter gives you the domestic list. Response fields data dataobjectThe whole reason set after the write, returned with `201`. Same shape as the listing: one key per installed language, each holding its own list. A language with no reasons comes back as an empty array. Errors 2 reason_required422The language or the text was empty. 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/sms/reasons?group=international-sms' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"lang":"en","reason":"Please upload a valid trade licence."}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/reasons?group=international-sms', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ lang: 'en', reason: 'Please upload a valid trade licence.', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/reasons?group=international-sms'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'lang' => 'en', 'reason' => 'Please upload a valid trade licence.', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Products()->AddSmsReason( ['lang' => 'en', 'reason' => 'Please upload a valid trade licence.'], ['group' => 'international-sms'], ); ``` #### Deleting a Refusal Reason delete/api/v1/admin/products/sms/reasons/{index} `Products/DeleteSmsReason` admin deleted by position Deletes a reason from one language by its position in the list. Query parameters 2 langstringrequiredWhich language's list to delete from. groupstringWhich channel: `sms` or `international-sms`. It defaults to `sms`, so forgetting the parameter gives you the domestic list. Response fields data dataobjectThe whole reason set after the delete. Same shape as the listing. The list is renumbered, so every reason below the deleted one moves up a position. Errors 3 lang_required422`lang` was not given. not_found404There is no reason at that position. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE -G 'https://panel.example.com/api/v1/admin/products/sms/reasons/0' \ -H "Authorization: Bearer $API_KEY" \ -d lang=en \ -d group=international-sms ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/products/sms/reasons/0'); url.searchParams.set('lang', 'en'); url.searchParams.set('group', 'international-sms'); const res = await fetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/products/sms/reasons/0?' . http_build_query([ 'lang' => 'en', 'group' => 'international-sms', ]); $ch = curl_init($url); 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 // Positions SHIFT after a delete: going back to front is the safe order for several. foreach ([2, 1] as $index) Api::Products()->DeleteSmsReason(['index' => $index], ['lang' => 'en']); ``` #### Reading the Settings get/api/v1/admin/products/sms/intl-settings `Products/GetSmsIntlSettings` admin Returns the module, pricing and pre-registration settings for international SMS. Response fields data — 5 active_modulestringThe SMS module in use. `none` when none is chosen. cron_statusboolWhether the job that refreshes prices is on. primary_currencyintCurrency id the prices are kept in. profit_ratefloatThe profit margin added on top of cost. pre_register_countriesstring[]The countries where the sender name has to be approved in advance. 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/sms/intl-settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-settings'); 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()->GetSmsIntlSettings(); ``` #### Writing the Settings put/api/v1/admin/products/sms/intl-settings `Products/SetSmsIntlSettings` admin the margin is its own branch Sets the module, the pricing job and the pre-registration countries, or changes only the margin. Body 5 marginfloatThe profit margin. Sending this field ignores the others and reprices. active_modulestringThe SMS module to use. cron_statusboolTurns on the job that refreshes prices. primary_currencyintThe currency the prices are kept in. countries_pre_registerstring[]The countries where the sender name needs approval in advance. Response fields data dataobjectThe settings as they now stand. Same shape as reading them. Errors 3 module_unsupported422The job was asked for but the module cannot fetch prices. update_failed422The setting could not be stored. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/sms/intl-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"active_module":"Twilio","cron_status":true,"primary_currency":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ active_module: 'Twilio', cron_status: true, primary_currency: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'active_module' => 'Twilio', 'cron_status' => true, 'primary_currency' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // With 'margin' in the body the OTHER fields are not read - make them two requests. Api::Products()->SetSmsIntlSettings(['active_module' => 'Twilio']); Api::Products()->SetSmsIntlSettings(['margin' => 20]); ``` #### Reading the Prices get/api/v1/admin/products/sms/intl-pricing `Products/GetSmsIntlPricing` admin Returns the cost and the selling price for each country. Query parameters 1 primary_currencyintUses this instead of the currency in the settings. Response fields data[] — 5 country_codestringThe country code. UPPER case here, lower case on the application records. costfloatWhat the provider charges you. amountfloatWhat the client is charged. cidintCurrency id. statusboolWhether sending to that country is open. 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/sms/intl-pricing' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-pricing', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-pricing'); 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()->GetSmsIntlPricing(); ``` #### Writing the Prices put/api/v1/admin/products/sms/intl-pricing `Products/SetSmsIntlPricing` admin Writes the cost and the price per country by hand. Body 1 valuesobjectrequiredA map from country code to a price object carrying the cost, the amount, the currency and whether sending is open. Only the countries you send change. Response fields data — 1 updatedintHow many countries were updated. Errors 2 values_required422`values` was empty. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/products/sms/intl-pricing' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"values":{"US":{"cost":0.01,"amount":0.02,"cid":1,"status":true}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-pricing', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ values: { US: { cost: 0.01, amount: 0.02, cid: 1, status: true }, }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-pricing'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'values' => [ 'US' => ['cost' => 0.01, 'amount' => 0.02, 'cid' => 1, 'status' => true], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The country code goes in UPPER case; do not mix it with the lower-case code on applications. Api::Products()->SetSmsIntlPricing([ 'values' => [ 'US' => ['cost' => 0.01, 'amount' => 0.02, 'cid' => 1, 'status' => true], ], ]); ``` #### Pulling Prices from the Module post/api/v1/admin/products/sms/intl-pricing/auto `Products/AutoDefineSmsIntlPricing` admin overwrites hand-set prices Pulls the country prices from the provider and writes them with the margin applied. Body — ——No body is needed; send an empty one. The module, the currency and the margin all come from the international SMS settings. Response fields data — 1 statusstringThe outcome of the run. Errors 5 no_module422No SMS module is selected. module_unsupported422The module does not support fetching prices. no_primary_currency422The primary currency is not set. auto_define_failed422The prices could not be fetched. 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/sms/intl-pricing/auto' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/intl-pricing/auto', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/intl-pricing/auto'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The endpoint has three preconditions: a module is chosen, it can fetch prices, a currency is set. $settings = Api::Products()->GetSmsIntlSettings()['data']; if ($settings['active_module'] !== 'none' && $settings['primary_currency'] > 0) Api::Products()->AutoDefineSmsIntlPricing(); ``` #### Clearing the Reports post/api/v1/admin/products/sms/clear-reports `Products/ClearSmsReports` admin cannot be undone Deletes the SMS records sent on and before the date you give. Body 2 datestringrequiredThe cut-off date. This date and everything before it goes. typestringWhich channel: `domestic` or `international`. Defaults to `domestic`. Response fields data — 3 clearedboolWhether the clear ran. beforestringThe cut-off date that was used. typestringThe channel that was cleared. Errors 2 invalid_date422The date is missing or invalid. 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/sms/clear-reports' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"date":"2026-01-01","type":"international"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/products/sms/clear-reports', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ date: '2026-01-01', type: 'international' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/products/sms/clear-reports'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'date' => '2026-01-01', 'type' => 'international', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Leave the channel out and the DOMESTIC records go; ask for international explicitly. Api::Products()->ClearSmsReports([ 'date' => '2026-01-01', 'type' => 'international', ]); ``` ### Pitfalls > **The country code case differs between endpoints** > > The application records return the country code in **lower** case while the pricing endpoints expect and return **upper** case. Code that matches the two without normalising the case finds no country at all, and does so quietly. > **The margin field ignores the others** > > With `margin` in the body only the margin branch runs; the module, the currency and the country list are **not read**. To change both, send two separate requests. > **Auto pricing overwrites hand-set prices** > > Pulling prices from the module writes over the country prices you set by hand. If you priced a few countries specially you have to enter them again after this call, and with the pricing job on the same overwrite happens **on a schedule**. > **The bulk decision carries no reason** > > The bulk endpoint only changes the status; there is no field for a refusal message. If the client is meant to see why they were refused, the applications have to be settled one at a time. > **Reason positions shift after a delete** > > Reasons are deleted by their position in the list, not by an id. Deleting one shifts everything after it down by one, so when deleting several go **back to front** or you will remove the wrong text. ### Related Articles - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) - [Domain Pricing and Settings](https://dev.wisecp.com/en/domain-pricing-settings) # API / Admin API / Services ## Service Endpoints https://dev.wisecp.com/en/service-endpoints The four endpoints that list, read, edit and delete client services. ### Overview A service is the thing a client bought and keeps: a hosting account, a domain, a server, a software licence. These four endpoints read, edit and delete the service itself. Responses come back **raw**: statuses and cycles as codes, dates in a standard shape, amounts as numbers without a symbol. Labels for humans come from the `reference` endpoints. The `capabilities` block on the detail says which operations this service accepts. The values are derived from the module, so they are not fixed. ### Reference #### Listing the Services get/api/v1/admin/services `Services/GetServices` admin paged Returns the client services, with filters. Query parameters 9 searchstringSearches the service name, the client, the e-mail and the address. statusstringDuruma göre süzer: `waiting`, `inprocess`, `active`, `suspended`, `expired`, `cancelled` or `completed`. typestringFilters by type: `domain`, `hosting`, `server`, `software`, `sms`, `ssl` or `special`. On special groups the id is appended to the type. client_idintFilters by the owning client. product_idintFilters by product. server_idintFilters by server. Zero gives the services with no server. cyclestringFilters by billing cycle. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 16 idintService id. namestringThe service name. typestringThe service type. type_idintThe sub-type id on special groups. product_idintId of the product behind it. domainstring | nullThe domain tied to the service. statusstringThe service status. amountfloatThe per-period amount. A raw number; formatting is yours. currency_idintCurrency id of the amount. cyclestringThe billing cycle. qtyintThe quantity. modulestring | nullThe module running the service. clientobject 4 fieldsWho owns the service. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. created_atdatetimeWhen the service was opened. due_atdatetimeWhen the term ends. renewal_atdatetimeThe renewal date. Pagination 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/services' \ -H "Authorization: Bearer $API_KEY" \ -d status=active \ -d type=hosting ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/services'); url.searchParams.set('status', 'active'); url.searchParams.set('type', 'hosting'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/services?' . http_build_query(['status' => 'active', '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); ``` ```php // The pagination fields sit at the ROOT, not under 'meta'. $page = 1; $all = []; do { $response = Api::Services()->GetServices([], [ 'status' => 'active', 'page' => $page, 'limit' => 100, ]); $all = array_merge($all, $response['data']); $page = $response['next_page']; } while ($page > 0); ``` Response 200 ```json { "data": [ { "id": 510, "name": "Mail Hosting", "type": "hosting", "type_id": 0, "product_id": 15, "domain": "example.com", "status": "active", "amount": 10.0, "currency_id": 1, "cycle": "monthly", "qty": 1, "module": "Mailcow", "client": { "id": 50, "full_name": "John Doe", "company_name": "", "email": "john@example.com" }, "created_at": "2026-06-18 12:00:00", "due_at": "2026-07-18 12:00:00", "renewal_at": "2026-07-18 12:00:00" } ], "total": 42, "page": 1, "limit": 25, "next_page": 2 } ``` #### Service Detail get/api/v1/admin/services/{id} `Services/GetService` admin capabilities included Returns a service in full, with its relations and what can be done to it. Response fields data — 26 idintService id. namestringThe service name. typestringThe service type. type_idintThe sub-type id on special groups. product_idintId of the product behind it. order_idintId of the order that produced it. Zero when there is none. invoice_idintId of the first invoice. statusstringThe service status. amountfloatThe per-period amount. total_amountfloatThe amount multiplied by the quantity. currency_idintCurrency id. qtyintThe quantity. periodstringThe period unit. period_timeintThe period multiplier. cyclestringThe billing cycle. is_overdueboolWhether it is overdue. created_atdatetimeWhen the service was opened. due_atdatetimeWhen the term ends. renewal_atdatetimeThe renewal date. modulestring | nullThe module running the service. clientobject 4 fieldsWho owns the service. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. productobjectA summary of the product: id, title, type and module. serverobject | nullThe server it runs on: id, name, address, username and status. orderobject | nullA summary of the order: id, number and status. optionsobjectThe service's raw settings. Password fields are stripped and the rest depends on the module. capabilitiesobject 6 fieldsWhich operations this service accepts. has_moduleboolWhether a module is attached. can_suspendboolWhether it can be suspended. can_unsuspendboolWhether it can be unsuspended. can_cancelboolWhether it can be cancelled. can_reinstallboolWhether it can be reinstalled. can_change_passwordboolWhether its password can be changed. Errors 2 not_found404No such service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/506' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); // Read the capability before attempting the operation. if (body.data.capabilities.can_reinstall) { // ... } ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $service = Api::Services()->GetService(['id' => 506])['data']; // Capabilities come from the module: most are off on a service without one. if ($service['capabilities']['can_suspend'] ?? false) { Api::Services()->SuspendService(['id' => 506]); } ``` #### Updating a Service patch/api/v1/admin/services/{id} `Services/UpdateService` admin written to history Changes a service's data fields. Status transitions do not happen here. Body 23 namestringThe service name. notesstringAn admin note. Never shown to the client. payment_methodstringThe payment method. Sending it empty removes the method. modulestringThe module running the service. Changeable on domain services only. client_idintMoves the service to another client. product_idintChanges the product behind it. product_groupstringResolves the type when changing product. Sent together with the product id. subscription_identifierstringThe subscription identifier. Sending it empty breaks the link. created_atdatetimeThe opening date. renewal_atdatetimeThe renewal date. due_atdatetimeThe term end date. Sending it empty leaves the service without one. suspend_datedateA scheduled suspension date. Sending it empty cancels the plan. cancel_datedateA scheduled cancellation date. process_exemption_datedateThe date until which automatic operations skip it. amountfloatThe per-period amount. As a plain number, not in the display format. currency_idintCurrency of the amount. cyclestringThe billing cycle. Ignored on domain services. qtyintThe quantity. The total is recalculated. auto_payboolCharges automatically on renewal. block_accessboolCuts the client's access to the service. skip_renewal_invoiceboolStops the renewal invoice being produced. billing_profile_idintThe profile the invoice is issued under. Zero goes back to the default. discountobject 6 fieldsA discount applied to this service's renewal invoices. Sending an empty object removes it. typestring`percent` or `amount`. valuefloatThe percentage or the amount. Above zero, and a percentage stays below a hundred. cidintCurrency of a fixed amount. Ignored on a percentage. ends_atstringThe last day the discount applies. Left empty it never expires. cycles_limitintHow many renewals it covers. Zero means no limit. notestringAn internal note on why the discount exists. Response fields data — 26 dataobjectThe service as it now stands. Same shape as the detail endpoint. Errors 5 not_found404No such service. owner_not_found422The target client was not found. invalid_date422A date could not be read. discount_invalid422The discount was refused. The value is out of range, the end date has passed, or the service belongs to a subscription. 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/services/506' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Mail Hosting Pro","amount":29.9,"qty":2,"auto_pay":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Mail Hosting Pro', amount: 29.9, qty: 2, auto_pay: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Mail Hosting Pro', 'amount' => 29.9, 'qty' => 2, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An update that moves the term end also shifts add-ons falling on the same day. $response = Api::Services()->UpdateService([ 'id' => 506, 'due_at' => '2026-08-18 12:00:00', ]); ``` #### Deleting a Service delete/api/v1/admin/services/{id} `Services/DeleteService` admin cannot be undone Deletes the service record, and if you ask, closes the account at the provider too. Body 1 apply_on_moduleboolAlso cancels the account at the provider. Off by default: the record goes and the account stays on the server. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted service. Errors 2 not_found404No such service. 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/services/510' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"apply_on_module":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/510', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ apply_on_module: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/510'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['apply_on_module' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Without the flag the account on the server STAYS UP and keeps consuming resources. $response = Api::Services()->DeleteService([ 'id' => 510, 'apply_on_module' => true, ]); ``` ### Pitfalls > **Deleting does not close the account on the server** > > By default the delete removes only the **record**; the account at the provider stays up and keeps consuming resources. Closing it too means putting the flag in the request. Once the record is gone there is no way left to do it through the API. > **Status changes are not on this endpoint** > > The update endpoint takes no `status` field: suspending, cancelling and reactivating live on their own endpoints. That is because those transitions make the module do work, and a plain field write would change nothing on the server. > **Pagination sits at the root, not under meta** > > The service list returns `total`, `page`, `limit` and `next_page` at the **root**. Many other lists put them under `meta`, so a shared pagination helper has to account for it. > **The term end date shifts add-ons too** > > Changing the term end also moves the end date of add-ons falling on the same day. That is usually what you want, but it is silent: you think you changed one date while other billable items moved with it. ### Related Articles - [Service Lifecycle](https://dev.wisecp.com/en/service-lifecycle) - [Service Settings and Server](https://dev.wisecp.com/en/service-settings-and-server) - [Add-ons on a Service](https://dev.wisecp.com/en/add-ons-on-a-service) ## Service Lifecycle https://dev.wisecp.com/en/service-lifecycle The five endpoints that suspend, unsuspend, cancel, reinstall and repassword a service. ### Overview These five endpoints change the **state** of a service. They are unlike editing data fields. Each one makes the provider do work, so the record and the account move together. Leave `apply_on_module` out and the sensible thing is assumed. On a service with a module the operation reaches the provider; on one without, only the record changes. Reinstalling and changing the password **do not run without a module**, because all the work they do is at the provider. ### Reference #### Suspending a Service post/api/v1/admin/services/{id}/suspend `Services/SuspendService` admin reaches the module Suspends the service and stops the account at the provider too. Body 3 reasonstringWhy it was suspended. It goes on the record and reaches the client if a notification is sent. notifyboolSends the client a notification. apply_on_moduleboolApplies the operation at the provider too. Left out, it is on for a service with a module and off for one without. Response fields data — 3 statusstringThe status afterwards. idintService id. applied_on_moduleboolWhether it reached the provider. False means the WISECP record changed while the account on the server did not. Errors 2 not_found404No such service. 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/services/506/suspend' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"reason":"Awaiting payment","notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/suspend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ reason: 'Awaiting payment', notify: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/suspend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'reason' => 'Awaiting payment', 'notify' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Services()->SuspendService([ 'id' => 506, 'reason' => 'Awaiting payment', 'notify' => true, ]); // If the status changed but it never reached the module, the account KEEPS running. if (!($response['data']['applied_on_module'] ?? false)) { // needs a hand } ``` Response 200 ```json { "data": { "status": "suspended", "id": 506, "applied_on_module": true } } ``` #### Unsuspending a Service post/api/v1/admin/services/{id}/unsuspend `Services/UnsuspendService` admin reaches the module Puts a suspended service back to work and reopens the account at the provider. Body 2 notifyboolSends the client a notification. apply_on_moduleboolApplies the operation at the provider too. Left out, it is on for a service with a module and off for one without. Response fields data — 3 statusstringThe status afterwards. idintService id. applied_on_moduleboolWhether it reached the provider. False means the WISECP record changed while the account on the server did not. Errors 2 not_found404No such service. 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/services/506/unsuspend' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/unsuspend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notify: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/unsuspend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['notify' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Services()->UnsuspendService(['id' => 506, 'notify' => true]); ``` #### Cancelling a Service post/api/v1/admin/services/{id}/cancel `Services/CancelService` admin cannot be undone Cancels the service and closes the account at the provider. Body 3 reasonstringWhy it was cancelled. notifyboolSends the client a notification. apply_on_moduleboolApplies the operation at the provider too. Left out, it is on for a service with a module and off for one without. Response fields data — 3 statusstringThe status afterwards. idintService id. applied_on_moduleboolWhether it reached the provider. False means the WISECP record changed while the account on the server did not. Errors 2 not_found404No such service. 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/services/506/cancel' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"reason":"Customer request","notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/cancel', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ reason: 'Customer request', notify: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/cancel'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'reason' => 'Customer request', 'notify' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The panel asks for the admin password here; on the API the key's scope is enough. $response = Api::Services()->CancelService([ 'id' => 506, 'reason' => 'Customer request', ]); ``` #### Reinstalling a Service post/api/v1/admin/services/{id}/reinstall `Services/ReinstallService` admin the data goes Deletes the account at the provider and builds it again. It does not run without a module. Body — ——No body is needed, so send an empty one. The module rebuilds the account from the stored service record; nothing can be overridden from the call. Response fields data — 2 statusstringThe outcome. idintService id. Errors 4 not_found404No such service. no_module422The service has no module attached. module_failed500The module could not complete the operation. 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/services/506/reinstall' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/reinstall', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/reinstall'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read the capability first: without a module this endpoint answers 'no_module'. $service = Api::Services()->GetService(['id' => 506])['data']; if ($service['capabilities']['can_reinstall'] ?? false) { Api::Services()->ReinstallService(['id' => 506]); } ``` Response 200 422 ```json { "data": { "status": "reinstalled", "id": 506 } } ``` ```json { "error": { "code": "no_module", "message": "Service has no module to reinstall." } } ``` #### Changing the Service Password put/api/v1/admin/services/{id}/password `Services/SetServicePassword` admin needs a module Changes the password at the provider and stores the new one encrypted on the service. Body 2 passwordstringrequiredThe new password. notifyboolSends the new sign-in details to the client. The service activation template is used. Response fields data — 2 statusstringThe outcome. idintService id. Errors 5 not_found404No such service. no_module422The service has no module attached. password_required422The password was empty. module_failed500The module could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/506/password' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"password":"S3cretP@ss!","notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/password', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password: newPassword, notify: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/password'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'password' => $newPassword, 'notify' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The password changes at the PROVIDER first, then on the record; if the module fails, neither moves. $response = Api::Services()->SetServicePassword([ 'id' => 506, 'password' => $newPassword, 'notify' => true, ]); ``` ### Pitfalls > **The status can change without reaching the server** > > Suspend, unsuspend and cancel can return `200` without ever reaching the provider; `applied_on_module` on the response tells you. When it is false the WISECP record changed while the account **keeps running** on the server. A client reading only the status code will not see it. > **Reinstalling wipes the account** > > This endpoint does not repair an account: it **deletes** the one at the provider and builds a fresh one. Everything inside goes. Running it as a troubleshooting step is the quickest way to lose the data a client keeps there. Make sure there is a backup first. > **The password changes at the provider first** > > The new password is written to the provider first and only then stored encrypted on the service. If the module fails neither changes and you get `module_failed`. The stored password and the one on the server are not expected to drift apart. Even so, keep your own copy of the new password: it cannot be read back from the record. > **The panel asks for a password, the API for a scope** > > Cancelling asks for the admin password in the panel. The API has no such second step: the key's scope is enough. Hand out a key carrying this scope knowing exactly who holds it. ### Related Articles - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) - [Renewal and Cancellation](https://dev.wisecp.com/en/renewal-cancellation) - [Service Settings and Server](https://dev.wisecp.com/en/service-settings-and-server) ## Service Settings and Server https://dev.wisecp.com/en/service-settings-and-server The four endpoints behind a service's access details, limits, server and information blocks. ### Overview These four endpoints touch a service's **settings**. The update on the service endpoints handles billing and dates; these decide how the service is reached, which server it sits on, and what the client reads on their page. The management form behaves differently per service type: access details and limits are handled on hosting, server and special products, while licence fields apply to software services only. A field sent to the wrong type is ignored without a word. ### Reference #### Saving the Management Form patch/api/v1/admin/services/{id}/management `Services/UpdateServiceManagement` admin depends on the type Saves a service's access details, resource limits and licence fields. Body 8 accessobject 5 fieldsHow the service is reached. Only the fields you send change; the rest stay as they are. domainstringThe domain tied to the service. hostnamestringThe host name. ipstringThe service address. usernamestringThe sign-in username. passwordstringThe sign-in password. Stored encrypted and never read back. override_product_limitsboolOverrides the product's limits for this service. Limits you send without turning this on are not applied. limitsobjectThe resource limits. The fields follow the service type: disk and bandwidth on hosting, processor, memory, disk and bandwidth on a server. modulestringThe module running the service. On special products only. licenseobject 4 fieldsThe licence fields. Handled on software services only. domainstringThe domain the licence is locked to. ipstringThe address the licence is locked to. codestringThe licence key. license_parametersobjectExtra fields the licence carries. creation_infoobjectThe module's creation info. Merged into what is already there. configobjectThe module settings. Merged into what is already there. configurationobjectThe module configuration. Merged into what is already there. Response fields data dataobjectThe service as it now stands. Same shape as the detail endpoint in the service endpoints article. ——The password never comes back in the answer. Read `options` to confirm the access details that are not secret. Errors 2 not_found404No such service. 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/services/506/management' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"access":{"username":"john"},"override_product_limits":true,"limits":{"disk_limit":50}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/management', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ access: { username: 'john' }, override_product_limits: true, limits: { disk_limit: 50 }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/management'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'access' => ['username' => 'john'], 'override_product_limits' => true, 'limits' => ['disk_limit' => 50], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Sending limits is not enough: turn the override on first, or the product's limits still rule. $response = Api::Services()->UpdateServiceManagement([ 'id' => 506, 'override_product_limits' => true, 'limits' => ['disk_limit' => 50, 'bandwidth_limit' => 500], ]); ``` #### Moving to Another Server put/api/v1/admin/services/{id}/server `Services/SetServiceServer` admin no data is moved Points the service record at another server and updates the module to match. Body 1 server_idintrequiredId of the target server. Sending zero leaves the service without a server and drops the module. Response fields data — 3 server_idintId of the new server. modulestringThe module the target server brings. `none` when left without a server. idintService id. Errors 4 not_found404No such service. same_server422The service is already on that server. server_not_found422The target server does not exist. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/506/server' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"server_id":5}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/server', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ server_id: 5 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/server'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['server_id' => 5]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint moves the RECORD only; building the account on the new server is separate work. $response = Api::Services()->SetServiceServer([ 'id' => 506, 'server_id' => 5, ]); $module = $response['data']['module']; ``` Response 200 422 ```json { "data": { "server_id": 5, "module": "Mailcow", "id": 506 } } ``` ```json { "error": { "code": "same_server", "message": "Service is already on this server." } } ``` #### Writing the Information Blocks put/api/v1/admin/services/{id}/blocks `Services/SetServiceBlocks` admin the list is written whole Writes the free-text blocks the client sees on the service page. Body 1 blocksarrayrequiredThe list of blocks. Each carries a title and a description. A block empty in both is skipped, and a second name is accepted for the description field. Response fields data — 2 blocksarrayThe blocks that were stored. idintService id. Errors 3 not_found404No such service. invalid_blocks422`blocks` is not an array. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/506/blocks' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"blocks":[{"title":"Connection","description":"Connect over SSH on port 22."}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/blocks', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ blocks: [ { title: 'Connection', description: 'Connect over SSH on port 22.' }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/blocks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'blocks' => [ ['title' => 'Connection', 'description' => 'Connect over SSH on port 22.'], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD a block send the existing list too: what you send becomes the truth. $service = Api::Services()->GetService(['id' => 506])['data']; $blocks = $service['options']['blocks'] ?? []; $blocks[] = ['title' => 'Backups', 'description' => 'Nightly, kept for 7 days.']; Api::Services()->SetServiceBlocks(['id' => 506, 'blocks' => $blocks]); ``` #### Clearing the Status Message post/api/v1/admin/services/{id}/clear-status-message `Services/ClearServiceStatusMessage` admin Removes the sticky status message the module left behind. Body — ——No body is needed. The service comes from the path and the whole message always goes; send an empty body. Response fields data — 2 clearedboolWhether it was cleared. idintService id. Errors 2 not_found404No such service. 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/services/506/clear-status-message' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/clear-status-message', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/clear-status-message'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Clearing the message does not fix its cause: the module can write the same error again. $response = Api::Services()->ClearServiceStatusMessage(['id' => 506]); ``` ### Pitfalls > **Changing the server does not move the data** > > This endpoint only points the **record** at the new server and swaps the module to match. The account stays exactly where it was on the old server and is never created on the new one. The real migration is separate work; this call is for lining the record up afterwards. > **Limits do nothing until the flag is on** > > Sending resource limits is not enough on its own: with `override_product_limits` off the product's limits still rule and your values are not applied. No error is raised either, so read the service detail back to confirm. > **The block list is written whole** > > What you send becomes the truth: blocks missing from the list are deleted. To add one, read the current list first, append to it and send all of it back. A block empty in both title and description is skipped without a word. > **Clearing the message does not fix its cause** > > The status message is a trace the module left. Clearing it removes the text only; if the same error persists the module writes it again on its next operation. Look at why it was written before hiding it. ### Related Articles - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) - [Service Lifecycle](https://dev.wisecp.com/en/service-lifecycle) - [Provisioning Servers](https://dev.wisecp.com/en/provisioning-servers) ## Service Requirement Answers https://dev.wisecp.com/en/service-requirement-answers The four endpoints that read, add, edit and delete a client's requirement answers on a service. ### Overview A requirement is the question a product asks at order time; what is managed here are the **answers on a service**. The questions themselves are defined on the product side. An answer comes from one of two sources. Ones **from a definition** are tied to a product requirement and carry its type, its options and its module mapping. **Free-form** ones carry nothing but a label and some content, added by hand later, and they never reach the module. ### Reference #### Listing the Answers get/api/v1/admin/services/{id}/requirements `Services/GetServiceRequirements` admin Returns every requirement answer recorded on the service. Response fields data[] — 8 idintId of the answer record. Update and delete use this one. requirement_idintId of the product requirement definition behind it. Zero on a free-form one. keystring`product` comes from a definition, `custom` was added freely. namestringThe requirement name or label. typestringThe field type: `text`, `select`, `radio`, `checkbox` or `file`. responsestring | array 4 fieldsThe answer given. An array of file objects on the file type, plain text on the rest. namestringThe name stored on the server. file_namestringThe original name that was uploaded. sizeintThe file size in bytes. pathstringWhere the file is kept. response_mkeystringWhat the choice maps to in the module. Filled on the choice types only, and resolved for you. field_optionsarrayThe options the definition offers. Errors 2 not_found404No such service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/506/requirements' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/requirements', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/requirements'); 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::Services()->GetServiceRequirements(['id' => 506]); // 'response' changes shape by type: an array on file, text on the rest. foreach ($response['data'] as $req) { $answer = $req['type'] === 'file' ? array_column($req['response'], 'file_name') : $req['response']; } ``` Response 200 ```json { "data": [ { "id": 12, "requirement_id": 22, "key": "product", "name": "Game Name", "type": "text", "response": "Minecraft", "response_mkey": "", "field_options": [] } ] } ``` #### Adding an Answer post/api/v1/admin/services/{id}/requirements `Services/CreateServiceRequirement` admin two body shapes Adds a requirement answer to the service, either from a definition or free-form. Body — shared 1 sourcestringWhich body shape applies: `defined` ties it to a definition, `custom` adds it freely. Defaults to `defined`. Body — from a definition 3 requirement_idintrequiredId of the product requirement definition to tie it to. responsestring | arrayThe answer. On the choice types you send the option's **id** from the definition, not its text; for multiple choice a comma-separated list or an array. filestringThe file input. Needed instead when the definition is a file type: a base64 data URI or a link that can be fetched. Body — free-form 4 labelstringrequiredThe requirement label. typestring`text` or `file`. Defaults to `text`; a free-form requirement has no choice types. contentstringThe text answer. Needed on the text type. filestringThe file input. Needed on the file type. Response fields data dataobjectThe answer that was created, returned with `201`. Same shape as one row of the list schema. Errors 8 not_found404No such service. requirement_id_required422No definition id was given when tying to one. definition_not_found422The requirement definition was not found. response_required422The answer was empty. content_required422The text was empty. label_required422The label was empty. file_invalid422The file could not be read or 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/services/506/requirements' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"source":"custom","label":"Server Name","type":"text","content":"srv-01"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/requirements', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ source: 'custom', label: 'Server Name', type: 'text', content: 'srv-01', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/requirements'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'source' => 'custom', 'label' => 'Server Name', 'type' => 'text', 'content' => 'srv-01', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On a choice type you send the OPTION'S ID; sending its text records the wrong thing. $definition = Api::Products()->GetRequirement(['id' => 22])['data']; $option = $definition['langs']['en']['options'][0]; Api::Services()->CreateServiceRequirement([ 'id' => 506, 'source' => 'defined', 'requirement_id' => 22, 'response' => $option['id'], ]); ``` #### Updating an Answer patch/api/v1/admin/services/{id}/requirements/{req_id} `Services/UpdateServiceRequirement` admin files cannot be edited Changes a text-based answer. File answers cannot be edited. Body 1 responsestringThe new answer. Comma-separated option ids on multiple choice. Response fields data dataobjectThe answer in its updated state. Same shape as one row of the list schema; on the choice types `response_mkey` is resolved again. Errors 3 not_found404No such service or requirement. file_not_editable422A file requirement cannot be edited. Delete it and add it again. 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/services/506/requirements/12' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"response":"Valheim"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/requirements/12', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ response: 'Valheim' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/requirements/12'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['response' => 'Valheim']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The way to change a file answer is delete-then-add. Api::Services()->DeleteServiceRequirement(['id' => 506, 'req_id' => 12]); Api::Services()->CreateServiceRequirement([ 'id' => 506, 'source' => 'custom', 'label' => 'Contract', 'type' => 'file', 'file' => 'data:application/pdf;base64,' . base64_encode($pdf), ]); ``` #### Deleting an Answer delete/api/v1/admin/services/{id}/requirements/{req_id} `Services/DeleteServiceRequirement` admin the file goes too Deletes the answer. On a file type the uploaded file is removed from disk as well. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted record. Errors 2 not_found404No such service or requirement. 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/services/506/requirements/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/requirements/12', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/requirements/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::Services()->DeleteServiceRequirement([ 'id' => 506, 'req_id' => 12, ]); ``` ### Pitfalls > **Choice types take the id, not the text** > > On the dropdown, single-choice and multiple-choice types the answer is the option's **id** from the definition. Sending the option's visible text raises no error but records the wrong value and leaves the module mapping unresolved. Read the ids from the product requirement definition. > **A file answer cannot be edited** > > The update endpoint answers `file_not_editable` on a file type. The only way to change one is to delete the record and add a new one; the delete also removes the uploaded file from disk, so nothing stale is left behind. > **The answer changes shape with the type** > > In the list the `response` field is an **array** on the file type and **plain text** on the rest. Code expecting one shape breaks on the first service with a file requirement, so check `type` before reading it. > **A free-form requirement never reaches the module** > > A free-form requirement only sits on the record: with no definition behind it there is no module mapping either. If the answer has to do something on the server, define the requirement on the product side and add it here from that definition. ### Related Articles - [Product Requirements](https://dev.wisecp.com/en/product-requirements) - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) ## Ownership Transfers https://dev.wisecp.com/en/ownership-transfers The four endpoints behind the requests that move a service to another client. ### Overview A client can hand a service over to another client. These four endpoints read those requests and settle them; approval moves the service to the target client. The id in the paths is the **request's**, not the service's. Requests are kept as event records, so they carry a number of their own. This is **not a licence transfer**. Moving a licence from one installation to another is a separate system with its own endpoints. ### Reference #### Listing the Requests get/api/v1/admin/services/transfer-requests `Services/GetTransferRequests` admin paged Returns the service handover requests, with the waiting ones first. Query parameters 7 searchstringSearches the name, e-mail, company and service name on either side. statusstring`pending` ya da `approved`. from_idintFilters by the client handing over. to_idintFilters by the client taking on. service_idintFilters by service. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 8 idintId of the request. This is an event id, **not** a service id, and it is what the other endpoints expect. service_idintId of the service being handed over. statusstring`pending` is waiting, `approved` has gone through. reasonstringWhy the transfer was asked for. created_atstringWhen the request was opened. serviceobject 3 fieldsA summary of the service. idintService id. namestringThe service name. typestringThe service type. fromobject 4 fieldsThe client handing it over. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. toobject 4 fieldsThe client taking it on. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. 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/services/transfer-requests' \ -H "Authorization: Bearer $API_KEY" \ -d status=pending ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/services/transfer-requests'); url.searchParams.set('status', 'pending'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/services/transfer-requests?' . http_build_query(['status' => 'pending']); $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::Services()->GetTransferRequests([], ['status' => 'pending']); ``` Response 200 ```json { "data": [ { "id": 7382, "service_id": 558, "status": "pending", "reason": "Sold to another account", "created_at": "2026-06-21 18:00:00", "service": { "id": 558, "name": "example.com", "type": "hosting" }, "from": { "id": 88, "full_name": "John Doe", "company_name": "", "email": "john@example.com" }, "to": { "id": 89, "full_name": "Jane Doe", "company_name": "", "email": "jane@example.com" } } ], "meta": { "total": 1, "page": 1, "limit": 25, "next_page": 0 } } ``` #### Request Detail get/api/v1/admin/services/transfer-requests/{eid} `Services/GetTransferRequest` admin request id Returns one handover request, with the approval details when it has gone through. Response fields data — 9 idintId of the request. This is an event id, **not** a service id, and it is what the other endpoints expect. service_idintId of the service being handed over. statusstring`pending` is waiting, `approved` has gone through. reasonstringWhy the transfer was asked for. created_atstringWhen the request was opened. serviceobject 3 fieldsA summary of the service. idintService id. namestringThe service name. typestringThe service type. fromobject 4 fieldsThe client handing it over. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. toobject 4 fieldsThe client taking it on. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. approvedobject | nullThe approval: who approved it, their name and when. Empty while the request is still waiting. Errors 2 not_found404No such transfer request. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/transfer-requests/7382' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/transfer-requests/7382', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/transfer-requests/7382'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The id in the path is the REQUEST'S id; passing a service id answers 404. $response = Api::Services()->GetTransferRequest(['eid' => 7382]); ``` #### Approving a Request post/api/v1/admin/services/transfer-requests/{eid}/approve `Services/ApproveTransferRequest` admin cannot be undone Moves the service to the target client and marks the request approved. Body — ——No body is needed. The target client is the one named in the stored request and cannot be changed at approval time; send an empty body. Response fields data — 5 statusstringThe status afterwards. idintId of the request. service_idintId of the service handed over. old_owner_idintId of the previous owner. new_owner_idintId of the new owner. Errors 6 not_found404No such request or service. not_pending422The request has already been approved. invalid_target422The request carries no target client. target_not_found422The target client was not found. blocked_by_gate422The `gate:service.transfer_approve` hook vetoed the operation. One of your own addons may be blocking the handover. 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/services/transfer-requests/7382/approve' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/transfer-requests/7382/approve', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/transfer-requests/7382/approve'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Approval is one way: undoing it means opening a NEW request in the other direction. $response = Api::Services()->ApproveTransferRequest(['eid' => 7382]); $movedFrom = $response['data']['old_owner_id']; $movedTo = $response['data']['new_owner_id']; ``` Response 200 422 ```json { "data": { "status": "approved", "id": 7382, "service_id": 558, "old_owner_id": 88, "new_owner_id": 89 } } ``` ```json { "error": { "code": "not_pending", "message": "Transfer request is not pending." } } ``` #### Deleting a Request delete/api/v1/admin/services/transfer-requests/{eid} `Services/DeleteTransferRequest` admin Deletes the request record. It does not touch who owns the service. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted request. Errors 2 not_found404No such transfer request. 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/services/transfer-requests/7382' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/transfer-requests/7382', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/transfer-requests/7382'); 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 // Deleting an approved request does NOT undo the handover; only the record goes. $response = Api::Services()->DeleteTransferRequest(['eid' => 7382]); ``` ### Pitfalls > **The id in the path is not the service's** > > These endpoints expect the **request id**; passing a service id answers `404`. Both are numbers, so the mistake never lands on the wrong record quietly, but it is easy to make: in the list `id` is the request's and `service_id` is the service's. > **Approval cannot be undone** > > Approving an already approved request answers `not_pending`, and deleting it does **not** bring the ownership back; only the record goes. The only way to reverse a handover is a new request in the other direction. > **A hook can block the handover** > > A `blocked_by_gate` error does not mean your request was wrong; it means an addon in the installation refused the handover. That is how non-transferable service types are defined, so if you wrote your own hook, look there first. > **Do not confuse it with a licence transfer** > > The handover here changes **who owns** the service: the service stays as it is and its invoices start going to another client. Moving a licence from one server to another is entirely different work living on its own endpoints. ### Related Articles - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) - [Licence Transfers](https://dev.wisecp.com/en/licence-transfers) ## Renewal and Cancellation https://dev.wisecp.com/en/renewal-cancellation The nine endpoints that run the end of a term: renewal invoices, cancellation requests, refunds and subscriptions. ### Overview When a term ends there are two ways it can go: renewed, or finished. These nine endpoints run both. They produce the renewal invoice, settle the client's cancellation request, close the service with a refund, and stop the payment subscription. Four separate things are easy to conflate. The **renewal invoice** asks for money. The **cancellation request** is what the client wants. **Cancel and refund** closes the service and gives money back. **Cancelling the subscription** only stops the automatic charging. None of them does another on its own. ### Reference #### A Service Renewal Invoice post/api/v1/admin/services/{id}/renewal-invoice `Services/GenerateServiceRenewalInvoice` admin 201 Produces a renewal invoice for the service, running by hand the same path the cron uses. Body — ——No body is needed; send an empty one. The renewal settings — the source, the add-on gathering, the metrics, the notification and the hook — are fixed on the server and cannot be set from here. Response fields data — 2 invoice_idintId of the invoice produced. service_idintService id. Errors 3 not_found404No such service. renewal_skipped422No invoice was produced. The term may already be invoiced, renewal invoicing may be switched off on the service, the term may be invalid, or client data may be missing; the message says which. 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/services/529/renewal-invoice' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/renewal-invoice', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/renewal-invoice'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A skip is a DECISION, not a failure; the message says why. $response = Api::Services()->GenerateServiceRenewalInvoice(['id' => 529]); if (($response['error']['code'] ?? '') === 'renewal_skipped') { $why = $response['error']['message']; } ``` Response 201 422 ```json { "data": { "invoice_id": 1234, "service_id": 529 } } ``` ```json { "error": { "code": "renewal_skipped", "message": "Renewal skipped: this period is already invoiced." } } ``` #### An Add-on Renewal Invoice post/api/v1/admin/services/{id}/addons/{addon_id}/renewal-invoice `Services/GenerateAddonRenewalInvoice` admin 201 Produces a renewal invoice for one add-on on its own. Body — ——No body is needed; send an empty one. Response fields data — 2 invoice_idintId of the invoice produced. addon_idintId of the add-on record. Errors 3 not_found404No such service or add-on. renewal_skipped422No invoice was produced. 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/services/529/addons/44/renewal-invoice' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44/renewal-invoice', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44/renewal-invoice'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A service renewal already gathers the add-ons; this endpoint is for renewing one ON ITS OWN. $response = Api::Services()->GenerateAddonRenewalInvoice([ 'id' => 529, 'addon_id' => 44, ]); ``` #### Cancel and Refund post/api/v1/admin/services/{id}/cancel-refund `Services/CancelAndRefundService` admin cannot be undone Cancels the service and, if you ask, refunds the part of the term that was not used. Body 2 refundstringHow to refund: `none` not at all, `credit` to the client's balance, `cash` as an expense record. Defaults to `none`. apply_on_moduleboolCancels the account at the provider too. Response fields data — 5 statusstringThe status afterwards. idintService id. refundstringThe refund you asked for. refundedboolWhether a refund actually happened. Asking is not enough: with no balance left this comes back false. applied_on_moduleboolWhether it reached the provider. Errors 3 not_found404No such service. already_cancelled422The service is already cancelled. 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/services/529/cancel-refund' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"refund":"credit","apply_on_module":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/cancel-refund', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ refund: 'credit', apply_on_module: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/cancel-refund'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'refund' => 'credit', 'apply_on_module' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The refund you asked for may NOT have happened: 'refunded' decides, not 'refund'. $response = Api::Services()->CancelAndRefundService([ 'id' => 529, 'refund' => 'credit', ]); $paid = $response['data']['refunded'] ?? false; ``` #### Listing the Cancellation Requests get/api/v1/admin/services/cancellation-requests `Services/GetCancellationRequests` admin paged Returns the cancellation requests clients opened, with the waiting ones first. Query parameters 4 searchstringSearches the client name, e-mail, company and service name. statusstring`pending` or `approved`. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 9 idintId of the request. An event id, **not** a service id. service_idintId of the service being cancelled. user_idintId of the client who opened it. statusstring`pending` is waiting, `approved` has gone through. urgencystring`now` means straight away, `period_ending` at the end of the term. This decides what approval does. reasonstringThe reason the client gave. created_atstringWhen the request was opened. serviceobject 3 fieldsA summary of the service. idintService id. namestringThe service name. typestringThe service type. clientobject 4 fieldsThe client who opened it. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. Only on the detail. 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/services/cancellation-requests' \ -H "Authorization: Bearer $API_KEY" \ -d status=pending ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/services/cancellation-requests'); url.searchParams.set('status', 'pending'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/services/cancellation-requests?' . http_build_query(['status' => 'pending']); $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::Services()->GetCancellationRequests([], ['status' => 'pending']); ``` #### Cancellation Request Detail get/api/v1/admin/services/cancellation-requests/{eid} `Services/GetCancellationRequest` admin the remainder is worked out Returns one request together with the used and remaining part of the term. Response fields data — 11 idintId of the request. An event id, **not** a service id. service_idintId of the service being cancelled. user_idintId of the client who opened it. statusstring`pending` is waiting, `approved` has gone through. urgencystring`now` means straight away, `period_ending` at the end of the term. This decides what approval does. reasonstringThe reason the client gave. created_atstringWhen the request was opened. serviceobject 3 fieldsA summary of the service. idintService id. namestringThe service name. typestringThe service type. clientobject 4 fieldsThe client who opened it. idintClient id. full_namestringFirst and last name. company_namestringCompany name. emailstringE-mail address. Only on the detail. remainingobject 3 fieldsThe used and remaining part of the term. used_daysintHow many days of the term were used. remaining_daysintHow many days of the term are left. remaining_amountfloatThe amount those days are worth. This is the number to read before deciding on a refund. approvedobject | nullThe approval: who approved it, their name and when. Empty while the request is still waiting. Errors 2 not_found404No such cancellation request. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/cancellation-requests/91' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/cancellation-requests/91', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/cancellation-requests/91'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read the remaining amount HERE before deciding on a refund; the cancel endpoint never asks. $request = Api::Services()->GetCancellationRequest(['eid' => 91])['data']; $owed = $request['remaining']['remaining_amount']; ``` #### Accepting a Request post/api/v1/admin/services/cancellation-requests/{eid}/accept `Services/AcceptCancellationRequest` admin follows the urgency Approves the request, and cancels the service there and then when the urgency says so. Body — ——No body is needed; send an empty one. The urgency and the reason come from the request as the client saved it, and approval cannot override either. Response fields data — 4 statusstringThe status afterwards. idintId of the request. service_idintService id. cancelled_nowboolWhether the service was cancelled by this request. False means it was left to the end of the term. Errors 4 not_found404No such request or service. already_approved422The request has already been approved. blocked_by_gate422The `gate:service.cancellation_accept` hook vetoed the operation. 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/services/cancellation-requests/91/accept' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/cancellation-requests/91/accept', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/cancellation-requests/91/accept'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Approving refunds NOTHING. If money has to go back, call the cancel-and-refund endpoint too. $response = Api::Services()->AcceptCancellationRequest(['eid' => 91]); $stoppedNow = $response['data']['cancelled_now'] ?? false; ``` #### Deleting a Request delete/api/v1/admin/services/cancellation-requests/{eid} `Services/DeleteCancellationRequest` admin Deletes the request record. It does not touch the service status. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted request. Errors 2 not_found404No such cancellation request. 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/services/cancellation-requests/91' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/cancellation-requests/91', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/cancellation-requests/91'); 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 // Deleting a request is not a refusal: nothing is said to the client. $response = Api::Services()->DeleteCancellationRequest(['eid' => 91]); ``` #### Cancelling a Service Subscription post/api/v1/admin/services/{id}/cancel-subscription `Services/CancelServiceSubscription` admin at the payment gateway Cancels the recurring payment subscription behind the service at the payment gateway. Body — ——No body is needed; send an empty one. The subscription is found from the service. Response fields data — 3 statusstringThe subscription status afterwards. subscription_idintId of the subscription. service_idintService id. Errors 4 not_found404No such service. subscription_not_found422The service has no subscription. subscription_cancel_failed500The payment gateway could not cancel the subscription. 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/services/529/cancel-subscription' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/cancel-subscription', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/cancel-subscription'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Cancelling the subscription does not cancel the SERVICE: charging stops, the service keeps running. Api::Services()->CancelServiceSubscription(['id' => 529]); Api::Services()->CancelService(['id' => 529]); ``` #### Cancelling an Add-on Subscription post/api/v1/admin/services/{id}/addons/{addon_id}/cancel-subscription `Services/CancelServiceAddonSubscription` admin at the payment gateway Cancels the recurring payment subscription behind an add-on. Body — ——No body is needed; send an empty one. The subscription is found from the service and the add-on record. Response fields data — 3 statusstringThe subscription status afterwards. subscription_idintId of the subscription. addon_idintId of the add-on record. Errors 4 not_found404No such service or add-on. subscription_not_found422The add-on has no subscription. subscription_cancel_failed500The payment gateway could not cancel the subscription. 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/services/529/addons/44/cancel-subscription' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44/cancel-subscription', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44/cancel-subscription'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Services()->CancelServiceAddonSubscription([ 'id' => 529, 'addon_id' => 44, ]); ``` #### Taking a Service Out of an Agreement post/api/v1/admin/services/{id}/remove-subscription `Services/RemoveServiceFromSubscription` the agreement lives on Takes one service out of its agreement while the other members stay. Body — ——No body is needed; send an empty one. The service comes from the path. Response fields data — 4 statusstringWhat the removal came to. subscription_statusstringWhere the agreement stands after the removal. It turns to cancelled once the last member leaves. subscription_idintThe agreement the service left. service_idintThe service taken out. Errors 3 insufficient_scope403The key lacks the required scope. not_found404No such service. subscription_not_found422The service is bound to no agreement. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/admin/services/482/remove-subscription' \ -H "Authorization: Bearer $ADMIN_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/services/${serviceId}/remove-subscription`, { method: 'POST', headers: { Authorization: `Bearer ${adminKey}` }, }); const { data } = await res.json(); if (data.subscription_status === 'cancelled') refreshAgreement(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/' . $serviceId . '/remove-subscription'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $adminKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // TAKING OUT differs from CANCELLING: this call takes only this service and the agreement runs on. // The billing side is re-priced too; to end the whole agreement use cancel-subscription. $r = Api::Services()->RemoveServiceFromSubscription(['id' => $serviceId])['data']; ``` ### Pitfalls > **Cancelling the subscription leaves the service running** > > The subscription endpoints stop the **recurring charge** at the gateway and nothing else. The service keeps running and still gets a renewal invoice when its term ends. That invoice now goes unpaid, because the automatic charge is gone. If the service is meant to end too, call the cancel endpoint as well. > **Accepting does not refund** > > Accepting a cancellation request closes the service but **gives no money back**. To return the unused term you have to call the cancel-and-refund endpoint separately, reading the amount from the calculation on the request detail. > **Asking for a refund is not getting one** > > The `refund` field in the body says what you asked for; `refunded` on the response says what happened. With no balance left no refund is made and the request still answers `200`. Mixing the two ends with the client being told about a refund that never happened. > **A skip is not a failure** > > `renewal_skipped` does not mean your request was malformed. It means no invoice should be raised for that service right now: the term may already be invoiced, renewal invoicing may be off, or data may be missing. The reason is in the message, and retrying will not help. > **The request endpoints want an event id** > > The id on the cancellation request endpoints is the **request's**, not the service's. In the list `id` belongs to the request and `service_id` to the service; passing a service id answers `404`. ### Related Articles - [Service Lifecycle](https://dev.wisecp.com/en/service-lifecycle) - [Add-ons on a Service](https://dev.wisecp.com/en/add-ons-on-a-service) - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) ## Upgrades and Downgrades https://dev.wisecp.com/en/upgrades-downgrades The nine endpoints that move a service to another product, bill the difference and follow the change through. ### Overview When a client changes product two things happen at once: the money is worked out and the server is changed. These nine endpoints run both. They show which products are reachable and what the difference costs, then open a record and follow it to the end. The change is **not immediate**. When the record opens, `flow` on the response says how it proceeds. Waiting for payment holds it until the invoice is settled, scheduling holds it until the term ends, and queueing hands it to a background job. ### Reference #### Reading the Options post/api/v1/admin/services/{id}/upgrade-options `Services/GetUpgradeOptions` admin prices worked out Returns the products the service can move to, each with its price and the difference. Body 1 gradestringThe direction: `up` to upgrade, `down` to downgrade. Defaults to `up`. Response fields data[] — 6 idintId of the target product. titlestringThe product name. typestringThe product type. modulestringThe product's server module. categorystringThe product category. pricesarray 9 fieldsThe price options for moving to this product. Tax, currency and cycle are already worked out. price_idintId of the price. This is what you send when creating the change. periodstringThe period unit. period_timeintThe period multiplier. cyclestringThe billing cycle. amountfloatThe new per-period amount. differencefloatThe difference against the current service. tax_amountfloatThe tax amount. payablefloatThe total payable with tax. currencyintCurrency id. Errors 2 not_found404No such service. 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/services/529/upgrade-options' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"grade":"up"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/upgrade-options', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ grade: 'up' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/upgrade-options'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['grade' => 'up']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The price id comes from HERE; do not assume the catalogue's price id. $options = Api::Services()->GetUpgradeOptions(['id' => 529, 'grade' => 'up'])['data']; $target = $options[0]; $price = $target['prices'][0]; Api::Services()->CreateUpdowngrade([ 'id' => 529, 'product_id' => $target['id'], 'price_id' => $price['price_id'], ]); ``` Response 200 ```json { "data": [ { "id": 16, "title": "Pro SSD 2", "type": "hosting", "module": "cpanel", "category": "Hosting", "prices": [ { "price_id": 12722, "period": "month", "period_time": 1, "cycle": "monthly", "amount": 185.82, "difference": 185.82, "tax_amount": 37.16, "payable": 222.99, "currency": 840 } ] } ] } ``` #### Creating the Change post/api/v1/admin/services/{id}/updowngrade `Services/CreateUpdowngrade` admin the server picks the flow Opens an upgrade or downgrade record. How it proceeds follows the settings you send. Body 9 product_idintrequiredId of the target product. price_idintThe price id from the options list. Left out, the default price is used. typestring`up` or `down`. Defaults to `up`. invoice_generationstringThe invoice mode: `none` no invoice, `unpaid` an unpaid one, `paid` one treated as settled. refundstringThe refund on a downgrade: `none` or `credit` to the client's balance. scheduleboolPuts a downgrade off until the term ends. The client keeps the term they paid for. notificationboolSends the client a notification. pmethodstringThe payment method for the invoice. confirm_recreateboolConsents to the module rebuilding the account. When it is needed the first request opens no record and returns a warning instead. Response fields data — 5 flowstringThe flow that ran: `invoice_unpaid` waits for payment, `scheduled` was left to the end of the term, `queued` was taken into processing. updowngrade_idintId of the record opened. invoice_idintId of the invoice produced. Zero when there is none. warningstringComes back as `recreate` when consent is needed. On that response **no record was opened**. warning_keystringThe language key for the warning. Errors 5 not_found404No such service. product_required422No target product was given. blocked_by_gate422The `gate:service.upgrade` hook vetoed the operation. subscription_cancel_failed500The old payment subscription could not be cancelled. 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/services/529/updowngrade' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"product_id":16,"price_id":12722,"type":"up","invoice_generation":"unpaid","notification":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/updowngrade', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ product_id: 16, price_id: 12722, type: 'up', invoice_generation: 'unpaid', notification: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/updowngrade'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'product_id' => 16, 'price_id' => 12722, 'type' => 'up', 'invoice_generation' => 'unpaid', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Services()->CreateUpdowngrade([ 'id' => 529, 'product_id' => 16, 'price_id' => 12722, ]); // On a warning NO RECORD EXISTS: consent and send the request AGAIN. if (($response['data']['warning'] ?? '') === 'recreate') { Api::Services()->CreateUpdowngrade([ 'id' => 529, 'product_id' => 16, 'price_id' => 12722, 'confirm_recreate' => true, ]); } ``` Response 201 200 ```json { "data": { "flow": "invoice_unpaid", "updowngrade_id": 75, "invoice_id": 1222 } } ``` ```json { "data": { "warning": "recreate", "warning_key": "updown-recreate-warning" } } ``` #### Listing the Records get/api/v1/admin/services/updowngrades `Services/GetUpdowngrades` admin paged Returns the upgrade and downgrade records that were opened. Query parameters 5 searchstringSearches the invoice number, the product name and the client. typestring`up` ya da `down`. statusstringFilters by status. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 13 idintId of the record. service_idintId of the service changing. user_idintClient id. invoice_idintId of the invoice behind it. Zero when none was produced. typestring`up` is an upgrade, `down` a downgrade. statusstring`waiting`, `pending`, `inprocess`, `completed` or `cancelled`. status_msgstringA message on the status. On a failed record the error itself is here. refundstringHow a downgrade refunds. old_product_idintId of the current product. new_product_idintId of the target product. created_atstringWhen the record was opened. clientobject 3 fieldsWho owns the service. idintClient id. full_namestringFirst and last name. company_namestringCompany name. detailsobject 8 fieldsThe old and new product side by side. old_namestringName of the current product. new_namestringName of the target product. old_categorystringCategory of the current product. new_categorystringCategory of the target product. old_amountfloatThe current per-period amount. new_amountfloatThe new per-period amount. differencefloatThe difference between the two. currency_idintCurrency id. 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/services/updowngrades' \ -H "Authorization: Bearer $API_KEY" \ -d status=waiting ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/services/updowngrades'); url.searchParams.set('status', 'waiting'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/services/updowngrades?' . http_build_query(['status' => 'waiting']); $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::Services()->GetUpdowngrades([], ['status' => 'waiting']); ``` #### Record Detail get/api/v1/admin/services/updowngrades/{uid} `Services/GetUpdowngrade` admin Returns one change record. The schema is the same as a list item. Response fields data — 13 idintId of the record. service_idintId of the service changing. user_idintClient id. invoice_idintId of the invoice behind it. Zero when none was produced. typestring`up` is an upgrade, `down` a downgrade. statusstring`waiting`, `pending`, `inprocess`, `completed` or `cancelled`. status_msgstringA message on the status. On a failed record the error itself is here. refundstringHow a downgrade refunds. old_product_idintId of the current product. new_product_idintId of the target product. created_atstringWhen the record was opened. clientobject 3 fieldsWho owns the service. idintClient id. full_namestringFirst and last name. company_namestringCompany name. detailsobject 8 fieldsThe old and new product side by side. old_namestringName of the current product. new_namestringName of the target product. old_categorystringCategory of the current product. new_categorystringCategory of the target product. old_amountfloatThe current per-period amount. new_amountfloatThe new per-period amount. differencefloatThe difference between the two. currency_idintCurrency id. Errors 2 not_found404No such record. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/updowngrades/74' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/updowngrades/74', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/updowngrades/74'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $record = Api::Services()->GetUpdowngrade(['uid' => 74])['data']; // Why a record is stuck is in status_msg. $why = $record['status_msg']; ``` #### Approving a Record post/api/v1/admin/services/updowngrades/{uid}/approve `Services/ApproveUpdowngrade` admin Handles the refund if there is one and takes the record into processing, which a background job runs. Body — ——No body is needed. The record comes from the id in the path; send an empty body. Response fields data — 2 statusstringThe status afterwards. idintId of the record. Errors 3 not_found404No such record. already_completed422A completed record cannot be approved. 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/services/updowngrades/74/approve' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/updowngrades/74/approve', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/updowngrades/74/approve'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Approval handles the refund ONCE; approving the same record again produces no second refund. $response = Api::Services()->ApproveUpdowngrade(['uid' => 74]); ``` #### Retrying a Record post/api/v1/admin/services/updowngrades/{uid}/retry `Services/RetryUpdowngrade` admin Puts a stuck record back into processing. Body — ——No body is needed. The record comes from the id in the path; send an empty body. Response fields data — 2 statusstringThe status afterwards. idintId of the record. Errors 3 not_found404No such record. already_completed422A completed record cannot be retried. 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/services/updowngrades/74/retry' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/updowngrades/74/retry', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/updowngrades/74/retry'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read the reason before retrying: the same condition stalls it in the same place. $record = Api::Services()->GetUpdowngrade(['uid' => 74])['data']; if ($record['status_msg'] === '') { Api::Services()->RetryUpdowngrade(['uid' => 74]); } ``` #### Completing a Record post/api/v1/admin/services/updowngrades/{uid}/complete `Services/CompleteUpdowngrade` admin for changes without a module Finishes by hand a change that has no module to run it. Body — ——No body is needed. The record comes from the id in the path; send an empty body. Response fields data — 2 statusstringThe status afterwards. idintId of the record. Errors 3 not_found404No such record. complete_failed500Completing it 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/services/updowngrades/74/complete' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/updowngrades/74/complete', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/updowngrades/74/complete'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Do not call this on a service with a module: the background job runs that change. $response = Api::Services()->CompleteUpdowngrade(['uid' => 74]); ``` #### Deleting a Record delete/api/v1/admin/services/updowngrades/{uid} `Services/DeleteUpdowngrade` admin the invoice is cancelled too Deletes the record, cancels the unpaid invoice behind it and stops the pending jobs. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted record. Errors 2 not_found404No such record. 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/services/updowngrades/74' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/updowngrades/74', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/updowngrades/74'); 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 // Deleting a completed record does NOT undo the change: the service stays on the new product. $response = Api::Services()->DeleteUpdowngrade(['uid' => 74]); ``` #### Cancelling a Scheduled Downgrade post/api/v1/admin/services/{id}/cancel-scheduled-downgrade `Services/CancelScheduledDowngrade` admin Calls off a downgrade left to the end of the term before it reaches the service. Body — ——No body is needed. The service comes from the id in the path; send an empty body. Response fields data — 2 cancelledboolWhether it was called off. idintId of the record called off. Errors 2 not_found404No scheduled downgrade was found. 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/services/529/cancel-scheduled-downgrade' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/cancel-scheduled-downgrade', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/cancel-scheduled-downgrade'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The path takes the SERVICE id, not the record id: the pending plan is found from the service. $response = Api::Services()->CancelScheduledDowngrade(['id' => 529]); ``` ### Pitfalls > **On a warning response no record exists** > > When the change needs the module to rebuild the account, the first request answers a warning and opens **no record at all**. To make it happen you add the consent and send the request again. No `updowngrade_id` on the response means nothing happened. > **The price id comes from the options list** > > The price id you send when creating a change is the one from the options endpoint. Using a catalogue price directly opens the change on the wrong term or currency. The amounts in the options list already have tax and exchange worked out. > **Deleting does not undo a completed change** > > The delete is there to call off a pending record: it also cancels the unpaid invoice and stops the pending jobs. Deleting a completed one does **not** put the service back on the old product. Reversing it means opening a new change in the other direction. > **Read the reason before retrying** > > Why a record stalled is written in `status_msg`. Retrying while the same condition holds stalls it in the same place, so clear the cause first. A change with no module never progresses on its own anyway; the complete endpoint is there for that. > **Cancelling a scheduled downgrade wants the service id** > > The other eight endpoints work on a record id. Cancelling a scheduled downgrade takes the **service** id instead, because the pending plan is found from the service. Passing a record id answers `404`. ### Related Articles - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) - [Renewal and Cancellation](https://dev.wisecp.com/en/renewal-cancellation) - [Product Endpoints](https://dev.wisecp.com/en/product-endpoints) ## Add-ons on a Service https://dev.wisecp.com/en/add-ons-on-a-service The eight endpoints that attach, edit and change the state of add-ons sold on top of a service. ### Overview An add-on is something sold on top of a service: extra disk, backups, WHOIS privacy. It has its own price, its own cycle and its own status; it lives with the service but can be suspended and cancelled apart from it. There are two sources. **Catalogue** add-ons are defined on the product side and attached by id. **Domain** add-ons never enter the catalogue; they are DNS management, e-mail forwarding and WHOIS privacy, and they are attached by key. ### Reference #### Listing the Add-ons get/api/v1/admin/services/{id}/addons `Services/GetServiceAddons` admin Returns every add-on hanging on the service. Response fields data[] — 20 idintId of the add-on record. This is what the other endpoints use. service_idintId of the service it hangs on. addon_idintId of the catalogue add-on definition. Zero on a domain add-on. addon_namestringThe add-on name. option_idintId of the option chosen. option_namestringThe option name. statusstring`waiting`, `inprocess`, `active`, `suspended` or `cancelled`. amountfloatThe unit amount. total_amountfloatThe amount multiplied by the quantity. quantityintThe quantity. currency_idintCurrency id. cyclestringThe billing cycle. periodstringThe period unit. period_timeintThe period multiplier. payment_methodstringThe payment method. invoice_idintId of the invoice tied to it. subscription_idintId of the subscription behind it. created_atstringWhen it was added. due_atstringWhen the term ends. renewal_atstringThe renewal date. Errors 2 not_found404No such service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/529/addons' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons'); 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::Services()->GetServiceAddons(['id' => 529]); ``` #### Add-on Detail get/api/v1/admin/services/{id}/addons/{addon_id} `Services/GetServiceAddon` admin Returns one add-on. The schema is the same as a list item. Response fields data — 20 idintId of the add-on record. This is what the other endpoints use. service_idintId of the service it hangs on. addon_idintId of the catalogue add-on definition. Zero on a domain add-on. addon_namestringThe add-on name. option_idintId of the option chosen. option_namestringThe option name. statusstring`waiting`, `inprocess`, `active`, `suspended` or `cancelled`. amountfloatThe unit amount. total_amountfloatThe amount multiplied by the quantity. quantityintThe quantity. currency_idintCurrency id. cyclestringThe billing cycle. periodstringThe period unit. period_timeintThe period multiplier. payment_methodstringThe payment method. invoice_idintId of the invoice tied to it. subscription_idintId of the subscription behind it. created_atstringWhen it was added. due_atstringWhen the term ends. renewal_atstringThe renewal date. Errors 2 not_found404No such service or 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/services/529/addons/44' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The id in the path is the RECORD'S, not the catalogue definition's. $response = Api::Services()->GetServiceAddon(['id' => 529, 'addon_id' => 44]); ``` #### Adding an Add-on post/api/v1/admin/services/{id}/addons `Services/CreateServiceAddon` admin two sources Attaches an add-on to the service, either from the catalogue or specific to a domain. Body — which add-on 2 addon_idintId of the catalogue add-on definition. Needed on anything other than a domain. addon_keystringA domain add-on: `dns-manage`, `email-forwarding` or `whois-privacy`. On domain services only, and instead of `addon_id`. Body — price and term 9 option_idintId of the add-on option to pick. option_namestringThe option's display name. addon_namestringThe add-on's display name. override_amountfloatSets the price by hand. Left out, the option's or the domain add-on's own price is used. override_currencystringCurrency code of the hand-set price. quantityintThe quantity. At least one, and it defaults to one. cyclestringThe billing cycle. Fixed on a domain add-on and cannot be changed. currency_idintCurrency id. payment_methodstringThe payment method. Body — dates, status and invoice 8 start_datedatetimeThe start date. Defaults to now. end_datedatetimeThe end date. renewal_datedatetimeThe renewal date. statusstringThe starting status: `waiting`, `inprocess` or `active`. Defaults to `waiting`. generate_invoiceboolProduces an invoice for the add-on. invoice_statusstringStatus of the invoice produced: `unpaid` or `paid`. Defaults to `unpaid`. subscription_identifierstringIdentifier of the subscription to tie it to. requirementsobjectAnswers to the fields the add-on asks for. A map from requirement id to value. Response fields 201 — data dataobjectThe add-on created. Same shape as the detail endpoint. Errors 6 not_found404No such service. addon_id_required422No id was given for a catalogue add-on. addon_invalid422The domain add-on key is not one of the three values. addon_not_found422The add-on definition was not found. addon_add_failed500The add-on could not be attached. 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/services/529/addons' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"addon_id":9,"option_id":3,"quantity":1,"cycle":"monthly","generate_invoice":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ addon_id: 9, option_id: 3, quantity: 1, cycle: 'monthly', generate_invoice: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'addon_id' => 9, 'option_id' => 3, 'quantity' => 1, 'cycle' => 'monthly', 'generate_invoice' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On a domain service you send addon_key, NOT addon_id. $service = Api::Services()->GetService(['id' => 529])['data']; $body = $service['type'] === 'domain' ? ['addon_key' => 'whois-privacy'] : ['addon_id' => 9, 'option_id' => 3]; Api::Services()->CreateServiceAddon(['id' => 529] + $body); ``` #### Updating an Add-on patch/api/v1/admin/services/{id}/addons/{addon_id} `Services/UpdateServiceAddon` admin Changes an add-on's price, quantity, cycle and dates. Body 11 addon_namestringThe add-on's display name. option_namestringThe option's display name. amountfloatThe unit amount. The total is recalculated. quantityintThe quantity. At least one. currency_idintCurrency id. cyclestringThe billing cycle. Not applied on a domain add-on. payment_methodstringThe payment method. subscription_identifierstringThe subscription identifier, matched to a subscription. An empty value or no match clears the link. start_datedatetimeThe start date. end_datedatetimeThe end date. Sending it empty clears the date. renewal_datedatetimeThe renewal date. Sending it empty clears the date. Response fields data dataobjectThe add-on as it now stands. Same shape as the detail endpoint. Errors 2 not_found404No such service or 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/services/529/addons/44' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"amount":15,"quantity":2}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 15, quantity: 2 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['amount' => 15, 'quantity' => 2]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Moving the service's term shifts add-ons on the same day; this endpoint moves ONE add-on. $response = Api::Services()->UpdateServiceAddon([ 'id' => 529, 'addon_id' => 44, 'end_date' => '2026-09-01 00:00:00', ]); ``` #### Deleting an Add-on delete/api/v1/admin/services/{id}/addons/{addon_id} `Services/DeleteServiceAddon` admin cannot be undone Deletes the add-on record. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted record. Errors 2 not_found404No such service or 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/services/529/addons/44' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44'); 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 // Deleting switches nothing OFF in the module; for the provider side call cancel first. Api::Services()->CancelServiceAddon([ 'id' => 529, 'addon_id' => 44, 'apply_on_module' => true, ]); Api::Services()->DeleteServiceAddon(['id' => 529, 'addon_id' => 44]); ``` #### Suspending an Add-on post/api/v1/admin/services/{id}/addons/{addon_id}/suspend `Services/SuspendServiceAddon` admin reaches the module Stops the add-on. It does not touch the service itself. Body 3 reasonstringWhy it was suspended. notifyboolSends the client a notification. apply_on_moduleboolApplies it at the provider too. Left out, whether the service has a module decides. Response fields data — 3 statusstringThe status afterwards. idintId of the add-on record. applied_on_moduleboolWhether it reached the provider. Errors 3 not_found404No such service or add-on. status_change_failed500The status could not be changed. 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/services/529/addons/44/suspend' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"reason":"Awaiting payment","notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44/suspend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ reason: 'Awaiting payment', notify: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44/suspend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['reason' => 'Awaiting payment']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Services()->SuspendServiceAddon([ 'id' => 529, 'addon_id' => 44, 'reason' => 'Awaiting payment', ]); // If the status changed but it never reached the module, the feature KEEPS working. $reached = $response['data']['applied_on_module'] ?? false; ``` #### Unsuspending an Add-on post/api/v1/admin/services/{id}/addons/{addon_id}/unsuspend `Services/UnsuspendServiceAddon` admin reaches the module Puts a suspended add-on back to work. Body 2 notifyboolSends the client a notification. apply_on_moduleboolApplies it at the provider too. Left out, whether the service has a module decides. Response fields data — 3 statusstringThe status afterwards. idintId of the add-on record. applied_on_moduleboolWhether it reached the provider. Errors 3 not_found404No such service or add-on. status_change_failed500The status could not be changed. 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/services/529/addons/44/unsuspend' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44/unsuspend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44/unsuspend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $response = Api::Services()->UnsuspendServiceAddon(['id' => 529, 'addon_id' => 44]); ``` #### Cancelling an Add-on post/api/v1/admin/services/{id}/addons/{addon_id}/cancel `Services/CancelServiceAddon` admin cannot be undone Cancels the add-on and, if you ask, switches it off at the provider. Body 3 reasonstringWhy it was cancelled. notifyboolSends the client a notification. apply_on_moduleboolSwitches it off at the provider too. Off by default here, unlike suspend, so you have to ask for it. Response fields data — 3 statusstringThe status afterwards. idintId of the add-on record. applied_on_moduleboolWhether it reached the provider. Errors 3 not_found404No such service or add-on. status_change_failed500The status could not be changed. 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/services/529/addons/44/cancel' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"apply_on_module":true,"notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/addons/44/cancel', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ apply_on_module: true, notify: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/addons/44/cancel'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['apply_on_module' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On cancel the flag is OFF BY DEFAULT: leave it out and the feature stays on at the server. $response = Api::Services()->CancelServiceAddon([ 'id' => 529, 'addon_id' => 44, 'apply_on_module' => true, ]); ``` ### Pitfalls > **A domain add-on is attached by a different field** > > A catalogue add-on is attached with `addon_id`, a domain one with `addon_key`, and neither stands in for the other. Sending an id on a domain service answers `addon_not_found`; sending a key on any other answers `addon_id_required`. Read the service type before attaching. > **The module flag is off by default on cancel** > > On suspend and unsuspend, applying at the provider follows whether the service has a module; on **cancel it is off**. Cancel without the flag and the record closes while the feature stays switched on at the server. That asymmetry is easy to miss. > **Deleting switches nothing off at the server** > > The delete removes the record and never touches the provider; it does not even take a module flag. If the add-on has to be switched off at the server too, call cancel with the flag first and delete afterwards. > **The id in the path is the record's** > > The id on the sub-endpoints is the **record** attached to the service, not the catalogue definition. Both come back side by side in the list: `id` is the record and `addon_id` is the definition. You attach with the definition's id and use the record's id for everything after. ### Related Articles - [Add-on Definitions](https://dev.wisecp.com/en/addon-definitions) - [Renewal and Cancellation](https://dev.wisecp.com/en/renewal-cancellation) - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) ## Service Metrics https://dev.wisecp.com/en/service-metrics The five endpoints that configure billing by usage and read the measurements and period records. ### Overview Metered billing charges by **what is used** rather than a flat fee: disk, traffic, calls. These five endpoints configure what gets measured, read the measurements, and show the period records that were raised. What *can* be measured is decided by the **module**; how much is free, what the tiers cost and where the ceiling sits are yours to write per service. The list endpoint returns both together, so one request shows which metrics a service could turn on. ### Reference #### Listing the Metrics get/api/v1/admin/services/{id}/metrics `Services/GetServiceMetrics` admin a merged list Returns what the module can measure and what is configured on the service, in one list. Response fields data[] — 10 keystringThe metric key. labelstringIts display label. unitstringThe unit it is measured in. supportedboolWhether the module can measure it. configuredboolWhether it is configured on the service. Rows where this is false only show what is possible. enabledboolWhether usage is billed. includedfloatHow much is free before billing starts. max_valueintThe ceiling applied in the panel. schemestringFiyatlama şeması: `per_unit` prices every unit the same, `volume` prices the whole total at the tier it lands in, `graduated` adds up each tier at its own price. pricingobjectThe tiers. An empty array on a metric that is not configured. Errors 2 not_found404No such service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/529/metrics' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/metrics', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/metrics'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list also carries what COULD be configured; separate the live ones with 'configured'. $metrics = Api::Services()->GetServiceMetrics(['id' => 529])['data']; $live = array_filter($metrics, fn (array $m): bool => $m['configured'] && $m['enabled']); ``` Response 200 ```json { "data": [ { "key": "disk_space", "label": "Disk Space", "unit": "GB", "supported": true, "configured": true, "enabled": true, "included": 10, "max_value": 100, "scheme": "per_unit", "pricing": { "1-100": { "from": 1, "to": 100, "USD": { "enable": 1, "price": 0.5 } } } }, { "key": "bandwidth", "label": "Bandwidth", "unit": "GB", "supported": true, "configured": false, "enabled": false, "included": 0, "max_value": 0, "scheme": "per_unit", "pricing": [] } ] } ``` #### Configuring a Metric put/api/v1/admin/services/{id}/metrics/{metric} `Services/UpdateServiceMetric` admin tells the module Writes one metric's free allowance, its ceiling and its price tiers. Body 5 enabledboolTurns billing of the usage on or off. Turning it on needs at least one active currency with a price. includedfloatHow much is free before billing starts. max_valueintThe ceiling applied in the panel. Sending zero leaves the current one alone. schemestringThe pricing scheme: `per_unit` prices every unit the same, `volume` prices the whole total at the tier it lands in, `graduated` adds up each tier at its own price. Defaults to `per_unit`. pricingobject[]The price tiers. Each carries a lower bound, an upper bound and, per currency code, an `{enable, price}` object. Tiers whose upper bound is zero or below the lower one are ignored. Response fields data — 6 keystringThe metric key. enabledboolWhether usage is billed. includedfloatThe free allowance. max_valueintThe ceiling applied. schemestringThe pricing scheme. pricingobjectThe tiers after normalising. What you sent as an array comes back as an object keyed by the bounds. Errors 5 not_found404No such service. metric_required422The metric key was empty. invalid_metric422The module cannot measure that metric. Checked only on the first configuration. currency_required422There is no active currency with a price to turn it on. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/529/metrics/disk_space' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"included":10,"max_value":100,"scheme":"per_unit","pricing":[{"from":1,"to":100,"USD":{"enable":1,"price":0.5}}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, included: 10, max_value: 100, scheme: 'per_unit', pricing: [{ from: 1, to: 100, USD: { enable: 1, price: 0.5 } }], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'enabled' => true, 'included' => 10, 'scheme' => 'per_unit', 'pricing' => [ ['from' => 1, 'to' => 100, 'USD' => ['enable' => 1, 'price' => 0.5]], ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Tiers go in as an ARRAY and come back as an OBJECT: do not send back what you read unchanged. $current = Api::Services()->GetServiceMetrics(['id' => 529])['data'][0]; $tiers = array_values($current['pricing']); Api::Services()->UpdateServiceMetric([ 'id' => 529, 'metric' => 'disk_space', 'pricing' => $tiers, ]); ``` Response 422 ```json { "error": { "code": "currency_required", "message": "Enabling a metric requires at least one currency with a price." } } ``` #### Removing a Metric delete/api/v1/admin/services/{id}/metrics/{metric} `Services/DeleteServiceMetric` admin the history stays Takes the metric out of the service configuration. The usage and billing records stay put. Response fields data — 2 deletedboolWhether it was removed. metricstringKey of the metric removed. Errors 2 not_found404The metric is not configured on this service. 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/services/529/metrics/disk_space' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space'); 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 // Removing does NOT delete the history: the billing rows stay queryable. $response = Api::Services()->DeleteServiceMetric([ 'id' => 529, 'metric' => 'disk_space', ]); ``` #### Reading the Usage get/api/v1/admin/services/{id}/metrics/{metric}/usage `Services/GetServiceMetricUsage` admin 370 days at most Returns the daily usage points for the period you give. Query parameters 2 period_startdaterequiredThe first day of the period. period_enddaterequiredThe last day of the period. It cannot precede the start, and the span cannot exceed 370 days. Response fields data — 4 metricstringThe metric key. period_startdateThe start of the period. period_enddateThe end of the period. pointsobject[]The daily points, each a date and a value. A day with no reading comes back empty, not zero. Errors 4 not_found404No such service. invalid_period422A date could not be read, or the end precedes the start. period_too_long422The span exceeds 370 days. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -G 'https://panel.example.com/api/v1/admin/services/529/metrics/disk_space/usage' \ -H "Authorization: Bearer $API_KEY" \ -d period_start=2026-06-01 \ -d period_end=2026-06-30 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space/usage'); url.searchParams.set('period_start', '2026-06-01'); url.searchParams.set('period_end', '2026-06-30'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/services/529/metrics/disk_space/usage?' . http_build_query([ 'period_start' => '2026-06-01', 'period_end' => '2026-06-30', ]); $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 $usage = Api::Services()->GetServiceMetricUsage( ['id' => 529, 'metric' => 'disk_space'], ['period_start' => '2026-06-01', 'period_end' => '2026-06-30'], ); // An empty value means 'no reading that day', NOT 'zero usage that day'. $measured = array_filter($usage['data']['points'], fn (array $p): bool => $p['value'] !== null); ``` #### Reading the Billing History get/api/v1/admin/services/{id}/metrics/{metric}/billing `Services/GetServiceMetricBilling` admin Returns the period records raised for the metric. Response fields data[] — 13 idintId of the billing row. metricstringThe metric key. period_startdateThe start of the period. period_enddateThe end of the period. total_usagefloatTotal usage in the period. includedfloatThe free allowance. overagefloatThe part beyond the allowance, which is what gets billed. unit_pricefloatThe unit price. amountfloatThe amount. currency_idintCurrency id. invoice_idintId of the invoice it went on. Zero while it has not been attached to one. statusstringThe status of the row. created_atstringWhen the row was created. Errors 2 not_found404No such service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/529/metrics/disk_space/billing' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space/billing', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/529/metrics/disk_space/billing'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $rows = Api::Services()->GetServiceMetricBilling([ 'id' => 529, 'metric' => 'disk_space', ])['data']; // Rows with no invoice behind them have not been charged yet. $pending = array_filter($rows, fn (array $r): bool => $r['invoice_id'] === 0); ``` ### Pitfalls > **Tiers go in as an array and come back as an object** > > You send the price tiers as an **array**, but the response returns them as an **object** keyed by their bounds. Sending back what you read does not work; you have to turn the values into a plain array first. > **An empty value does not mean zero usage** > > When a day's value comes back empty, **no reading was taken** that day; it does not mean usage was zero. An average that treats empties as zeros shows days when measurement stopped as days when usage dropped. > **Removing a metric does not delete its history** > > The remove endpoint only takes the metric out of the configuration. The usage points and billing rows stay where they are and remain queryable, so nothing already charged disappears. Configure the metric again and the old history is still beside it. > **Turning it on needs a currency with a price** > > Turning a metric on for billing needs **at least one active currency** priced in the tiers, or the request answers `currency_required`. You do not have to turn it on to set the allowance: `included` can be written on a metric that stays off. ### Related Articles - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) - [Service Settings and Server](https://dev.wisecp.com/en/service-settings-and-server) ## Service Tools https://dev.wisecp.com/en/service-tools The five endpoints that run the provider panel's tools, call module methods and produce sign-in links. ### Overview Tools let you do through the API what the service's provider panel does: create a database, add a mailbox, look at files. You get the same data the panel sees, but **raw** instead of as HTML. Tools exist on hosting and server services only. Domain services have their own endpoints, and special products have no tool system at all — they use the **module method** route instead, which is where power operations, console details and the like are called. ### Reference #### Listing the Tools get/api/v1/admin/services/{id}/tools `Services/GetServiceTools` admin hosting and servers Returns the tools the service's module offers and which operations each accepts. Response fields data[] — 5 keystringThe tool key. The other endpoints use it in the path. groupstringThe group the tool sits in. labelstringThe display label. iconstringThe icon class. capabilitiesstring[]The operations the tool accepts. Calling one that is not listed is refused. Errors 3 not_found404No such service. tools_not_supported422This service type exposes no tools. Domain and special products have none. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/506/tools' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/tools', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/tools'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read the capability before trying an operation: modules support tools to different degrees. $tools = Api::Services()->GetServiceTools(['id' => 506])['data']; foreach ($tools as $tool) { $canCreate = in_array('create', $tool['capabilities'], true); } ``` Response 200 422 ```json { "data": [ { "key": "databases", "group": "databases", "label": "Databases", "icon": "bi bi-database", "capabilities": ["list", "create", "delete"] }, { "key": "email-accounts", "group": "email", "label": "Email Accounts", "icon": "bi bi-envelope", "capabilities": ["list", "create", "edit", "delete"] } ] } ``` ```json { "error": { "code": "tools_not_supported", "message": "This service type does not expose tools." } } ``` #### Reading a Tool get/api/v1/admin/services/{id}/tools/{tool} `Services/GetServiceToolData` admin the shape comes from the module Returns what the tool read from the provider, in its raw form. Query parameters 2 actionstringThe sub-operation passed to the module. It defaults to the tool's index view. *mixedAny other query parameter you give is passed straight to the module. Response fields data datamixedThe tool's own data. Its shape depends entirely on the module and the tool; there is no fixed schema. Errors 5 not_found404The tool was not found or is not supported. tools_not_supported422This service type exposes no tools. Domain and special products have none. tool_required422The tool key was empty. tool_data_failed500The module could not fetch the data. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/506/tools/databases' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/tools/databases', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/tools/databases'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The panel renders HTML, the API returns RAW data: the shape differs between modules. $response = Api::Services()->GetServiceToolData( ['id' => 506, 'tool' => 'databases'], ); ``` Response 200 ```json { "data": { "databases": [ { "name": "user_app", "size": 50855321, "tables": 42, "users": ["user_admin"] } ], "users": ["user_admin"], "prefix": "user_" } } ``` #### Running a Tool Operation post/api/v1/admin/services/{id}/tools/{tool}/{action} `Services/RunServiceToolAction` admin runs on the server Runs an operation on the tool. The module's own validation checks the body fields. Body * *mixedWhatever the operation expects. The module decides; the fields are sanitised, validated and written to the history. Response fields data datamixedThe module's result. Usually a status and a message. Errors 4 not_found404The tool was not found or is not supported. tools_not_supported422This service type exposes no tools. Domain and special products have none. tool_action_failed500The module could not complete the operation. 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/services/506/tools/databases/create' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"user_app"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/tools/databases/create', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'user_app' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/tools/databases/create'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['name' => 'user_app']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The operation really runs ON THE SERVER: delete calls cannot be undone. $response = Api::Services()->RunServiceToolAction( ['id' => 506, 'tool' => 'databases', 'action' => 'create'], [], ['name' => 'user_app'], ); ``` #### Calling a Module Method post/api/v1/admin/services/{id}/module-method `Services/UseServiceModuleMethod` admin bound to an allow list Calls a method the module allows. This is the way in for service types with no tool system. Body 2 methodstringrequiredName of the method to call. Only methods on the module's callable list, or with a matching handler, will run. *mixedAny other field you give is passed to the method as a parameter. Response fields data — 4 methodstringThe method that was called. redirect_urlstring | nullThe address, when the method produced a redirect. resultmixed | nullWhat the method returned. Filled only when it returns an array or a string. outputstring | nullAnything the method printed. Errors 5 not_found404No such service. method_required422No method name was given. invalid_method422The method is not callable on the module. no_module422The service has no module attached. module_method_failed500The method threw an error. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/admin/services/506/module-method' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"method":"vnc_info"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/module-method', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ method: 'vnc_info' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/module-method'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['method' => 'vnc_info']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The outcome can arrive in three different fields: a return value, printed output, or a redirect. $response = Api::Services()->UseServiceModuleMethod([ 'id' => 506, 'method' => 'vnc_info', ]); $data = $response['data']['result'] ?? $response['data']['output'] ?? $response['data']['redirect_url']; ``` Response 200 422 ```json { "data": { "method": "vnc_info", "redirect_url": null, "result": { "host": "203.0.113.10", "port": 5901 }, "output": null } } ``` ```json { "error": { "code": "invalid_method", "message": "Module method is not callable." } } ``` #### A Sign-in Link for the Panel post/api/v1/admin/services/{id}/sso `Services/GetServiceSso` admin one-click sign-in Produces a one-click sign-in link for the service's panel. Body 1 rootboolProduces an administrator sign-in. Off by default, and while off it signs in to the client's own account. Response fields data — 2 urlstringThe sign-in link. rootboolWhether an administrator sign-in was asked for. Errors 4 not_found404No such service. sso_not_supported422The module does not support one-click sign-in. sso_failed500The module produced no link. 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/services/506/sso' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"root":false}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/sso', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ root: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/sso'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['root' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The link carries a session: keep it out of logs, do not share it, use it quickly. $response = Api::Services()->GetServiceSso(['id' => 506]); $url = $response['data']['url']; ``` ### Pitfalls > **A tool operation really runs on the server** > > These endpoints do not update a record; they reach the provider and have the work done **there**. Delete operations really remove the client's data and cannot be undone. Check the capabilities in the tool list before trying one, since an unsupported operation is refused. > **The response shape depends on the module** > > Tool data comes back raw and its shape differs between modules: one hosting panel returns databases under different keys than another. Parsing written against a single module breaks on the second one, so do not assume a fixed schema. > **Module methods run against an allow list** > > You cannot pick the method name freely: only ones the module marks callable, or that have a matching handler, will run, and the rest answer `invalid_method`. That is a security boundary, so do not assume every public method on the module is reachable. > **Client-side restrictions apply on the API too** > > Tools switched off for the client on a server are switched off for API calls as well. A tool you can see as an administrator in the panel may be unreachable through the API with the same key, and the reason is the restriction setting on the server rather than your permissions. > **The sign-in link carries a session** > > The link opens a session when clicked. Logging it, storing it or sharing it means sharing access to that account, so treat it as a short-lived credential. A call asking for the administrator sign-in opens the **root of the panel**, not one client account. ### Related Articles - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) - [Service Settings and Server](https://dev.wisecp.com/en/service-settings-and-server) - [Provisioning Servers](https://dev.wisecp.com/en/provisioning-servers) ## Licence Transfers https://dev.wisecp.com/en/licence-transfers The six endpoints that move a service licence to another client: starting, following, chasing and cancelling. ### Overview A licence transfer moves a service's licence to another client. These six endpoints manage the transfer record: starting it, following it, chasing it and calling it off. The flow **does not finish in one step**. Starting only opens the record; both sides confirm by e-mail, the fee invoice is paid if there is one, and only then does the licence change hands. What the transfer is waiting on right now is in the confirmation state on the detail. This is **not a service ownership handover**. Changing who owns a service is a separate system with its own endpoints. ### Reference #### Listing the Transfers get/api/v1/admin/services/{id}/license-transfers `Services/GetServiceLicenseTransfers` admin Returns the service's transfer history, newest first. Response fields data[] — 17 idintId of the transfer. service_idintId of the service being transferred. product_idintId of the product. transferor_idintId of the client handing over. transferee_idintId of the client taking on. statusstringThe transfer status. It differs while verification is pending, once complete, and after cancellation. feeobject 3 fieldsThe transfer fee. typestring`percentage` is a share, `fixed` a set amount. amountfloatThe value of the fee. currency_idintCurrency id. invoice_idintId of the fee invoice. Zero when none was produced. invoice_recipientstringWho the invoice is raised against: the one handing over or the one taking on. notify_partiesboolWhether the parties are being notified. notesstringAn admin note. expires_atstringWhen the transfer lapses. If both sides have not confirmed by then, it falls away. initiated_atstringWhen it was started. completed_atstring | nullWhen it completed. cancelled_atstring | nullWhen it was cancelled. cancel_reasonstring | nullWhy it was cancelled. created_atstringWhen the record was created. Errors 2 not_found404No such service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/506/license-transfers' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list does NOT carry the confirmation state; read one transfer's detail to see it. $transfers = Api::Services()->GetServiceLicenseTransfers(['id' => 506])['data']; ``` #### Starting a Transfer post/api/v1/admin/services/{id}/license-transfers `Services/CreateServiceLicenseTransfer` admin 201 Opens a transfer, sends a verification to both sides and raises the fee invoice if there is one. Body 3 transferee_idintrequiredId of the client taking the licence on. notesstringAn admin note. notify_partiesboolNotifies the parties. On by default. Response fields data — 18 idintId of the transfer. service_idintId of the service being transferred. product_idintId of the product. transferor_idintId of the client handing over. transferee_idintId of the client taking on. statusstringThe transfer status. It differs while verification is pending, once complete, and after cancellation. feeobject 3 fieldsThe transfer fee. typestring`percentage` is a share, `fixed` a set amount. amountfloatThe value of the fee. currency_idintCurrency id. invoice_idintId of the fee invoice. Zero when none was produced. invoice_recipientstringWho the invoice is raised against: the one handing over or the one taking on. notify_partiesboolWhether the parties are being notified. notesstringAn admin note. expires_atstringWhen the transfer lapses. If both sides have not confirmed by then, it falls away. initiated_atstringWhen it was started. completed_atstring | nullWhen it completed. cancelled_atstring | nullWhen it was cancelled. cancel_reasonstring | nullWhy it was cancelled. created_atstringWhen the record was created. verificationsobject[] 7 fieldsThe confirmation state of both sides. partystringWhich side: `transferor` hands over, `transferee` takes on. emailstringWhere the verification was sent. verifiedboolWhether this side confirmed. verified_atstring | nullWhen they confirmed. sent_atstringWhen it was first sent. last_resent_atstring | nullWhen it was last resent. resend_countintHow many times it was resent. Errors 4 not_found404No such service. transferee_required422No receiving client was given. transfer_not_allowed422The transfer cannot start. The licence transfer addon may not be installed, transfers may be off on the product, the service may already have an open transfer, or it may not meet the eligibility rules. 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/services/506/license-transfers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"transferee_id":88,"notes":"sold","notify_parties":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ transferee_id: 88, notes: 'sold', notify_parties: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'transferee_id' => 88, 'notes' => 'sold', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Starting does NOT complete it: the licence stays put until both sides confirm by e-mail. $transfer = Api::Services()->CreateServiceLicenseTransfer([ 'id' => 506, 'transferee_id' => 88, ])['data']; $waiting = array_filter($transfer['verifications'], fn (array $v): bool => !$v['verified']); ``` Response 201 422 ```json { "data": { "id": 7, "service_id": 506, "transferor_id": 50, "transferee_id": 88, "status": "pending_verification", "fee": { "type": "percentage", "amount": 5.0, "currency_id": 840 }, "invoice_id": 0, "invoice_recipient": "transferee", "expires_at": "2026-06-28 12:00:00", "verifications": [ { "party": "transferor", "email": "john@example.com", "verified": false, "verified_at": null, "sent_at": "2026-06-21 12:00:00", "last_resent_at": null, "resend_count": 0 }, { "party": "transferee", "email": "jane@example.com", "verified": false, "verified_at": null, "sent_at": "2026-06-21 12:00:00", "last_resent_at": null, "resend_count": 0 } ] } } ``` ```json { "error": { "code": "transfer_not_allowed", "message": "License transfer is not enabled for this product." } } ``` #### Transfer Detail get/api/v1/admin/services/{id}/license-transfers/{tid} `Services/GetServiceLicenseTransfer` admin confirmations included Returns one transfer together with the confirmation state of both sides. Response fields data — 18 idintId of the transfer. service_idintId of the service being transferred. product_idintId of the product. transferor_idintId of the client handing over. transferee_idintId of the client taking on. statusstringThe transfer status. It differs while verification is pending, once complete, and after cancellation. feeobject 3 fieldsThe transfer fee. typestring`percentage` is a share, `fixed` a set amount. amountfloatThe value of the fee. currency_idintCurrency id. invoice_idintId of the fee invoice. Zero when none was produced. invoice_recipientstringWho the invoice is raised against: the one handing over or the one taking on. notify_partiesboolWhether the parties are being notified. notesstringAn admin note. expires_atstringWhen the transfer lapses. If both sides have not confirmed by then, it falls away. initiated_atstringWhen it was started. completed_atstring | nullWhen it completed. cancelled_atstring | nullWhen it was cancelled. cancel_reasonstring | nullWhy it was cancelled. created_atstringWhen the record was created. verificationsobject[] 7 fieldsThe confirmation state of both sides. partystringWhich side: `transferor` hands over, `transferee` takes on. emailstringWhere the verification was sent. verifiedboolWhether this side confirmed. verified_atstring | nullWhen they confirmed. sent_atstringWhen it was first sent. last_resent_atstring | nullWhen it was last resent. resend_countintHow many times it was resent. Errors 2 not_found404No such service or transfer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/506/license-transfers/7' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This is where you see what the transfer is waiting on: a confirmation or a payment. $transfer = Api::Services()->GetServiceLicenseTransfer(['id' => 506, 'tid' => 7])['data']; $allConfirmed = !array_filter($transfer['verifications'], fn (array $v): bool => !$v['verified']); $feeUnpaid = $transfer['invoice_id'] > 0; ``` #### Cancelling a Transfer delete/api/v1/admin/services/{id}/license-transfers/{tid} `Services/CancelServiceLicenseTransfer` admin the invoice is cancelled too Stops a transfer in progress and cancels the unpaid fee invoice behind it. Body 1 notify_partiesboolNotifies the parties. On by default. Response fields data — 2 cancelledboolWhether it was cancelled. idintId of the transfer. Errors 4 not_found404No such service or transfer. not_active422The transfer is not in a state that can be cancelled. It may already be complete or cancelled. cancel_failed500The transfer could not be cancelled. 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/services/506/license-transfers/7' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"notify_parties":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notify_parties: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['notify_parties' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A completed transfer CANNOT be cancelled: taking ownership back needs a new transfer the other way. $response = Api::Services()->CancelServiceLicenseTransfer(['id' => 506, 'tid' => 7]); ``` #### Resending a Verification post/api/v1/admin/services/{id}/license-transfers/{tid}/resend `Services/ResendServiceLicenseTransferVerification` admin Sends the verification e-mail again to a side that has not confirmed. Body 1 partystringWhich side: `transferor`, `transferee` or both. It defaults to both. Response fields data — 2 resentarrayThe sides that actually got one. A side that already confirmed is not listed. idintId of the transfer. Errors 4 not_found404No such service or transfer. invalid_party422The side is not one of the three values. resend_failed422There is nothing to resend. Both sides have confirmed, or the transfer is past the verification stage. 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/services/506/license-transfers/7/resend' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"party":"both"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/resend', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ party: 'both' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/resend'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['party' => 'both']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Asking for both is harmless: a side that confirmed is skipped, and the list says who got one. $response = Api::Services()->ResendServiceLicenseTransferVerification([ 'id' => 506, 'tid' => 7, 'party' => 'both', ]); $sentTo = $response['data']['resent']; ``` #### Reminding About the Fee Invoice post/api/v1/admin/services/{id}/license-transfers/{tid}/remind-invoice `Services/RemindServiceLicenseTransferInvoice` admin Sends a reminder for the transfer fee invoice. Body — ——No body is needed, send an empty one. The service and the transfer come from the path, and the recipient is whoever the transfer's invoice recipient names; there is no field to send it elsewhere. Response fields data — 3 remindedboolWhether the reminder went out. idintId of the transfer. invoice_idintId of the invoice reminded about. Errors 3 not_found404No such service or transfer. invoice_not_generated422No fee invoice was produced for this transfer. A transfer with no fee never gets one. 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/services/506/license-transfers/7/remind-invoice' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/remind-invoice', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/506/license-transfers/7/remind-invoice'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The reminder goes to whoever the invoice is RAISED AGAINST, which can be either side. $transfer = Api::Services()->GetServiceLicenseTransfer(['id' => 506, 'tid' => 7])['data']; if ($transfer['invoice_id'] > 0) { Api::Services()->RemindServiceLicenseTransferInvoice(['id' => 506, 'tid' => 7]); } ``` ### Pitfalls > **Starting does not complete the transfer** > > Even when the start request answers `201`, the licence is still with its old owner. The transfer only moves once **both sides** confirm by e-mail and the fee invoice, if any, is paid. The confirmation list on the response says who is being waited on. > **A transfer has a deadline** > > The record carries an expiry. If the confirmations are not in by then the transfer lapses on its own and the licence never moves. On one that has been waiting a while, check the deadline before cancelling: it may have lapsed already. > **Four different things can refuse the start** > > `transfer_not_allowed` is not one rule but four conditions at once: the licence transfer addon may not be installed, transfers may be off on the product, the service may already have an open transfer, or it may not meet the eligibility rules. The message says which one; reading the code alone is misleading. > **Do not confuse it with an ownership handover** > > The transfer here moves the **licence** and has its own verification and fee flow. Changing who owns a service, so that its invoices start going to another client, is an entirely different system living on its own endpoints. ### Related Articles - [Ownership Transfers](https://dev.wisecp.com/en/ownership-transfers) - [Service Endpoints](https://dev.wisecp.com/en/service-endpoints) ## Domain Basics https://dev.wisecp.com/en/domain-basics The nine endpoints behind a domain's nameservers, transfer lock, ownership details and recovery. ### Overview These nine endpoints run a domain's **basics**. Where it points, whether it can be moved, who it appears to belong to, and whether it survives expiry. All of them make a **real call** to the registry. So they can be slow, they can hit the registry's own rules, and a module may not support an operation at all. Every endpoint shares the same four preconditions. The service must exist, be a domain, have a registrar module attached, and that module must support the operation. ### Reference #### Setting the Nameservers put/api/v1/admin/services/{id}/domain/nameservers `Services/SetDomainNameservers` admin goes to the registry Writes which nameservers the domain points at. Body 4 ns1stringrequiredThe primary nameserver. It has to be a valid host name. ns2stringrequiredThe secondary nameserver. ns3stringA third nameserver. ns4stringA fourth nameserver. Response fields data — 2 nameserversstring[]The nameservers written. Ones left empty are not in the list. idintService id. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. ns_required422One of the first two nameservers was empty. ns_invalid422A nameserver is not a valid host name. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/nameservers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ns1":"ns1.example.com","ns2":"ns2.example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/nameservers', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ns1: 'ns1.example.com', ns2: 'ns2.example.com', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'ns1' => 'ns1.example.com', 'ns2' => 'ns2.example.com', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list is written WHOLE: leave ns3 and ns4 out and any existing ones are cleared. $response = Api::Services()->SetDomainNameservers([ 'id' => 520, 'ns1' => 'ns1.example.com', 'ns2' => 'ns2.example.com', ]); ``` #### Reading the Transfer Lock get/api/v1/admin/services/{id}/domain/transfer-lock `Services/GetDomainTransferLock` admin Returns whether the domain is locked against being moved to another registrar. Response fields data — 1 transfer_lockstringThe lock state. The value comes raw from the registry and differs between them. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/transfer-lock' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/transfer-lock', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/transfer-lock'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Reading returns a RAW value while writing returns 'enabled'/'disabled': not the same vocabulary. $response = Api::Services()->GetDomainTransferLock(['id' => 520]); ``` #### Changing the Transfer Lock put/api/v1/admin/services/{id}/domain/transfer-lock `Services/SetDomainTransferLock` admin Locks the domain against being moved, or unlocks it. Body 1 statusstringrequired`enable` locks it, `disable` unlocks it. Response fields data — 2 transfer_lockstringThe lock state afterwards. idintService id. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. invalid_status422The status is neither `enable` nor `disable`. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/transfer-lock' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"enable"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/transfer-lock', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'enable' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/transfer-lock'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 'enable']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // While the lock is ON the domain cannot move: unlock it before the client leaves for another registrar. Api::Services()->SetDomainTransferLock(['id' => 520, 'status' => 'disable']); $code = Api::Services()->GetDomainAuthCode(['id' => 520]); ``` #### Getting the Authorisation Code get/api/v1/admin/services/{id}/domain/auth-code `Services/GetDomainAuthCode` admin may go by e-mail Asks for the code needed to move the domain to another registrar. Response fields data — 2 auth_codestringThe authorisation code. Present only when the module hands it over directly. sentboolThe code was e-mailed to the registrant. In that case the code itself is not in the response. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/auth-code' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/auth-code', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); if (body.data.sent) { // it went by e-mail, the code is not here } ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/auth-code'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There are two response shapes; do NOT assume the code arrived. $response = Api::Services()->GetDomainAuthCode(['id' => 520])['data']; $code = $response['auth_code'] ?? null; $mailed = $response['sent'] ?? false; ``` Response 200 200 ```json { "data": { "auth_code": "EXAMPLE-AUTH-CODE" } } ``` ```json { "data": { "sent": true } } ``` #### Restoring the Domain post/api/v1/admin/services/{id}/domain/restore `Services/RestoreDomain` admin time-bound Recovers an expired domain. It only works while the recovery window is open. Body — ——No body is needed; send an empty one. The domain is addressed by the service id in the path. Response fields data — 2 restoredboolWhether the recovery succeeded. idintService id. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. not_in_window422The domain is not in the recovery window. Once it closes the domain may have been released. module_failed500The registry could not complete the operation. 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/services/520/domain/restore' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/restore', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/restore'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Recovery costs a FEE at the registry, and it has to be called before the window closes. $response = Api::Services()->RestoreDomain(['id' => 520]); ``` #### Checking a Transfer post/api/v1/admin/services/{id}/domain/check-transfer `Services/CheckDomainTransfer` admin Asks the registry where a domain transfer in progress has got to. Body — ——No body is needed; send an empty one. The transfer is addressed by the service id in the path. Response fields data — 2 transfer_statusstringThe transfer status. A service that is not a transfer comes back with the error value. messagestring | nullAn explanation from the registry. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. 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/services/520/domain/check-transfer' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/check-transfer', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/check-transfer'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The error state comes back inside a 200, not as an HTTP error: read the status before assuming success. $check = Api::Services()->CheckDomainTransfer(['id' => 520])['data']; $stuck = $check['transfer_status'] === 'error'; ``` #### Reading the WHOIS Contacts get/api/v1/admin/services/{id}/domain/whois `Services/GetDomainWhois` admin four contacts Fetches the domain's contact details from the registry. Response fields data — 4 registrantobject 14 fieldsThe registrant's contact. This is who legally holds the domain. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. administrativeobject 14 fieldsThe administrative contact. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. technicalobject 14 fieldsThe technical contact. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. billingobject 14 fieldsThe billing contact. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/whois' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/whois', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/whois'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The read goes to the registry and refreshes the local copy too, so it can be slow. $whois = Api::Services()->GetDomainWhois(['id' => 520])['data']; $owner = $whois['registrant']; ``` Response 200 ```json { "data": { "registrant": { "first_name": "John", "last_name": "Doe", "company": "", "email": "john@example.com", "phone": "5550100", "phone_cc": "+1", "fax": "", "fax_cc": "", "address_line1": "123 Market Street", "address_line2": "", "city": "San Francisco", "state": "California", "zipcode": "94105", "country": "US" }, "administrative": { "first_name": "John", "last_name": "Doe", "email": "john@example.com" }, "technical": { "first_name": "John", "last_name": "Doe", "email": "john@example.com" }, "billing": { "first_name": "John", "last_name": "Doe", "email": "john@example.com" } } } ``` #### Writing the WHOIS Contacts put/api/v1/admin/services/{id}/domain/whois `Services/SetDomainWhois` admin ownership is affected Writes the contact details to the registry. Body 4 registrantobject 14 fieldsThe registrant's contact. This is who legally holds the domain. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. administrativeobject 14 fieldsThe administrative contact. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. technicalobject 14 fieldsThe technical contact. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. billingobject 14 fieldsThe billing contact. first_namestringFirst name. last_namestringLast name. companystringCompany name. emailstringE-mail address. phonestringPhone number. phone_ccstringCountry code for the phone. faxstringFax number. fax_ccstringCountry code for the fax. address_line1stringFirst line of the address. address_line2stringSecond line of the address. citystringCity. statestringState or region. zipcodestringPostcode. countrystringCountry. The two-letter ISO code. Response fields data — 1 savedboolWhether the save succeeded. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/whois' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"registrant":{"first_name":"John","last_name":"Doe","email":"john@example.com","phone":"5550100","phone_cc":"+1","address_line1":"123 Market Street","city":"San Francisco","state":"California","zipcode":"94105","country":"US"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/whois', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ registrant: { first_name: 'John', last_name: 'Doe', email: 'john@example.com', phone: '5550100', phone_cc: '+1', address_line1: '123 Market Street', city: 'San Francisco', state: 'California', zipcode: '94105', country: 'US', }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/whois'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'registrant' => [ 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john@example.com', 'address_line1' => '123 Market Street', 'city' => 'San Francisco', 'country' => 'US', ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read first and write on top: fields left out can end up empty at the registry. $whois = Api::Services()->GetDomainWhois(['id' => 520])['data']; $whois['registrant']['email'] = 'new@example.com'; Api::Services()->SetDomainWhois(['id' => 520] + $whois); ``` #### Changing WHOIS Privacy put/api/v1/admin/services/{id}/domain/whois-privacy `Services/SetDomainWhoisPrivacy` admin Turns on or off the hiding of contact details in public lookups. Body 1 statusstringrequired`enable` hides them, `disable` reveals them. Response fields data — 1 whois_privacystringThe privacy state afterwards. Either `enabled` or `disabled`. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. invalid_status422The status is neither `enable` nor `disable`. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/whois-privacy' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"enable"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/whois-privacy', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'enable' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/whois-privacy'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 'enable']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On most extensions privacy is a PAID add-on, so turning it on can raise an invoice. $response = Api::Services()->SetDomainWhoisPrivacy([ 'id' => 520, 'status' => 'enable', ]); ``` ### Pitfalls > **The authorisation code has two response shapes** > > Some registries hand the code over directly while others e-mail it to the registrant. In the second case the response says only that it was sent and **carries no code**. A client that assumes the code arrived reads an empty value here. > **The nameserver list is written whole** > > What you send becomes the truth: leave the third and fourth out and any existing ones are **cleared**. Even to change only the primary, the others have to go with it. > **Changing the registrant changes ownership** > > The WHOIS write is more than updating contact details: the registrant field decides **who legally holds** the domain. On most extensions that change starts an approval process and locks the domain out of transfers for a while. Fields left out can also end up empty at the registry, so read first and write on top. > **The recovery window closes** > > An expired domain first passes through a period where it can be recovered, then it is released. Once the window closes this endpoint answers `not_in_window` and there is nothing left to do. Recovery also tends to cost a steep fee at the registry. > **The transfer check returns its error inside a 200** > > A service that is not a transfer, or a transfer that is stuck, shows up in the status field of the response rather than as an HTTP error. A client reading only the status code counts a failed transfer as a success. ### Related Articles - [DNS Management](https://dev.wisecp.com/en/dns-management) - [E-mail and URL Forwarding](https://dev.wisecp.com/en/email-and-url-forwarding) - [Domain Extensions](https://dev.wisecp.com/en/domain-extensions) ## DNS Management https://dev.wisecp.com/en/dns-management The eleven endpoints behind a domain's DNS records, child nameservers and DNSSEC signatures. ### Overview These eleven endpoints run a domain's **name resolution**: which name points where, the domain's own nameservers, and signature validation. Three separate jobs sit together. **Child nameservers** define the domain's own servers. **DNS records** say which name goes where. **DNSSEC** makes those answers signed. None of it keeps a local copy: every read goes to the registry and every write is live at once. ### Reference #### Listing the Child Nameservers get/api/v1/admin/services/{id}/domain/child-nameservers `Services/GetDomainChildNameservers` admin Returns the nameservers defined under the domain itself. Response fields data[] — 2 nsstringThe child nameserver's name. ipstringThe address it resolves to. Errors 5 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // When the module cannot list them a LOCAL copy is returned, which can drift from the registry. $response = Api::Services()->GetDomainChildNameservers(['id' => 520]); ``` #### Adding a Child Nameserver post/api/v1/admin/services/{id}/domain/child-nameservers `Services/AddDomainChildNameserver` admin 201 Defines a new nameserver under the domain. Body 2 nsstringrequiredThe nameserver's name. ipstringrequiredThe address it resolves to. Validated as IPv4 or IPv6. Response fields data — 2 nsstringThe added child nameserver's name. Returned with `201`. ipstringThe address it resolves to. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. cns_fields_required422The name or the address was empty. invalid_ip422The address is not a valid IP. 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/services/520/domain/child-nameservers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ns":"ns1.example.com","ip":"203.0.113.10"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ns: 'ns1.example.com', ip: '203.0.113.10' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'ns' => 'ns1.example.com', 'ip' => '203.0.113.10', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Defining a child nameserver does not START USING it; point the domain at it separately. Api::Services()->AddDomainChildNameserver([ 'id' => 520, 'ns' => 'ns1.example.com', 'ip' => '203.0.113.10', ]); Api::Services()->SetDomainNameservers([ 'id' => 520, 'ns1' => 'ns1.example.com', 'ns2' => 'ns2.example.com', ]); ``` #### Updating a Child Nameserver put/api/v1/admin/services/{id}/domain/child-nameservers `Services/UpdateDomainChildNameserver` admin found by its old value Finds an existing child nameserver and replaces it with new values. Body 4 old_nsstringrequiredThe current name of the record to change. old_ipstringThe current address. It tells apart several records sharing a name. new_nsstringrequiredThe new name. new_ipstringrequiredThe new address. Response fields data — 2 nsstringThe child nameserver's name after the change. ipstringThe address it now resolves to. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. cns_fields_required422One of the required fields was empty. invalid_ip422The new address is not a valid IP. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"old_ns":"ns1.example.com","old_ip":"203.0.113.10","new_ns":"ns1.example.com","new_ip":"203.0.113.20"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ old_ns: 'ns1.example.com', old_ip: '203.0.113.10', new_ns: 'ns1.example.com', new_ip: '203.0.113.20', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'old_ns' => 'ns1.example.com', 'old_ip' => '203.0.113.10', 'new_ns' => 'ns1.example.com', 'new_ip' => '203.0.113.20', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The record is found by its old value, not an id: get the old name wrong and nothing matches. Api::Services()->UpdateDomainChildNameserver([ 'id' => 520, 'old_ns' => 'ns1.example.com', 'old_ip' => '203.0.113.10', 'new_ns' => 'ns1.example.com', 'new_ip' => '203.0.113.20', ]); ``` #### Deleting a Child Nameserver delete/api/v1/admin/services/{id}/domain/child-nameservers `Services/DeleteDomainChildNameserver` admin Removes a child nameserver definition. Body 2 nsstringrequiredName of the record to delete. ipstringThe address. Some modules need it to match. Response fields data — 2 deletedboolWhether the delete succeeded. nsstringName of the deleted record. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete 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/services/520/domain/child-nameservers' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ns":"ns1.example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ns: 'ns1.example.com' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ns' => 'ns1.example.com']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Deleting a child nameserver that is in use can leave the domain unreachable. Api::Services()->DeleteDomainChildNameserver(['id' => 520, 'ns' => 'ns1.example.com']); ``` #### Listing the DNS Records get/api/v1/admin/services/{id}/domain/dns-records `Services/GetDomainDnsRecords` admin straight from the registry Returns the domain's DNS records. No local copy is kept. Response fields data[] — 6 identitystringThe id the module gives the record. Its shape differs between modules. typestringThe record type. namestringThe record name or subdomain. valuestringThe record value. ttlintTime to live, in seconds. priorityintPriority. Meaningful on mail records. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/dns-records' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dns-records', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The 'identity' needed to update or delete comes from HERE; it cannot be produced elsewhere. $records = Api::Services()->GetDomainDnsRecords(['id' => 520])['data']; $www = current(array_filter($records, fn (array $r): bool => $r['name'] === 'www')); ``` Response 200 ```json { "data": [ { "identity": "1001", "type": "A", "name": "@", "value": "203.0.113.10", "ttl": 3600, "priority": 0 } ] } ``` #### Adding a DNS Record post/api/v1/admin/services/{id}/domain/dns-records `Services/AddDomainDnsRecord` admin 201 Adds a new DNS record to the domain. Body 5 typestringrequiredThe record type. namestringrequiredThe record name or subdomain. valuestringrequiredThe record value. ttlintTime to live, in seconds. priorityintPriority. Response fields data — 2 addedboolThe record was added. Returned with `201`. ——A module that returns the added record sends that record instead. Read the shape you get; do not count on `added`. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. dns_fields_required422One of the required fields was empty. 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/services/520/domain/dns-records' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"A","name":"www","value":"203.0.113.10","ttl":3600}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dns-records', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'A', name: 'www', value: '203.0.113.10', ttl: 3600, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'A', 'name' => 'www', 'value' => '203.0.113.10', 'ttl' => 3600, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The response may NOT carry the record it added; read the list again to learn its identity. Api::Services()->AddDomainDnsRecord([ 'id' => 520, 'type' => 'A', 'name' => 'www', 'value' => '203.0.113.10', ]); $records = Api::Services()->GetDomainDnsRecords(['id' => 520])['data']; ``` #### Updating a DNS Record put/api/v1/admin/services/{id}/domain/dns-records `Services/UpdateDomainDnsRecord` admin not on every module Changes an existing DNS record. It does not run if the module offers no update. Body 6 typestringrequiredThe record type. namestringrequiredThe record name. valuestringrequiredThe new value. identitystringThe record id from the list. This is the safest way to hit the right record. ttlintTime to live. priorityintPriority. Response fields data — 2 updatedboolThe record was updated. ——A module that returns the updated record sends that record instead. Read the shape you get; do not count on `updated`. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. dns_fields_required422One of the required fields was empty. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/dns-records' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"A","name":"www","value":"203.0.113.20","identity":"1001"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dns-records', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'A', name: 'www', value: '203.0.113.20', identity: '1001', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'A', 'name' => 'www', 'value' => '203.0.113.20', 'identity' => '1001', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A module with no update answers 'not_supported'; fall back to delete-then-add. $response = Api::Services()->UpdateDomainDnsRecord([ 'id' => 520, 'type' => 'A', 'name' => 'www', 'value' => '203.0.113.20', 'identity' => '1001', ]); ``` #### Deleting a DNS Record delete/api/v1/admin/services/{id}/domain/dns-records `Services/DeleteDomainDnsRecord` admin live immediately Removes a DNS record. Body 4 typestringrequiredThe record type. namestringThe record name. It narrows the match. valuestringThe record value. It narrows the match. identitystringThe record id from the list. Response fields data deletedboolThe record was deleted. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. dns_fields_required422The record type was empty. 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/services/520/domain/dns-records' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"A","name":"www","identity":"1001"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dns-records', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'A', name: 'www', identity: '1001' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'A', 'name' => 'www', 'identity' => '1001', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Sending only the type can sweep up other records of the SAME TYPE; give the identity too. Api::Services()->DeleteDomainDnsRecord([ 'id' => 520, 'type' => 'A', 'name' => 'www', 'identity' => '1001', ]); ``` #### Listing the DNSSEC Records get/api/v1/admin/services/{id}/domain/dnssec `Services/GetDomainDnssecRecords` admin Returns the domain's DNSSEC signing records. Response fields data[] — 5 identitystringThe id the module gives the record. digeststringThe digest value. key_tagintThe key tag. digest_typeintThe digest type. The allowed values come from the module settings. algorithmintThe algorithm. The allowed values come from the module settings. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/dnssec' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dnssec', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dnssec'); 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::Services()->GetDomainDnssecRecords(['id' => 520]); ``` #### Adding a DNSSEC Record post/api/v1/admin/services/{id}/domain/dnssec `Services/AddDomainDnssecRecord` admin 201 Adds a DNSSEC signing record to the domain. Body 4 digeststringrequiredThe digest value. key_tagintrequiredThe key tag. It has to be above zero. digest_typeintrequiredThe digest type. It has to be on the module's allowed list. algorithmintrequiredThe algorithm. It has to be on the module's allowed list. Response fields data — 2 addedboolThe record was added. Returned with `201`. ——A module that returns the added record sends that record instead. Read the shape you get; do not count on `added`. Errors 8 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. dnssec_fields_required422One of the required fields was empty. invalid_digest_type422The digest type is not defined in the module settings. invalid_algorithm422The algorithm is not defined in the module settings. 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/services/520/domain/dnssec' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"digest":"49FD46E6C4B45C55D4AC","key_tag":12345,"digest_type":2,"algorithm":13}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dnssec', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ digest: '49FD46E6C4B45C55D4AC', key_tag: 12345, digest_type: 2, algorithm: 13, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dnssec'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'digest' => '49FD46E6C4B45C55D4AC', 'key_tag' => 12345, 'digest_type' => 2, 'algorithm' => 13, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The allowed digest type and algorithm come from the MODULE SETTINGS, not every value in the standard. Api::Services()->AddDomainDnssecRecord([ 'id' => 520, 'digest' => $digest, 'key_tag' => 12345, 'digest_type' => 2, 'algorithm' => 13, ]); ``` #### Deleting a DNSSEC Record delete/api/v1/admin/services/{id}/domain/dnssec `Services/DeleteDomainDnssecRecord` admin validation can break Removes a DNSSEC signing record. Body 5 digeststringrequiredThe digest value. key_tagintrequiredThe key tag. digest_typeintThe digest type. It narrows the match. algorithmintThe algorithm. It narrows the match. identitystringThe record id from the list. Response fields data deletedboolThe record was deleted. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. dnssec_fields_required422The digest or the key tag was empty. 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/services/520/domain/dnssec' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"digest":"49FD46E6C4B45C55D4AC","key_tag":12345}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/dnssec', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ digest, key_tag: 12345 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/dnssec'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'digest' => $digest, 'key_tag' => 12345, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Deleting the last DS record turns off signature validation; add the new one first. Api::Services()->AddDomainDnssecRecord(['id' => 520] + $newRecord); Api::Services()->DeleteDomainDnssecRecord(['id' => 520] + $oldRecord); ``` ### Pitfalls > **The record id only comes from the list** > > The `identity` on the update and delete endpoints is what the list returned. You cannot make one up, and its shape differs between modules. The add response **may not carry** the record it created, so read the list again to learn the id. > **A delete without an id can sweep up more** > > Only the record type is required on the delete. Sent without a name, a value or an id, it can sweep up **other records of the same type**, because the module does the matching. Pin down which record you mean with an id before deleting. > **Not every module can update a DNS record** > > The update only runs when the module offers it; otherwise it answers `not_supported`. On such a module the only route is delete then add, which means a brief gap between the two. > **The allowed DNSSEC values come from the module** > > The digest type and the algorithm do not accept every value in the standard. The allowed set is defined in the module's own settings, and a value absent from that list answers `invalid_algorithm`. > **Writes go live at once** > > There is no draft or approval step here; a change you make reaches the registry and starts propagating. Deleting a child nameserver that is in use can leave the domain unreachable. Deleting the last DNSSEC record turns off signature validation. Undoing a change that went wrong takes as long as the caches hold it. ### Related Articles - [Domain Basics](https://dev.wisecp.com/en/domain-basics) - [E-mail and URL Forwarding](https://dev.wisecp.com/en/email-and-url-forwarding) ## E-mail and URL Forwarding https://dev.wisecp.com/en/email-and-url-forwarding The seven endpoints that send a domain's mail and its visitors somewhere else. ### Overview These seven endpoints decide **where what arrives** at a domain goes. They are two separate jobs: an e-mail forward moves **the mail**, a URL forward moves **the visitor**. An e-mail forward opens no mailbox; the mail is passed on as it is. A URL forward points the domain at another address without hosting a site on it. ### Reference #### Listing the E-mail Forwards get/api/v1/admin/services/{id}/domain/email-forwards `Services/GetDomainEmailForwards` admin Returns the records saying where mail to the domain is passed on. Response fields data[] — 4 identitystringThe record id. When the module gives none it is derived from the prefix and the target, so changing the target changes the id. prefixstringThe local part of the source address. sourcestringThe full source address. Worked out from the prefix and the domain. targetstringWhere the mail is passed on to. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/email-forwards' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // 'source' is a worked-out field; it appears on reads ONLY, never on writes. $forwards = Api::Services()->GetDomainEmailForwards(['id' => 520])['data']; ``` Response 200 ```json { "data": [ { "identity": "3001", "prefix": "info", "source": "info@example.com", "target": "john@example.com" } ] } ``` #### Adding an E-mail Forward post/api/v1/admin/services/{id}/domain/email-forwards `Services/AddDomainEmailForward` admin 201 Passes mail arriving at one of the domain's addresses on to another. Body 2 prefixstringrequiredThe local part of the source address. The domain is appended for you. targetstringrequiredWhere to pass the mail on to. Its format is validated. Response fields data — 2 prefixstringThe source prefix of the forward added. targetstringThe target it now points to. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. email_fwd_fields_required422One of the required fields was empty. invalid_email422The target is not a valid e-mail address. 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/services/520/domain/email-forwards' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"prefix":"info","target":"john@example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ prefix: 'info', target: 'john@example.com' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'prefix' => 'info', 'target' => 'john@example.com', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The prefix is the local part ONLY: sending a full address produces 'info@example.com@example.com'. Api::Services()->AddDomainEmailForward([ 'id' => 520, 'prefix' => 'info', 'target' => 'john@example.com', ]); ``` #### Updating an E-mail Forward put/api/v1/admin/services/{id}/domain/email-forwards `Services/UpdateDomainEmailForward` admin only the target changes Changes where a forward points. The source prefix cannot be changed. Body 4 prefixstringrequiredThe source prefix of the record to change. target_newstringrequiredThe new target address. targetstringThe current target. It tells apart several records on the same prefix. identitystringThe record id from the list. Response fields data — 2 prefixstringThe source prefix of the record changed. targetstringThe target it now points to. This is the value you sent as `target_new`. Errors 7 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. email_fwd_fields_required422One of the required fields was empty. invalid_email422The target is not a valid e-mail address. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/email-forwards' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"prefix":"info","target":"john@example.com","target_new":"jane@example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ prefix: 'info', target: 'john@example.com', target_new: 'jane@example.com', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'prefix' => 'info', 'target' => 'john@example.com', 'target_new' => 'jane@example.com', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is no endpoint for changing the prefix: delete the old one and add a new one. Api::Services()->DeleteDomainEmailForward(['id' => 520, 'prefix' => 'info']); Api::Services()->AddDomainEmailForward([ 'id' => 520, 'prefix' => 'contact', 'target' => 'jane@example.com', ]); ``` #### Deleting an E-mail Forward delete/api/v1/admin/services/{id}/domain/email-forwards `Services/DeleteDomainEmailForward` admin Removes an e-mail forward. Body 3 prefixstringrequiredThe source prefix of the record to delete. targetstringThe target. It narrows the match. identitystringThe record id from the list. Response fields data — 2 deletedboolWhether the delete succeeded. prefixstringPrefix of the deleted record. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. email_fwd_fields_required422One of the required fields was empty. 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/services/520/domain/email-forwards' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"prefix":"info"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ prefix: 'info' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['prefix' => 'info']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // With several targets on one prefix, sending the prefix alone can delete ALL of them. Api::Services()->DeleteDomainEmailForward([ 'id' => 520, 'prefix' => 'info', 'target' => 'john@example.com', ]); ``` #### Reading the URL Forward get/api/v1/admin/services/{id}/domain/forwarding `Services/GetDomainForwarding` admin Returns whether the domain redirects to another address. Response fields data — 4 activeboolWhether the forward is running. methodintThe redirect type. Permanent or temporary; it defaults to permanent. protocolstringWhich protocol the target is reached over. domainstringThe address being redirected to. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete the operation. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/services/520/domain/forwarding' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/forwarding', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/forwarding'); 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::Services()->GetDomainForwarding(['id' => 520]); ``` #### Setting the URL Forward put/api/v1/admin/services/{id}/domain/forwarding `Services/SetDomainForwarding` admin takes the site away Sends visitors arriving at the domain to another address. Body 3 domainstringrequiredThe address to redirect to. protocolstringWhich protocol to reach the target over. It defaults to the unencrypted one. methodintThe redirect type. It defaults to permanent, and a permanent redirect is cached by browsers. Response fields data — 4 activeboolWhether the forward is running. methodintThe redirect type. Permanent or temporary; it defaults to permanent. protocolstringWhich protocol the target is reached over. domainstringThe address being redirected to. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. domain_fwd_url_required422The target address was empty. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/services/520/domain/forwarding' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"domain":"example.net","protocol":"https","method":301}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/forwarding', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ domain: 'example.net', protocol: 'https', method: 301, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/forwarding'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'domain' => 'example.net', 'protocol' => 'https', 'method' => 301, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A permanent redirect is cached by browsers, so pick 302 while you are still trying things out. Api::Services()->SetDomainForwarding([ 'id' => 520, 'domain' => 'example.net', 'protocol' => 'https', 'method' => 302, ]); ``` #### Removing the URL Forward delete/api/v1/admin/services/{id}/domain/forwarding `Services/CancelDomainForwarding` admin Removes the redirect and leaves the domain to its own nameservers. Response fields data — 1 cancelledboolWhether the redirect was removed. Errors 6 not_found404No such service. not_domain422The service is not a domain. no_module422No registrar module is attached. not_supported422The module does not support this operation. module_failed500The registry could not complete 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/services/520/domain/forwarding' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/services/520/domain/forwarding', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/services/520/domain/forwarding'); 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 // Removing the redirect leaves the domain to its nameservers; with no DNS record the site will not open. Api::Services()->CancelDomainForwarding(['id' => 520]); $records = Api::Services()->GetDnsRecords(['id' => 520])['data']; ``` ### Pitfalls > **The prefix is the local part only** > > Do not send the source address with the domain in it: the endpoint appends the domain to the prefix itself. Send a full address and you get a source with the domain twice, which never matches. The full address you see on a read is a worked-out field and is not used on writes. > **Sending only the prefix can delete them all** > > Only the prefix is required on the delete. With several targets on one prefix, a request sent without a target or an id can remove **all of them**, because the module does the matching. Send the target too when you mean one record. > **The prefix cannot be updated** > > The update only changes the target. Changing the source means deleting the record and adding a new one, and mail arriving in between is passed nowhere. > **A permanent redirect sticks in the browser** > > The default redirect type is permanent and browsers cache it. Point a permanent redirect at the wrong target and fix it later, and users who visited before keep going to the old target for a while. Use the temporary type while trying things out. > **Removing the forward does not bring a site back** > > With the forward gone the domain falls back to its own nameservers. If there is no record there the domain resolves nowhere, and because a forward was being used no DNS record may ever have been written. Look at the records before removing it. ### Related Articles - [DNS Management](https://dev.wisecp.com/en/dns-management) - [Domain Basics](https://dev.wisecp.com/en/domain-basics) # API / Admin API / Settings ## Site Settings https://dev.wisecp.com/en/site-settings The six endpoints behind the company details, the site systems and the search engine details. ### Overview These six endpoints hold the settings that describe the installation **itself**: who the company is, how to reach it, which systems are on, and what the site tells search engines. All three pairs work the same way: one endpoint reads every setting, the other applies the fields you send and leaves the rest alone. But **the shape changes in three places**, and those are this article's pitfalls. ### Reference #### Reading the Basic Settings get/api/v1/admin/settings/basic `Settings/GetBasicSettings` admin company per language Returns the company details, the contact channels and the contact form settings. Response fields data — 8 companyobject 3 fieldsThe company details, keyed by language. company_namestringThe company name. addressstringThe company address. informationsstringExtra information text. email_addressesstring[]The contact e-mail addresses. The list is written whole. phone_numbersstring[]The contact phone numbers. The list is written whole. social_linksobject[] 3 fieldsThe social media links. The list is written whole. iconstringThe icon class. namestringThe link name. urlstringThe link address. map_embed_codestringThe embed code for the map on the contact page. contact_formintWhether the contact form is on. contact_form_mandatory_phoneintWhether the phone is required on the form. contact_mapintWhether the map is shown. 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/settings/basic' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/basic', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/basic'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The company details are kept PER LANGUAGE: each carries its own name and address. $basic = Api::Settings()->GetBasicSettings()['data']; $name = $basic['company']['en']['company_name']; ``` Response 200 ```json { "data": { "company": { "en": { "company_name": "Example Inc.", "address": "123 Market Street", "informations": "" } }, "email_addresses": ["support@example.com"], "phone_numbers": ["+1 555 0100"], "social_links": [ { "icon": "fa-x", "name": "X", "url": "https://x.com/example" } ], "map_embed_code": "", "contact_form": 1, "contact_form_mandatory_phone": 0, "contact_map": 1 } } ``` #### Writing the Basic Settings put/api/v1/admin/settings/basic `Settings/UpdateBasicSettings` admin lists are written whole Applies the fields you send. The list fields are replaced, not merged. Body 8 companyobject 3 fieldsThe company details, keyed by language. company_namestringThe company name. addressstringThe company address. informationsstringExtra information text. email_addressesstring[]The contact e-mail addresses. The list is written whole. phone_numbersstring[]The contact phone numbers. The list is written whole. social_linksobject[] 3 fieldsThe social media links. The list is written whole. iconstringThe icon class. namestringThe link name. urlstringThe link address. map_embed_codestringThe embed code for the map on the contact page. contact_formintWhether the contact form is on. contact_form_mandatory_phoneintWhether the phone is required on the form. contact_mapintWhether the map is shown. Response fields data — 8 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/basic' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"company":{"en":{"company_name":"Example Inc.","address":"123 Market Street","informations":""}},"email_addresses":["support@example.com"],"contact_form":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/basic', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ company: { en: { company_name: 'Example Inc.', address: '123 Market Street', informations: '', }, }, contact_form: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/basic'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'company' => ['en' => ['company_name' => 'Example Inc.']], 'contact_form' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD one address send the existing list too: the list is written whole. $basic = Api::Settings()->GetBasicSettings()['data']; $emails = $basic['email_addresses']; $emails[] = 'sales@example.com'; Api::Settings()->UpdateBasicSettings(['email_addresses' => $emails]); ``` #### Reading the Advanced Settings get/api/v1/admin/settings/advanced `Settings/GetAdvancedSettings` admin Returns the settings for the basket, orders, support, limits, cache and embedded codes. Response fields data — 26 basket_systemintWhether the basket system is on. visitors_will_see_basketintWhether visitors who are not signed in see the basket. easy_orderintWhether the quick order flow is on. order_renewal_typestringHow order renewal works. ticket_systemintWhether the support ticket system is on. kbase_systemintWhether the knowledge base is on. use_couponintWhether coupons can be used. clear_end_two_zero_moneyintWhether trailing zeroes are hidden on prices. ctoc_service_transferintWhether clients can hand services to each other. voice_notificationintWhether the audible notification is on. accessibilityintWhether the accessibility aids are on. pagination_ranksintHow many rows a page of a list holds. redirect_httpsintWhether visitors are redirected to the encrypted address. redirect_wwwintWhether visitors are redirected to the prefixed address. cookie_policyobjectThe cookie policy: its status and the page attached. pg_activationobjectWhich payment methods are on. Only keys that already exist are updated. limitsobjectThe system limits, a map from key to number. product_fields_extensionsstringThe file extensions allowed on product fields. attachment_extensionsstringThe file extensions allowed on attachments. product_fields_max_file_sizeintThe maximum size for a product field file. Bytes when read, megabytes when written. attachment_max_file_sizeintThe maximum size for an attachment. Bytes when read, megabytes when written. cacheintWhether the cache is on. analytics_codestringThe analytics code. support_codestringThe support tool code. webmaster_tools_codestringThe search engine verification code. external_embed_codestringExternal code added to the pages. 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/settings/advanced' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/advanced', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/advanced'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // File sizes come back in BYTES but are written in megabytes; convert the unit. $adv = Api::Settings()->GetAdvancedSettings()['data']; $mb = (int) round($adv['attachment_max_file_size'] / 1048576); ``` #### Writing the Advanced Settings put/api/v1/admin/settings/advanced `Settings/UpdateAdvancedSettings` admin the size unit changes Applies the fields you send and leaves the rest as they are. Body 26 basket_systemintWhether the basket system is on. visitors_will_see_basketintWhether visitors who are not signed in see the basket. easy_orderintWhether the quick order flow is on. order_renewal_typestringHow order renewal works. ticket_systemintWhether the support ticket system is on. kbase_systemintWhether the knowledge base is on. use_couponintWhether coupons can be used. clear_end_two_zero_moneyintWhether trailing zeroes are hidden on prices. ctoc_service_transferintWhether clients can hand services to each other. voice_notificationintWhether the audible notification is on. accessibilityintWhether the accessibility aids are on. pagination_ranksintHow many rows a page of a list holds. redirect_httpsintWhether visitors are redirected to the encrypted address. redirect_wwwintWhether visitors are redirected to the prefixed address. cookie_policyobjectThe cookie policy: its status and the page attached. pg_activationobjectWhich payment methods are on. Only keys that already exist are updated. limitsobjectThe system limits, a map from key to number. product_fields_extensionsstringThe file extensions allowed on product fields. attachment_extensionsstringThe file extensions allowed on attachments. product_fields_max_file_sizeintThe maximum size for a product field file. Bytes when read, megabytes when written. attachment_max_file_sizeintThe maximum size for an attachment. Bytes when read, megabytes when written. cacheintWhether the cache is on. analytics_codestringThe analytics code. support_codestringThe support tool code. webmaster_tools_codestringThe search engine verification code. external_embed_codestringExternal code added to the pages. Response fields data — 26 dataobjectThe settings as they now stand. Same shape as the read endpoint, so the file sizes come back in bytes. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/advanced' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"basket_system":1,"use_coupon":1,"cookie_policy":{"status":1,"page":0},"cache":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/advanced', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ basket_system: 1, use_coupon: 1, cache: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/advanced'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'basket_system' => 1, 'use_coupon' => 1, 'cache' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The size is written in MEGABYTES; do not send back the byte value you read. Api::Settings()->UpdateAdvancedSettings([ 'attachment_max_file_size' => 10, ]); ``` #### Reading the SEO Settings get/api/v1/admin/settings/seo `Settings/GetSeoSettings` admin per language Returns what the home page tells search engines. Response fields data object 3 fieldsThe meta details, keyed by language. titlestringThe page title. keywordsstringThe keywords. descriptionstringThe page description. 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/settings/seo' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/seo', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/seo'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The response is keyed by language; the write is keyed by FIELD. The two shapes differ. $seo = Api::Settings()->GetSeoSettings()['data']; $en = $seo['en']['title']; ``` #### Writing the SEO Settings put/api/v1/admin/settings/seo `Settings/UpdateSeoSettings` admin the shape is inverted Writes the home page meta details. The body is keyed by field, not by language. Body 3 titleobjectThe titles, keyed by language. keywordsobjectThe keywords, keyed by language. descriptionobjectThe descriptions, keyed by language. Response fields data dataobjectThe settings as they now stand. Same shape as the read endpoint, so it comes back keyed by language, not by field. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/seo' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":{"en":"Home"},"description":{"en":"Welcome."}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/seo', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: { en: 'Home' }, description: { en: 'Welcome.' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/seo'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'title' => ['en' => 'Home'], 'description' => ['en' => 'Welcome.'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Reading is keyed by language, writing by field: you have to invert the shape. $seo = Api::Settings()->GetSeoSettings()['data']; $titles = []; foreach ($seo as $lang => $meta) $titles[$lang] = $meta['title']; $titles['en'] = 'Home'; Api::Settings()->UpdateSeoSettings(['title' => $titles]); ``` ### Pitfalls > **The SEO read and write are keyed the other way** > > The read returns objects grouped by language code; the write expects objects grouped by **field name**. Sending back what you read does not work, you have to invert the shape. The other two pairs have no such difference. > **File sizes read in bytes and write in megabytes** > > On the advanced settings the maximum file size fields come back in **bytes** but are written in **megabytes**. Sending the value you read straight back raises the limit a millionfold, and no error says a word about it. > **List fields are not merged** > > The contact e-mails, the phone numbers and the social links are **replaced** by the list you send. Adding one address means reading the current list and appending to it, or the rest are deleted. > **The company details are per language** > > The company name, the address and the extra information are kept separately for each language. Writing to one language leaves the others as they were, so on a multilingual site a visitor can see a different company name depending on their language. > **New keys are not added to the payment list** > > In the payment activation setting only keys that **already exist** are updated. Trying to switch on a method that is not installed is ignored without a word; the method has to be installed first. ### Related Articles - [Client Registration](https://dev.wisecp.com/en/client-registration) - [Localisation and URLs](https://dev.wisecp.com/en/localisation-and-urls) - [Security Settings](https://dev.wisecp.com/en/security-settings) ## Client Registration https://dev.wisecp.com/en/client-registration The seven endpoints deciding what the registration form asks and which extra fields it carries. ### Overview These seven endpoints decide **what a client is asked** when they register. There are two layers: the fields the system already has, and the custom ones you add. On the built-in fields three things are set separately for each question: **whether it is asked**, **whether it is required**, and **whether the client can change it later**. They are independent keys, so making a field required does not start asking for it. Custom fields **belong to a language**: the same field is opened as a separate record in each language, with an id of its own. ### Reference #### Reading the Registration Settings get/api/v1/admin/settings/client `Settings/GetClientSettings` admin all zero or one Returns which fields the sign-up and sign-in flow asks for, and which are required. Response fields data — 27 sign_in_statusintWhether signing in is open. sign_up_statusintWhether registration is open. sign_up_email_verifyintWhether the e-mail has to be verified. smart_namingintWhether names are tidied automatically. crtacwshopintWhether an account is opened automatically during checkout. sign_up_gsm_statusintWhether the mobile number is asked for. sign_up_gsm_requiredintWhether the mobile number is required. sign_up_gsm_checkerintWhether the mobile number is format-checked. sign_up_gsm_verifyintWhether the mobile number has to be verified. sign_up_landline_phone_statusintWhether the landline is asked for. sign_up_landline_phone_requiredintWhether the landline is required. sign_up_landline_phone_checkerintWhether the landline is format-checked. sign_up_kind_statusintWhether the personal or business choice is asked for. sign_up_identity_statusintWhether the identity number is asked for. sign_up_identity_requiredintWhether the identity number is required. sign_birthday_statusintWhether the birth date is asked for. sign_birthday_requiredintWhether the birth date is required. sign_birthday_adult_verifyintWhether an age limit is enforced. security_question_statusintWhether the security question is asked. security_question_requiredintWhether the security question is required. sign_editable_full_nameintWhether the client can change their name later. sign_editable_emailintWhether the client can change their e-mail. sign_editable_gsmintWhether the client can change their mobile number. sign_editable_landline_phoneintWhether the client can change their landline. sign_editable_identityintWhether the client can change their identity number. sign_editable_kindintWhether the client can change their account kind. sign_editable_birthdayintWhether the client can change their birth date. 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/settings/client' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/client', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/client'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Asking for a field and REQUIRING it are separate keys: read them together. $cfg = Api::Settings()->GetClientSettings()['data']; $asksGsm = $cfg['sign_up_gsm_status'] === 1; $needsIt = $asksGsm && $cfg['sign_up_gsm_required'] === 1; ``` #### Writing the Registration Settings put/api/v1/admin/settings/client `Settings/UpdateClientSettings` admin affects the storefront Applies the keys you send and leaves the rest as they are. Body 27 sign_in_statusintWhether signing in is open. sign_up_statusintWhether registration is open. sign_up_email_verifyintWhether the e-mail has to be verified. smart_namingintWhether names are tidied automatically. crtacwshopintWhether an account is opened automatically during checkout. sign_up_gsm_statusintWhether the mobile number is asked for. sign_up_gsm_requiredintWhether the mobile number is required. sign_up_gsm_checkerintWhether the mobile number is format-checked. sign_up_gsm_verifyintWhether the mobile number has to be verified. sign_up_landline_phone_statusintWhether the landline is asked for. sign_up_landline_phone_requiredintWhether the landline is required. sign_up_landline_phone_checkerintWhether the landline is format-checked. sign_up_kind_statusintWhether the personal or business choice is asked for. sign_up_identity_statusintWhether the identity number is asked for. sign_up_identity_requiredintWhether the identity number is required. sign_birthday_statusintWhether the birth date is asked for. sign_birthday_requiredintWhether the birth date is required. sign_birthday_adult_verifyintWhether an age limit is enforced. security_question_statusintWhether the security question is asked. security_question_requiredintWhether the security question is required. sign_editable_full_nameintWhether the client can change their name later. sign_editable_emailintWhether the client can change their e-mail. sign_editable_gsmintWhether the client can change their mobile number. sign_editable_landline_phoneintWhether the client can change their landline. sign_editable_identityintWhether the client can change their identity number. sign_editable_kindintWhether the client can change their account kind. sign_editable_birthdayintWhether the client can change their birth date. Response fields data — 27 *intThe whole flag set as it stands after the write, in the same schema as reading the settings. Each one comes back as 0 or 1. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/client' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"sign_up_status":1,"sign_up_email_verify":1,"security_question_status":0}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/client', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sign_up_status: 1, sign_up_email_verify: 1, security_question_status: 0, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/client'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'sign_up_status' => 1, 'sign_up_email_verify' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Making a field required needs it to be ASKED FOR; switch both on together. Api::Settings()->UpdateClientSettings([ 'sign_up_gsm_status' => 1, 'sign_up_gsm_required' => 1, ]); ``` #### Listing the Custom Fields get/api/v1/admin/settings/client-fields `Settings/GetClientFields` admin separate per language Returns the extra fields added to the registration form. They are kept per language. Query parameters 1 langstringWhich language's fields. Left out, the current language is used. Response fields data[] — 12 idintId of the field. langstringThe language the field belongs to. typestringThe field type. namestringThe label shown to the client. statusstring`active` or `inactive`. requiredboolWhether it has to be filled. uneditableboolWhether the client can change it later. client_hiddenboolWhether it is hidden from the client. While hidden it appears in neither the account, the registration form, the invoice, nor the client API. invoiceboolWhether it shows on the invoice. sign_formboolWhether it is asked on the registration form. optionsstringThe values offered on the choice types. rankintIts position on the form. Errors 2 invalid_lang422The language code is not valid. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -G 'https://panel.example.com/api/v1/admin/settings/client-fields' \ -H "Authorization: Bearer $API_KEY" \ -d lang=en ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/settings/client-fields'); url.searchParams.set('lang', 'en'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/settings/client-fields?' . http_build_query(['lang' => 'en']); $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 // The fields BELONG to a language: a separate list, with separate ids, for each. $en = Api::Settings()->GetClientFields([], ['lang' => 'en'])['data']; $tr = Api::Settings()->GetClientFields([], ['lang' => 'tr'])['data']; ``` Response 200 ```json { "data": [ { "id": 5, "lang": "en", "type": "text", "name": "VAT Number", "status": "active", "required": false, "uneditable": false, "client_hidden": false, "invoice": true, "sign_form": true, "options": "", "rank": 0 } ], "meta": { "lang": "en", "total": 1 } } ``` #### Adding a Custom Field post/api/v1/admin/settings/client-fields `Settings/CreateClientField` admin 201 Adds a new field to the registration form. Body 10 namestringrequiredThe label to show the client. langstringThe language to open the field in. typestringThe field type. It defaults to text. statusintSwitches the field on or off. It defaults to on. requiredintMakes the field required. uneditableintStops the client changing it later. client_hiddenintHides the field from the client. Only admins see it. invoiceintShows the field on the invoice. sign_formintAsks the field on the registration form. optionsstringThe values to offer on the choice types. Response fields data — 12 *objectThe field that was created, in the same schema as a list item. The answer carries 201. Errors 4 invalid_lang422The language code is not valid. name_required422The field name was empty. create_failed422The field could not be added. 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/settings/client-fields' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"lang":"en","name":"VAT Number","type":"text","required":1,"invoice":1,"sign_form":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/client-fields', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ lang: 'en', name: 'VAT Number', type: 'text', required: 1, invoice: 1, sign_form: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/client-fields'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'lang' => 'en', 'name' => 'VAT Number', 'required' => 1, 'sign_form' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The field opens in the GIVEN language only; you have to add it for each one. foreach (['en', 'tr'] as $lang) { Api::Settings()->CreateClientField([ 'lang' => $lang, 'name' => $lang === 'tr' ? 'Vergi No' : 'VAT Number', 'required' => 1, 'sign_form' => 1, ]); } ``` #### Ordering the Fields put/api/v1/admin/settings/client-fields/order `Settings/ReorderClientFields` admin Writes the order the fields appear in on the form, for one language. Body 2 langstringrequiredWhich language's fields to order. orderint[]requiredThe field ids, in the order you want. Response fields data[] — 12 *objectThe reordered list, in the same schema as listing the fields. rankintRewritten from the order you send. An id you leave out keeps its old rank, which can leave gaps; send the full order for a clean sequence. Errors 2 invalid_lang422The language code is not valid. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/client-fields/order' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"lang":"en","order":[7,5,9]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/client-fields/order', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ lang: 'en', order: [7, 5, 9] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/client-fields/order'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['lang' => 'en', 'order' => [7, 5, 9]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The order is PER LANGUAGE: each one has to be ordered separately, with its own ids. $ids = array_column( Api::Settings()->GetClientFields([], ['lang' => 'en'])['data'], 'id', ); Api::Settings()->ReorderClientFields(['lang' => 'en', 'order' => array_reverse($ids)]); ``` #### Updating a Custom Field patch/api/v1/admin/settings/client-fields/{id} `Settings/UpdateClientField` admin Changes an existing field. The name cannot be emptied. Body 10 namestringrequiredThe label to show the client. langstringThe language to open the field in. typestringThe field type. It defaults to text. statusintSwitches the field on or off. It defaults to on. requiredintMakes the field required. uneditableintStops the client changing it later. client_hiddenintHides the field from the client. Only admins see it. invoiceintShows the field on the invoice. sign_formintAsks the field on the registration form. optionsstringThe values to offer on the choice types. Response fields data — 11 *objectThe updated field, in the shape it was stored in. Errors 3 not_found404No such field. name_required422The name you sent was empty. 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/settings/client-fields/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Tax ID","required":0}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/client-fields/5', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Tax ID', required: 0 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/client-fields/5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['name' => 'Tax ID', 'required' => 0]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Hiding and requiring CONTRADICT each other: a client cannot fill a field they never see. Api::Settings()->UpdateClientField([ 'id' => 5, 'client_hidden' => 1, 'required' => 0, ]); ``` #### Deleting a Custom Field delete/api/v1/admin/settings/client-fields/{id} `Settings/DeleteClientField` admin the answers go too Removes the field. The answers clients gave to it go as well. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted field. Errors 2 not_found404No such field. 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/settings/client-fields/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/client-fields/5', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/client-fields/5'); 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 // Switching it OFF instead of deleting keeps the answers: the field leaves the form, the data stays. Api::Settings()->UpdateClientField(['id' => 5, 'status' => 0]); ``` ### Pitfalls > **Requiring does not start asking** > > Each built-in field has separate keys for **being asked** and for **being required**. Switching on the requirement alone does not put the field on the form; the client never sees it and the setting quietly does nothing. Switch both on together. > **A custom field opens in one language only** > > Adding a custom field creates it in the language you gave and nowhere else. With two languages on the site, clients on the second one **never see** that field. You have to add it for each language, and write the order for each separately. > **A hidden field cannot be required** > > Hiding a field from the client removes it from the account, the registration form, the invoice and the client API. Leaving it required at the same time makes registration impossible: a client cannot fill a field they never see. Clear the requirement when you hide it. > **Deleting a field deletes the answers** > > Deleting a custom field does not merely change the form: **every answer clients gave** to it goes with it. To stop asking without losing them, switch the field off instead; the form is tidied and the data collected stays. > **Closing registration does not close sign-in** > > Registration and sign-in are separate keys. Closing registration does not stop existing clients signing in, so shutting the system for maintenance means closing both. The reverse also holds: closing sign-in while registration stays open produces clients who can open an account but not get into it. ### Related Articles - [Site Settings](https://dev.wisecp.com/en/site-settings) - [Client Endpoints](https://dev.wisecp.com/en/client-endpoints) - [Authentication Settings](https://dev.wisecp.com/en/authentication-settings) ## API Credentials https://dev.wisecp.com/en/api-credentials The seven endpoints that produce API access keys, limit them and watch their requests. ### Overview These seven endpoints run **who can reach the API**. They produce keys, set what those keys allow, limit where they work from, and show who called what. The key itself is **stored as a digest**. The full value appears once, on the creation response, and no endpoint returns it afterwards. A lost key is not recovered, it is replaced. Permissions are written as scopes: a single operation, or a wildcard for a whole resource. Giving a key only the scopes it truly needs is the one real piece of advice in this article. Every key belongs to the staff account that made it. A key can never do more than that account may do in the panel. The check runs on **every request**, so narrowing someone's privileges narrows their keys at the same moment. ### Reference #### Listing the Keys get/api/v1/admin/settings/api-credentials `Settings/GetApiCredentials` admin returned masked Returns the keys that grant access to this API. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the keys. Response fields data[] — 11 idintId of the credential. namestringThe key's name. For you only, so you remember where it is used. typestringWho made it: `admin` for staff, `client` for a customer. Customer keys are managed from the client area. ownerobjectThe account the key belongs to, as `id` and `name`. Its privileges cap what the key can reach. token_previewstringThe key, masked. The full value **cannot** be recovered; only a digest is stored. permissionsstring[]The scopes allowed. A single operation, or a wildcard for a whole resource. ipsstring[]The addresses allowed. Empty means it works from anywhere. rate_limitintThe per-minute request limit for this key. Zero uses the general default. created_atstring | nullWhen it was created. updated_atstring | nullWhen it last changed. last_accessstring | nullWhen it was last used. Empty means it never has been. Meta 4 totalintHow many keys there are. 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 'https://panel.example.com/api/v1/admin/settings/api-credentials' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Keys never used are candidates for cleanup: an empty last-access means nobody has touched it. $keys = Api::Settings()->GetApiCredentials()['data']; $unused = array_filter($keys, fn (array $k): bool => $k['last_access'] === null); ``` #### Creating a Key post/api/v1/admin/settings/api-credentials `Settings/CreateApiCredential` admin the key is shown once Produces a new access key. The full value comes back **on this response only**. Body 4 namestringrequiredThe key's name. permissionsstring[]requiredThe scopes to allow. At least one is needed; a wildcard opens every operation on a resource. ipsstring[] | stringThe addresses to allow. Either an array or text, one per line. rate_limitintThe per-minute request limit. Zero goes back to the general default. Response fields data — 10 idintId of the credential. namestringThe key's name. For you only, so you remember where it is used. token_previewstringThe key, masked. The full value **cannot** be recovered; only a digest is stored. permissionsstring[]The scopes allowed. A single operation, or a wildcard for a whole resource. ipsstring[]The addresses allowed. Empty means it works from anywhere. rate_limitintThe per-minute request limit for this key. Zero uses the general default. created_atstring | nullWhen it was created. updated_atstring | nullWhen it last changed. last_accessstring | nullWhen it was last used. Empty means it never has been. api_keystringThe key in full. It appears here and nowhere else; lose it and the only way back is a new key. Errors 4 name_required422The name was empty. permissions_required422No permission was given. permissions_exceed_owner422None of the scopes you asked for are covered by the owning account's privileges. 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/settings/api-credentials' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Integration","permissions":["Clients/*","Services/GetServices"],"ips":[],"rate_limit":300}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Integration', permissions: ['Clients/*', 'Services/GetServices'], rate_limit: 300, }), }); const body = await res.json(); // Store it now: this is the only time it is shown. const newKey = body.data.api_key; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Integration', 'permissions' => ['Clients/*'], 'rate_limit' => 300, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The full key comes back HERE only; miss it now and you can never read it again. $cred = Api::Settings()->CreateApiCredential([ 'name' => 'Integration', 'permissions' => ['Clients/*'], ])['data']; $secret = $cred['api_key'] ?? null; // absent on every later read ``` Response 201 422 ```json { "data": { "id": 13, "name": "Integration", "token_preview": "wak_a1b2c3d4e••••••••", "permissions": ["Clients/*"], "ips": [], "rate_limit": 300, "api_key": "wak_a1b2c3d4e5f6..." } } ``` ```json { "error": { "code": "permissions_required", "message": "At least one permission is required." } } ``` #### Key Detail get/api/v1/admin/settings/api-credentials/{cid} `Settings/GetApiCredential` admin Returns one key. The schema matches a list item and the key is still masked. Response fields data — 11 idintId of the credential. namestringThe key's name. For you only, so you remember where it is used. typestringWho made it: `admin` for staff, `client` for a customer. Customer keys are managed from the client area. ownerobjectThe account the key belongs to, as `id` and `name`. Its privileges cap what the key can reach. token_previewstringThe key, masked. The full value **cannot** be recovered; only a digest is stored. permissionsstring[]The scopes allowed. A single operation, or a wildcard for a whole resource. ipsstring[]The addresses allowed. Empty means it works from anywhere. rate_limitintThe per-minute request limit for this key. Zero uses the general default. created_atstring | nullWhen it was created. updated_atstring | nullWhen it last changed. last_accessstring | nullWhen it was last used. Empty means it never has been. Errors 2 not_found404No such credential. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/settings/api-credentials/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials/12', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials/12'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The detail does NOT give the full key either: a lost key is not recovered, it is replaced. $cred = Api::Settings()->GetApiCredential(['cid' => 12])['data']; ``` #### Updating a Key patch/api/v1/admin/settings/api-credentials/{cid} `Settings/UpdateApiCredential` admin permissions are written whole Applies the fields you send and leaves the rest as they were: the name, the permissions, the address list and the request limit. The key itself is untouched. Body 4 namestringThe key's name. Leave it out and the current name stays. permissionsstring[]The scopes to allow. Leave them out and the current list stays; sending them replaces the set whole, and an empty list is refused. A wildcard opens every operation on a resource. ipsstring[] | stringThe addresses to allow. Either an array or text, one per line. rate_limitintThe per-minute request limit. Zero goes back to the general default. Response fields data — 11 dataobjectThe credential as it now stands. Same shape as an item in the key list; the key itself is unchanged and still masked. Errors 7 not_found404No such credential. not_credential_owner403The key belongs to another staff account. A key owned by the root privilege group reaches every credential. client_credential422The key belongs to a customer and is managed from the client area. name_required422The name you sent was empty. permissions_required422The permission list you sent was empty. permissions_exceed_owner422None of the scopes you asked for are covered by the owning account's privileges. 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/settings/api-credentials/12' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"permissions":["Clients/*","Invoices/*"],"ips":["203.0.113.10"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials/12', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ permissions: ['Clients/*', 'Invoices/*'], ips: ['203.0.113.10'], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials/12'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'permissions' => ['Clients/*', 'Invoices/*'], 'ips' => ['203.0.113.10'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD a permission send the existing list too: the set you send replaces the old one. $cred = Api::Settings()->GetApiCredential(['cid' => 12])['data']; $scope = $cred['permissions']; $scope[] = 'Invoices/*'; Api::Settings()->UpdateApiCredential(['cid' => 12, 'permissions' => $scope]); ``` #### Deleting a Key delete/api/v1/admin/settings/api-credentials/{cid} `Settings/DeleteApiCredential` admin access stops at once Revokes the key. Every request using it starts being refused immediately. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted key. Errors 4 not_found404No such credential. not_credential_owner403The key belongs to another staff account. A key owned by the root privilege group reaches every credential. client_credential422The key belongs to a customer and is managed from the client area. 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/settings/api-credentials/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-credentials/12', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-credentials/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 // Deleting your own key cuts THIS session's access too; take care not to lock yourself out. Api::Settings()->DeleteApiCredential(['cid' => 12]); ``` #### Listing the Request Log get/api/v1/admin/settings/api-logs `Settings/GetApiLogs` admin paged Returns the record of requests that reached the API. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 6 idintId of the record. credentialobjectThe key that made the request: its id and name. methodstringThe request method. actionstringThe operation called. ipstringThe address the request came from. created_atstring | nullWhen the request arrived. 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/settings/api-logs' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-logs', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-logs'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The record does not keep the DATA sent: you see which key called what, not what it sent. $logs = Api::Settings()->GetApiLogs()['data']; ``` #### Clearing the Request Log delete/api/v1/admin/settings/api-logs `Settings/ClearApiLogs` admin takes no date Deletes every API request record. Response fields data — 1 clearedboolWhether the clear ran. Errors 1 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/settings/api-logs' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/api-logs', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/api-logs'); 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 // This endpoint takes NO date: every record goes, there is no selective clear. Api::Settings()->ClearApiLogs(); ``` ### Pitfalls > **A key follows its owner** > > Scopes are intersected with the owner's privileges on every request. Take a permission away from that staff account and the key loses it too, with no edit. If the account is deleted or blocked, the key stops working and returns `owner_inactive`. A key left with nothing its owner may still do returns `owner_scope_revoked`. > **The key is shown once** > > The full key comes back **on the creation response only**. The list and detail endpoints give it masked. Since a digest is what gets stored, the server does not know the raw value either. Miss that response and the only move left is to delete the key and make a new one. > **The permission list is written whole** > > The scope list you send on an update **replaces** the old one. Adding a single permission means reading the current list and appending to it. Otherwise the key quietly loses its access and your integration stops working. > **Deleting your own key cuts you off too** > > The delete takes effect at once, and that includes **the key you are using**. When tidying the keys of an integration, watch which one your requests carry. Lock yourself out and the only way back is producing a new key from the panel. > **An empty address list opens everywhere** > > A key with an empty address list works **from anywhere in the world**. On a server-to-server integration, naming that one address stops a leaked key being used at all. That is the cheapest protection after narrowing the scope. > **The request log does not keep what was sent** > > The log shows which key called which operation from which address; it **keeps neither the body nor the response**. Investigating what a request changed needs the affected record's own history, not this. The clear takes no date either: all of it or none. ### Related Articles - [Security Settings](https://dev.wisecp.com/en/security-settings) - [Site Settings](https://dev.wisecp.com/en/site-settings) ## Localisation and URLs https://dev.wisecp.com/en/localisation-and-urls The five endpoints behind the installation's language, currency, time zone and address structure. ### Overview These five endpoints decide **which language, which currency and which addresses** the installation runs on. They look like small settings, but three of them reach across the whole installation, and this article is largely about those. The time zone and the date format change safely. The **locale, the currency, the country** and the **address mode** write files, flip tables or create folders; change those on a live installation with care. ### Reference #### Reading the Localisation get/api/v1/admin/settings/localisation `Settings/GetLocalisationSettings` admin options included Returns the installation's language, currency, country and time zone. Response fields data — 8 languagestringThe default language. localstringThe default locale. It decides the number and date formats. currencyintId of the default currency. countrystringThe default country. timezonestringThe time zone. date_formatstringHow dates are displayed. ip_modulestringThe module that resolves a visitor's location. availableobjectThe time zones you can pick and the location modules installed. Present on the read only. 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/settings/localisation' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/localisation', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/localisation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The values you can pick come back in the SAME response; no separate reference call is needed. $loc = Api::Settings()->GetLocalisationSettings()['data']; $zones = $loc['available']['timezones']; ``` #### Writing the Localisation put/api/v1/admin/settings/localisation `Settings/UpdateLocalisationSettings` admin high risk Applies the settings you send. Three of the fields have side effects across the installation. Body 8 languagestringThe default language. localstringThe default locale. Changing it rewrites the language package file. currencyintId of the default currency. Changing it flips the currency table and starts an exchange rate sync. countrystringThe default country. Moving away from one particular country switches off the identity number fields. timezonestringThe time zone. date_formatstringHow dates are displayed. ip_modulestringThe location module. It has to be installed. ip_module_configobjectThe location module's settings. Sent together with the module only. Response fields data — 8 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/localisation' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"timezone":"America/New_York","date_format":"Y-m-d"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/localisation', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ timezone: 'America/New_York', date_format: 'Y-m-d', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/localisation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'timezone' => 'America/New_York', 'date_format' => 'Y-m-d', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The time zone and the date format are the SAFE fields; the locale, currency and country are not. Api::Settings()->UpdateLocalisationSettings([ 'timezone' => 'America/New_York', 'date_format' => 'Y-m-d', ]); ``` #### Reading the Address Settings get/api/v1/admin/settings/url `Settings/GetUrlSettings` admin Returns how addresses are formed and the path names in each language. Response fields data — 2 rich_urlstringThe address mode. One of three: clean addresses, plain addresses, and a middle mode. routesobjectThe path names, keyed by language. In each, a map from route key to path segment. 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/settings/url' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/url', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/url'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The path names are per language: the same page opens on a different address in each. $url = Api::Settings()->GetUrlSettings()['data']; $en = $url['routes']['en']['products']; ``` #### Changing the Address Mode put/api/v1/admin/settings/url `Settings/UpdateUrlSettings` admin affects panel access Switches addresses between clean and plain, testing the server's support live. Body 1 rich_urlstringrequiredThe new address mode. Sending the value it already has does nothing, and is safe. Response fields data — 2 dataobjectThe settings as they now stand. Same shape as the read endpoint. A reply means the live check passed and the new mode is in force; on failure the old one stays. Errors 2 mod_rewrite_failed422The server does not support clean addresses. The check runs live, and on failure the mode is left alone. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/url' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rich_url":"on"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/url', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rich_url: 'on' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/url'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['rich_url' => 'on']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint creates or removes the ADMIN FOLDER: a wrong mode can break the panel address. // Read the current mode first; sending the same value is harmless. $current = Api::Settings()->GetUrlSettings()['data']['rich_url']; if ($current !== 'on') { Api::Settings()->UpdateUrlSettings(['rich_url' => 'on']); } ``` #### Writing the Path Names put/api/v1/admin/settings/url/views `Settings/UpdateUrlViews` admin old addresses break Writes the path names that appear in page addresses, per language. Body 1 routesobjectrequiredThe path names, keyed by language. Only route keys that already exist are written; an unknown key is ignored. Response fields data — 2 dataobjectThe address settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/url/views' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"routes":{"en":{"products":"products","cart":"cart"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/url/views', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ routes: { en: { products: 'products', cart: 'cart' } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/url/views'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'routes' => ['en' => ['products' => 'products']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing a path name breaks the OLD address, the one in search engines and in links people saved. // Read the keys from the current structure; an invented key is skipped without a word. $routes = Api::Settings()->GetUrlSettings()['data']['routes']; $routes['en']['products'] = 'shop'; Api::Settings()->UpdateUrlViews(['routes' => $routes]); ``` ### Pitfalls > **The address mode rebuilds the panel folder** > > Changing the mode **tests live** whether the server supports clean addresses, and creates or removes the admin folder. A wrong mode can leave the panel unreachable. Sending the value it already has does nothing, so reading the current mode first is the safest route. > **A currency change flips the table** > > Changing the default currency does not merely update a preference: the local flag moves in the currency table and an **exchange rate sync starts**. How prices look changes across the installation. Do not do it during trading hours. > **The locale rewrites the language file** > > Changing the default locale causes the language package file to be **rewritten**. That is a different kind of act from saving a setting: a file on disk changes. The locale is best set once at installation and left alone. > **The country can switch off the identity fields** > > Moving the default country away from one particular country **switches off the identity number fields** on the registration form. It is a silent side effect: the form changes though you touched nothing in the client registration settings. > **Changing a path name breaks the old address** > > Change a page's path name and the old address **stops working**: search engine entries and links clients saved go nowhere, and no redirect is set up. An unrecognised route key is also ignored without a word, so a path you thought you wrote may never have been written. Confirm the result through the read endpoint. ### Related Articles - [Site Settings](https://dev.wisecp.com/en/site-settings) - [Client Registration](https://dev.wisecp.com/en/client-registration) ## Security Settings https://dev.wisecp.com/en/security-settings The five endpoints behind panel access, the password rules and the banned lists. ### Overview These five endpoints hold the installation's **front door**. Where the admin panel is, who can reach it, how strong passwords must be, and who cannot register at all. Two of them can **lock you out**: the folder name changes the panel's address, and the address restriction narrows who gets in. One more cannot be undone and touches everyone. This article is largely about those three. ### Reference #### Reading the General Settings get/api/v1/admin/settings/security/general `Settings/GetSecurityGeneral` admin Returns where the panel is, who can reach it, and the password rules. Response fields data — 7 admin_folderstringThe folder the admin panel sits in. It is the secret part of the address and behaves like a credential. admin_ip_restrictionstringThe addresses that may reach the panel. Empty means from anywhere. password_lengthintThe shortest a password may be. password_charactersstring[]The character classes a password has to contain. password_reset_cycleintHow many days before a password has to be renewed. Zero means never. clickjacking_protectionintStops the panel being embedded in another page. clickjacking_protection_whiteliststringThe addresses allowed to embed it. 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/settings/security/general' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/general', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/general'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The admin folder name is a kind of SECRET: keep it out of your own logs and away from outsiders. $sec = Api::Settings()->GetSecurityGeneral()['data']; ``` #### Writing the General Settings put/api/v1/admin/settings/security/general `Settings/UpdateSecurityGeneral` admin lock-out risk Applies the settings you send. Changing the folder name changes the panel's address. Body 7 admin_folderstringThe admin panel folder. It cannot be empty, cannot be a predictable name, and cannot clash with an existing file or folder. admin_ip_restrictionstringThe addresses that may reach the panel. password_lengthintThe minimum password length. password_charactersstring[] | stringThe required character classes. The list is written whole. password_reset_cycleintHow often passwords are renewed, in days. clickjacking_protectionintTurns on protection against embedding. clickjacking_protection_whiteliststringThe addresses allowed to embed it. Response fields data — 7 dataobjectThe settings as they now stand. Same shape as the read endpoint. ——When the folder name changed, the value that comes back is the **new** folder. Read the panel address from here rather than assuming the rename kept the name you sent. Errors 3 admin_folder_invalid422The folder name was refused. admin_folder_exists422A file or folder by that name already exists. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/general' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"password_length":10,"password_reset_cycle":90,"clickjacking_protection":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/general', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password_length: 10, password_reset_cycle: 90, clickjacking_protection: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/general'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'password_length' => 10, 'password_reset_cycle' => 90, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing the folder changes the panel's ADDRESS; do not send it without noting the new one. // The address restriction locks too: keep your own address on the list. Api::Settings()->UpdateSecurityGeneral([ 'admin_ip_restriction' => $myAddress, 'password_length' => 10, ]); ``` #### Reading the Banned Lists get/api/v1/admin/settings/security/prohibited `Settings/GetProhibited` admin Returns the domains, addresses, numbers and words refused at registration. Response fields data — 5 block_temporary_emailintBlocks registration from disposable e-mail services. domain_liststring[]The banned domains. email_liststring[]The banned e-mail addresses. gsm_liststring[]The banned phone numbers. word_liststring[]The banned words. 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/settings/security/prohibited' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/prohibited', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/prohibited'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $prohibited = Api::Settings()->GetProhibited()['data']; ``` #### Writing the Banned Lists put/api/v1/admin/settings/security/prohibited `Settings/UpdateProhibited` admin lists are written whole Writes the banned lists. The list you send replaces the old one. Body 5 block_temporary_emailintBlocks registration from disposable e-mail services. domain_liststring[] | stringThe banned domains. An array or text, one per line. email_liststring[] | stringThe banned e-mail addresses. gsm_liststring[] | stringThe banned phone numbers. word_liststring[] | stringThe banned words. Response fields data — 5 dataobjectThe lists as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/prohibited' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"block_temporary_email":1,"domain_list":["example-spam.com"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/prohibited', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ block_temporary_email: 1, domain_list: ['example-spam.com'], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/prohibited'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'block_temporary_email' => 1, 'domain_list' => ['example-spam.com'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD one domain send the existing list too, or the earlier ones are deleted. $lists = Api::Settings()->GetProhibited()['data']; $domains = $lists['domain_list']; $domains[] = 'example-spam.com'; Api::Settings()->UpdateProhibited(['domain_list' => $domains]); ``` #### Forcing a Password Reset post/api/v1/admin/settings/security/force-reset-password `Settings/ForceResetPassword` admin affects everyone Forces every user to renew their password on their next sign-in. Body — ——No body is needed, send an empty one. The reach cannot be narrowed: every account is covered. Response fields data — 1 appliedboolWhether it was applied. It does not say how many users were affected. Errors 2 force_reset_failed422It could not be applied. 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/settings/security/force-reset-password' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/force-reset-password', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/force-reset-password'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This request touches EVERY user and CANNOT be undone: there is no body and no confirmation step. // Run it only with a real reason behind it, such as after a breach. Api::Settings()->ForceResetPassword(); ``` ### Pitfalls > **The folder name is the panel's address** > > Changing the admin folder **renames the directory**: the panel is no longer at its old address. Do not send this request without writing the new name down somewhere. The name cannot be empty, cannot be predictable and cannot clash with an existing file. Those raise errors. A **wrong yet valid** name raises none and leaves the panel unfindable. > **The address restriction covers you too** > > Restricting panel access by address **makes no exceptions**. Leave your own address off the list and the next attempt shuts you out as well. On a connection whose address changes, not using this setting is the safer choice. > **The forced reset touches everyone** > > This endpoint takes no body, no filter and has no confirmation step. The moment you call it **every user** must renew their password at their next sign-in, and it cannot be undone. The response does not even say how many were affected. Do not run it without a real reason. > **The banned lists are written whole** > > The list you send **replaces** the old one. Adding a single domain means reading the current list and appending to it, or every ban you built up disappears in one request, with no error to say so. > **The folder name is a secret** > > The admin folder name is the secret part of the address and behaves like a credential. Do not write the value you read from here into your own records or show it in error messages. The system masks it even in its own error log. ### Related Articles - [Authentication Settings](https://dev.wisecp.com/en/authentication-settings) - [Bot and Spam Protection](https://dev.wisecp.com/en/bot-and-spam-protection) - [API Credentials](https://dev.wisecp.com/en/api-credentials) ## Authentication Settings https://dev.wisecp.com/en/authentication-settings The seven endpoints behind the second step, location and address checks asked beyond the password. ### Overview These seven endpoints decide what is asked **beyond the password** at sign-in: a second step, an extra check when someone arrives from an unfamiliar location, and another when they arrive from an unfamiliar address. All three are set **separately for clients and admins**. Switching one side on leaves the other alone, so wanting both means sending both blocks. ### Reference #### Reading Two-Step Verification get/api/v1/admin/settings/security/two-factor `Settings/GetTwoFactor` admin Returns which second-step methods are on and who is asked for them. Response fields data — 4 authenticationsstring[]The keys of the methods that are on. clientintWhether clients are asked for a second step. adminintWhether admins are asked for a second step. show_popupintWhether the set-up reminder is shown at sign-in. 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/settings/security/two-factor' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/two-factor', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/two-factor'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The switches only bite while AT LEAST ONE method is on; read them together. $tfa = Api::Settings()->GetTwoFactor()['data']; $live = $tfa['admin'] === 1 && $tfa['authentications'] !== []; ``` #### Writing Two-Step Verification put/api/v1/admin/settings/security/two-factor `Settings/UpdateTwoFactor` admin lock-out risk Writes the methods and who is asked for them. Body 4 authenticationsstring[]The keys of the methods to switch on. The list is written whole; a method left out is switched off. clientintAsks clients for a second step. adminintAsks admins for a second step. show_popupintShows the set-up reminder at sign-in. Response fields data dataobjectThe settings as they stand after the write. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/two-factor' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"authentications":["GoogleAuthenticator"],"admin":1,"show_popup":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/two-factor', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ authentications: ['GoogleAuthenticator'], admin: 1, show_popup: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/two-factor'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'authentications' => ['GoogleAuthenticator'], 'admin' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The method list is written WHOLE: sending one while meaning to add turns the others off. $tfa = Api::Settings()->GetTwoFactor()['data']; $methods = $tfa['authentications']; $methods[] = 'GoogleAuthenticator'; Api::Settings()->UpdateTwoFactor(['authentications' => array_unique($methods)]); ``` #### Listing the Methods get/api/v1/admin/settings/security/two-factor/methods `Settings/GetTwoFactorMethods` admin Returns the second-step methods installed and which of them are on. Response fields data[] — 4 keystringThe method key. This is what the write endpoint takes. namestringThe method name. descriptionstringWhat it does. activeboolWhether it is on right now. 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/settings/security/two-factor/methods' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/two-factor/methods', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/two-factor/methods'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Take the method keys from HERE: an invented key is ignored without a word. $keys = array_column(Api::Settings()->GetTwoFactorMethods()['data'], 'key'); ``` #### Reading Location Verification get/api/v1/admin/settings/security/location-verification `Settings/GetLocationVerification` admin two sides, separate Returns what happens when someone signs in from an unfamiliar location. Response fields data — 2 clientobject 3 fieldsThe setting on the client side. statusintWhether location verification is on. methodstringWhich channel the verification is asked through. typestringHow strictly the verification is enforced. adminobject 3 fieldsThe setting on the admin side. statusintWhether location verification is on. methodstringWhich channel the verification is asked through. typestringHow strictly the verification is enforced. 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/settings/security/location-verification' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/location-verification', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/location-verification'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The client and admin settings are INDEPENDENT: switching one on leaves the other alone. $loc = Api::Settings()->GetLocationVerification()['data']; ``` #### Writing Location Verification put/api/v1/admin/settings/security/location-verification `Settings/UpdateLocationVerification` admin Writes location verification, one side at a time. Body 2 clientobjectThe client-side setting: status, channel and strictness. adminobjectThe admin-side setting: status, channel and strictness. Response fields data dataobjectThe settings as they stand after the write. Same shape as the read endpoint: a client block and an admin block, both of them. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/location-verification' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"client":{"status":1,"method":"email","type":"soft"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/location-verification', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ client: { status: 1, method: 'email', type: 'soft' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/location-verification'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'client' => ['status' => 1, 'method' => 'email', 'type' => 'soft'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Resolving a location depends on the LOCATION MODULE: without one the check cannot work as expected. $loc = Api::Settings()->GetLocalisation()['data']; if ($loc['ip_module'] !== '') { Api::Settings()->UpdateLocationVerification([ 'client' => ['status' => 1, 'method' => 'email'], ]); } ``` #### Reading Address Verification get/api/v1/admin/settings/security/ip-verification `Settings/GetIpVerification` admin two sides, separate Returns what happens when someone signs in from an unfamiliar address. Response fields data — 2 clientobject 2 fieldsThe setting on the client side. statusintWhether address verification is on. whiteliststringThe addresses exempt from verification. adminobject 2 fieldsThe setting on the admin side. statusintWhether address verification is on. whiteliststringThe addresses exempt from verification. 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/settings/security/ip-verification' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/ip-verification', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/ip-verification'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php $ip = Api::Settings()->GetIpVerification()['data']; ``` #### Writing Address Verification put/api/v1/admin/settings/security/ip-verification `Settings/UpdateIpVerification` admin lock-out risk Writes address verification, one side at a time. Body 2 clientobjectThe client-side setting: status and exempt addresses. adminobjectThe admin-side setting: status and exempt addresses. Response fields data dataobjectThe settings as they stand after the write. Same shape as the read endpoint: a client block and an admin block, both of them. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/ip-verification' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"admin":{"status":1,"whitelist":"203.0.113.0/24"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/ip-verification', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ admin: { status: 1, whitelist: '203.0.113.0/24' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/ip-verification'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'admin' => ['status' => 1, 'whitelist' => '203.0.113.0/24'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The exempt list is REPLACED by what you send: read it first and append to it. $ip = Api::Settings()->GetIpVerification()['data']; Api::Settings()->UpdateIpVerification([ 'admin' => [ 'status' => 1, 'whitelist' => $ip['admin']['whitelist'] . "\n" . $myAddress, ], ]); ``` ### Pitfalls > **The switch does nothing without a method** > > The client and admin switches only bite while **at least one method is on**. Turning the admin second step on with an empty method list changes nothing, yet the setting reads as on and you believe you are protected. Read them together. > **The method list is written whole** > > The method list you send **replaces** the old one: sending one while meaning to add turns the others off. The second step users set up with those methods stops working at once. Read the current list and append to it. > **Location verification leans on a module** > > Working out where a sign-in came from is the location module's job. With no module installed, or with its lookup failing, the check does not behave as you expect. Confirm the location module is chosen in the localisation settings before switching this on. > **Address verification covers you too** > > Switching address verification on for admins covers **you as well**. On a connection whose address changes, every sign-in asks for the extra check, and forgetting your own address on the exempt list can leave you in an awkward spot. The exempt list is also replaced by what you send, not merged. > **Method keys cannot be invented** > > The methods to switch on are named by the keys of installed modules, and those keys come from the methods endpoint. An unrecognised key is **ignored without a word**: the request looks successful while the method stays off. Confirm through the read endpoint afterwards. ### Related Articles - [Security Settings](https://dev.wisecp.com/en/security-settings) - [Localisation and URLs](https://dev.wisecp.com/en/localisation-and-urls) - [Client Registration](https://dev.wisecp.com/en/client-registration) ## Bot and Spam Protection https://dev.wisecp.com/en/bot-and-spam-protection The ten endpoints that stop unwanted traffic with a bot shield, a captcha and spam checks. ### Overview These ten endpoints stop unwanted traffic in **three separate layers**. The bot shield counts repeated attempts and cuts them off. The captcha puts a check in front of forms. The spam protection looks at what was submitted and at the visitor's reputation. The three are independent and **each has its own switch**. Configuring one layer leaves the others alone, so protecting a form means switching on the right layer. ### Reference #### Reading the Bot Shield get/api/v1/admin/settings/security/bot-shield `Settings/GetBotShield` admin Returns when repeated failed attempts get stopped. Response fields data — 3 statusintWhether the shield is on. within_timeobjectThe window the attempts are counted in. A map from period name to minutes, and only one period is kept. attemptsobjectHow many attempts each protected operation allows. 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/settings/security/bot-shield' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/bot-shield', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/bot-shield'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The attempt count is kept PER OPERATION: lowering it for one leaves the others alone. $shield = Api::Settings()->GetBotShield()['data']; $signIn = $shield['attempts']['sign-in'] ?? null; ``` #### Writing the Bot Shield put/api/v1/admin/settings/security/bot-shield `Settings/UpdateBotShield` admin Switches the shield on and writes the counting window and the attempt limits. Body 3 statusintSwitches the shield on or off. within_timeobjectThe counting window. One period is accepted, and what you send replaces the old one. attemptsobjectThe attempt limit per operation. Response fields data — 3 statusintWhether the shield is on. within_timeobjectThe window the attempts are counted in. A map from period name to minutes, and only one period is kept. attemptsobjectHow many attempts each protected operation allows. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/bot-shield' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":1,"within_time":{"hour":60},"attempts":{"sign-in":5}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/bot-shield', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 1, within_time: { hour: 60 }, attempts: { 'sign-in': 5 }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/bot-shield'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 1, 'within_time' => ['hour' => 60], 'attempts' => ['sign-in' => 5], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Set the limit too low and REAL clients behind a shared connection get blocked as well. Api::Settings()->UpdateBotShield([ 'status' => 1, 'within_time' => ['hour' => 60], 'attempts' => ['sign-in' => 5], ]); ``` #### Reading the Captcha Settings get/api/v1/admin/settings/security/captcha `Settings/GetCaptcha` admin Returns which provider is used and which forms are protected. Response fields data — 3 statusintWhether the captcha is on. typestringThe provider in use. protected_areasobjectThe protected forms. A map from area name to whether it is on. 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/settings/security/captcha' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/captcha', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/captcha'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // With the main switch off the protected list still reads FULL while none of it is enforced. $cap = Api::Settings()->GetCaptcha()['data']; $live = $cap['status'] === 1; ``` #### Writing the Captcha Settings put/api/v1/admin/settings/security/captcha `Settings/UpdateCaptcha` admin Writes the provider, the protected forms and the provider's own settings. Body 4 statusintSwitches the captcha on or off. typestringThe provider to use. The key comes from the provider list. protected_areasarray | objectThe forms to protect: `contact-form`, `sign-up`, `sign-in`, `sign-forget`, `customer-feedback`, `newsletter`, `domain-check`, `software-license`. Either a list or a map. configobjectThe provider's own settings. Written only when you send it. Response fields data — 3 statusintWhether the captcha is on. typestringThe provider in use. protected_areasobjectThe protected forms. A map from area name to whether it is on. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/captcha' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":1,"type":"DefaultCaptcha","protected_areas":["sign-in","sign-up"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/captcha', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 1, type: 'DefaultCaptcha', protected_areas: ['sign-in', 'sign-up'], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/captcha'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 1, 'type' => 'DefaultCaptcha', 'protected_areas' => ['sign-in', 'sign-up'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing provider REQUIRES its own settings: a provider with no keys breaks the form. $fields = Api::Settings()->GetCaptchaFields(['module' => 'ReCaptcha'])['data']; Api::Settings()->UpdateCaptcha([ 'status' => 1, 'type' => 'ReCaptcha', 'config' => ['site_key' => $siteKey, 'secret_key' => $secret], ]); ``` #### Listing the Providers get/api/v1/admin/settings/security/captcha/modules `Settings/GetCaptchaModules` admin Returns the captcha providers installed. Response fields data[] — 4 keystringThe provider key. This is what the write endpoint takes. namestringThe provider name. descriptionstringWhat it does. activeboolWhether it is the one in use. 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/settings/security/captcha/modules' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/captcha/modules', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/captcha/modules'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The built-in provider comes FIRST in the list and needs no settings; the others need keys. $modules = Api::Settings()->GetCaptchaModules()['data']; ``` #### Reading a Provider's Fields get/api/v1/admin/settings/security/captcha/{module}/fields `Settings/GetCaptchaFields` admin Returns which settings a provider wants and what is stored for them. Response fields data — 3 modulestringThe provider key. fieldsarrayThe raw definition of the settings wanted. They differ between providers. configobjectThe stored setting values. Errors 3 module_required422No provider name was given. not_found404No such provider. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/settings/security/captcha/ReCaptcha/fields' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/captcha/ReCaptcha/fields', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/captcha/ReCaptcha/fields'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Learn what a provider wants from here BEFORE switching to it. $needs = Api::Settings()->GetCaptchaFields(['module' => 'ReCaptcha'])['data']['fields']; ``` #### Reading the Spam Protection get/api/v1/admin/settings/security/spam `Settings/GetSpamProtection` admin Returns the word filter, the outside reputation service and the proxy check. Response fields data — 6 word_liststringThe words that get blocked. api_statusintWhether the outside reputation service is on. api_keystringThe service key. api_risk_scoreintThe risk score above which a visitor is blocked. block_temporaryintWhether the temporary block is on. contact_check_proxyintWhether visitors arriving through a proxy are checked. 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/settings/security/spam' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/spam', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/spam'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The outside service does not run WITHOUT A KEY: it can read as on while no lookup happens. $spam = Api::Settings()->GetSpamProtection()['data']; $live = $spam['api_status'] === 1 && $spam['api_key'] !== ''; ``` #### Writing the Spam Protection put/api/v1/admin/settings/security/spam `Settings/UpdateSpamProtection` admin Writes the word filter and the outside reputation check. Body 6 word_liststringThe words to block. api_statusintTurns the outside reputation service on. api_keystringThe service key. The service does not run without it. api_risk_scoreintThe blocking threshold. A lower value blocks more visitors. block_temporaryintTurns the temporary block on. contact_check_proxyintChecks visitors arriving through a proxy. Response fields data — 6 word_liststringThe words that get blocked. api_statusintWhether the outside reputation service is on. api_keystringThe service key. api_risk_scoreintThe risk score above which a visitor is blocked. block_temporaryintWhether the temporary block is on. contact_check_proxyintWhether visitors arriving through a proxy are checked. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/security/spam' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"word_list":"spam,scam","block_temporary":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/spam', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ word_list: 'spam,scam', block_temporary: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/spam'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'word_list' => 'spam,scam', 'block_temporary' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Turning the service on needs a KEY; send both or the check quietly does nothing. Api::Settings()->UpdateSpamProtection([ 'api_status' => 1, 'api_key' => $key, 'api_risk_score' => 25, ]); ``` #### Listing What Was Blocked get/api/v1/admin/settings/security/spam-records `Settings/GetSpamRecords` admin Returns the requests blocked recently and the total number blocked. Response fields data[] + meta data[]objectThe records blocked recently. Only a recent slice is kept, not the whole history. total_blockedintThe total number blocked. It comes back under meta and can exceed how many rows the list holds. 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/settings/security/spam-records' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/spam-records', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/spam-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The counter and the list are NOT the same thing: the total can far exceed what the list shows. $rec = Api::Settings()->GetSpamRecords(); $shown = count($rec['data']); $total = $rec['meta']['total_blocked']; ``` #### Clearing What Was Blocked delete/api/v1/admin/settings/security/spam-records `Settings/ClearSpamRecords` admin the counter resets too Clears the blocked records and resets the total counter. Response fields data — 1 clearedboolWhether the clear ran. Errors 1 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/settings/security/spam-records' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/security/spam-records', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/security/spam-records'); 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 // The clear resets the COUNTER as well: how many were blocked over the installation's life is lost. // Read it and keep it on your side first. $total = Api::Settings()->GetSpamRecords()['meta']['total_blocked']; Api::Settings()->ClearSpamRecords(); ``` ### Pitfalls > **With the main switch off the protected areas do nothing** > > While the captcha's main switch is off, the protected form list **still reads full** and none of it is enforced. Adding a form to that list does not protect it; the main switch has to be on as well. The same holds for the bot shield. > **Changing provider requires its settings** > > The built-in captcha runs with no settings; the others **need keys**. Switching provider without supplying them breaks the forms: the check never loads and clients cannot submit. Read what the provider wants from the fields endpoint before switching. > **The reputation service quietly does nothing without a key** > > Switching the outside reputation service on is not enough on its own: with an empty key no lookup happens and no visitor is checked. The setting **reads as on** and no error is raised. Send both together, then watch whether the blocked records start growing. > **A tight limit cuts off real clients too** > > The bot shield counts attempts **by address**. Clients behind a shared connection, in an office or a school, appear as one address; with a low limit they spend each other's attempts and none of them gets in. When lowering the limit, weigh the counting window with it. > **The clear resets the counter as well** > > Clearing the blocked records does not merely empty the list: the **total counter** of everything blocked over the installation's life is reset with it. That number never comes back, so read it and keep it on your side first. The list itself only holds a recent slice anyway, and the total can be far larger. ### Related Articles - [Security Settings](https://dev.wisecp.com/en/security-settings) - [Authentication Settings](https://dev.wisecp.com/en/authentication-settings) - [Client Registration](https://dev.wisecp.com/en/client-registration) ## Backup Storage https://dev.wisecp.com/en/backup-storage The nine endpoints that set up, test and remove the storage targets backups go to. ### Overview A storage target defines **where backups get sent**. These nine endpoints set targets up, test them and take them away. When backups run is a separate matter. Providers fall into two groups. **Server-type** ones are set up with an address and a password. **Cloud-type** ones wait for the user to grant access in a browser. That path has three steps: get the consent address, send the user, then create the target with the returned token. A target is **actually connected to** before it is saved. A wrong password surfaces the moment you write the target, not on the night of the backup. ### Reference #### Listing the Providers get/api/v1/admin/settings/backup/storage/providers `Settings/GetBackupStorageProviders` admin Returns the storage providers installed for backups to be sent to. Response fields data[] — 5 keystringThe provider key. This is what creating a target takes. namestringThe provider name. descriptionstringWhat it does. oauthboolWhether it needs the user to grant access. When true, writing settings is not enough on its own. fieldsarrayThe definition of the settings the provider wants. 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/settings/backup/storage/providers' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/storage/providers', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/providers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A server-type provider is set up with settings; a cloud one needs the USER TO GRANT ACCESS. $providers = Api::Settings()->GetBackupStorageProviders()['data']; $simple = array_filter($providers, fn ($p) => ! $p['oauth']); ``` #### Testing a Connection post/api/v1/admin/settings/backup/storage/validate `Settings/ValidateBackupStorage` admin Runs a configuration through a connection test without saving it. Body 3 typestringreqThe provider to test. Required unless you give a target id. idintThe id of an existing target. Its settings get used. configobjectThe provider settings. Secret values stay protected behind the mask. Response fields data — 2 validatedboolWhether the connection worked. messagestringWhat the result means. Errors 3 unknown_provider422The provider is not recognised. module_not_loadable422The provider could not be loaded. 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/settings/backup/storage/validate' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"FTP","config":{"host":"ftp.example.com","username":"backup","password":"secret"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/storage/validate', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'FTP', config: { host: 'ftp.example.com', username: 'backup', password: secret }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/validate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'FTP', 'config' => ['host' => 'ftp.example.com', 'username' => 'backup', 'password' => $secret], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The test SAVES NOTHING: passing it does not mean a target now exists. $check = Api::Settings()->ValidateBackupStorage([ 'type' => 'FTP', 'config' => $config, ])['data']; if ($check['validated']) Api::Settings()->CreateBackupStorage($payload); ``` #### Getting the Consent Address post/api/v1/admin/settings/backup/storage/oauth-url `Settings/GetBackupOauthUrl` admin Builds the address where the user grants a cloud provider access. Body 3 typestringreqThe provider asking for consent. idintThe id of an existing target. configobjectThe provider settings. Response fields data — 2 authorize_urlstringThe address the user gets sent to. statestringThe signed state value. It has to match when the user comes back. Errors 4 unknown_provider422The provider is not recognised. oauth_unsupported422The provider does not use a consent flow. oauth_url_failed422The address could not be built. 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/settings/backup/storage/oauth-url' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"GoogleDrive"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/storage/oauth-url', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'GoogleDrive' }), }); const { data } = await res.json(); window.location.href = data.authorize_url; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/oauth-url'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['type' => 'GoogleDrive']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The address alone is not enough: no target can be CREATED before the user comes back. $url = Api::Settings()->GetBackupOauthUrl(['type' => 'GoogleDrive'])['data']['authorize_url']; ``` #### Reading the Bunny Zones post/api/v1/admin/settings/backup/storage/bunny-zones `Settings/GetBunnyZones` admin Returns the Bunny storage zones the given account key can reach. Body 2 account_api_keystringreqThe Bunny account key. Sending the mask means a target id is needed too. idintThe id of an existing target. Response fields data — 1 zonesarrayThe storage zones on the account. Errors 3 api_key_required422No account key was given. fetch_zones_failed422The zone list could not be fetched. 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/settings/backup/storage/bunny-zones' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"account_api_key":"'"$BUNNY_KEY"'"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/storage/bunny-zones', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ account_api_key: bunnyKey }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/bunny-zones'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['account_api_key' => $bunnyKey]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A stored target returns its key masked; when sending the mask back, give the ID as well. $zones = Api::Settings()->GetBunnyZones([ 'account_api_key' => '*****', 'id' => $storageId, ])['data']['zones']; ``` #### Listing the Targets get/api/v1/admin/settings/backup/storage `Settings/GetBackupStorages` admin Returns the backup storage targets defined. Query 1 searchstringSearches by name. Response fields data[] — 6 idintThe target id. namestringThe target name. typestringThe provider type. statusstringThe target status. validated_atstring | nullWhen the connection was last tested. Empty means it was never tested. created_atstring | nullWhen it was created. 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/settings/backup/storage' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/storage', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The last test can be OLD: the password may have changed since that day. $targets = Api::Settings()->GetBackupStorages()['data']; $stale = array_filter($targets, fn ($t) => $t['validated_at'] === null); ``` #### Creating a Target post/api/v1/admin/settings/backup/storage `Settings/CreateBackupStorage` admin connects first Sets up a new storage target, trying to connect before it saves. Body 3 namestringreqThe target name. Up to 150 characters. typestringreqThe provider key. configobjectThe provider settings. A cloud provider also needs the consent token. Response fields 201 — data dataobjectThe target created. Same shape as the detail endpoint, with secrets masked. Errors 5 name_required422The name is empty. invalid_provider422The provider is not valid. oauth_required422The cloud provider has no consent yet. storage_test_failed422The connection could not be made. 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/settings/backup/storage' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Offsite FTP","type":"FTP","config":{"host":"ftp.example.com","username":"backup","password":"secret","folder_path":"/backups"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/storage', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Offsite FTP', type: 'FTP', config: { host: 'ftp.example.com', username: 'backup', password: secret, folder_path: '/backups', }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Offsite FTP', 'type' => 'FTP', 'config' => [ 'host' => 'ftp.example.com', 'username' => 'backup', 'password' => $secret, 'folder_path' => '/backups', ], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The mask is USELESS here: there is no old value to keep, so send the real password. Api::Settings()->CreateBackupStorage([ 'name' => 'Offsite FTP', 'type' => 'FTP', 'config' => ['host' => $host, 'username' => $user, 'password' => $secret], ]); ``` #### Reading One Target get/api/v1/admin/settings/backup/storage/{id} `Settings/GetBackupStorage` admin Returns one target together with its settings. Response fields data — 7 idintThe target id. namestringThe target name. typestringThe provider type. statusstringThe target status. configobjectThe provider settings. Secret values arrive masked. validated_atstring | nullWhen the connection was last tested. created_atstring | nullWhen it was created. Errors 2 not_found404No such target. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/settings/backup/storage/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/storage/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Do not feed what you read straight into a NEW target: the password is masked. $target = Api::Settings()->GetBackupStorage(['id' => $id])['data']; ``` #### Updating a Target patch/api/v1/admin/settings/backup/storage/{id} `Settings/UpdateBackupStorage` admin Changes the target name and its settings. Body 2 namestringThe new name. configobjectThe provider's settings, written as a whole. Send the full set: a field you leave out is dropped, and a checkbox or number falls back to zero. `*****` keeps an encrypted field's stored value. Response fields data dataobjectThe target as it now stands. Same shape as the detail endpoint, with secrets masked. Errors 5 not_found404No such target. name_required422The name is empty. oauth_required422The cloud provider has no consent yet. storage_test_failed422The connection could not be made. 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/settings/backup/storage/12' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Offsite FTP (EU)","config":{"folder_path":"/backups-eu","password":"*****"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/storage/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Offsite FTP (EU)', config: { folder_path: '/backups-eu', password: '*****' }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Offsite FTP (EU)', 'config' => ['folder_path' => '/backups-eu', 'password' => '*****'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Change the path and the connection is tested AGAIN; a missing folder fails the update. Api::Settings()->UpdateBackupStorage([ 'id' => $id, 'config' => ['folder_path' => '/backups-eu', 'password' => '*****'], ]); ``` #### Deleting a Target delete/api/v1/admin/settings/backup/storage/{id} `Settings/DeleteBackupStorage` admin consent is withdrawn Removes a storage target. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the target removed. Errors 3 not_found404No such target. storage_in_use422A schedule still points at this target. 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/settings/backup/storage/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/storage/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/storage/' . $id); 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 // On a cloud target the delete also WITHDRAWS consent: re-adding it needs a fresh grant. Api::Settings()->DeleteBackupStorage(['id' => $id]); ``` ### Pitfalls > **The mask protects on read and does nothing on create** > > The detail and list endpoints return secrets masked. Sending the mask back on an update **keeps the old value**. On create there is no old value to keep. Feed what you read straight into a new target and the mask itself gets stored as the password. The connection test then fails. > **Testing is not saving** > > The test endpoint saves nothing and only tries the connection. Passing it **does not** mean a target exists. The panel shows the two steps back to back, which is where this gets confused. The test succeeds, the create call is forgotten, and no target exists to give a schedule. > **A cloud provider is not set up with settings alone** > > For a provider that asks for consent, writing settings is not enough: the user has to grant access in a browser. Without the token the create call fails with **consent missing**. The consent field in the provider list tells you in advance which providers want that three-step path. > **A target a schedule uses cannot be deleted** > > While a schedule points at a target, the delete call is refused. Move the schedule to another target first, or remove it. The guard is **deliberate**: silently deleting the target would turn the schedule into a job that runs into nothing every night. > **Deleting withdraws the consent too** > > Deleting a cloud target also **revokes** the access grant at the provider. Adding the same account back asks for a fresh round of consent, and the old token is of no use. The revoke is attempted as best it can be. If the provider cannot be reached, the target still goes and the grant may live on at their end. ### Related Articles - [Backup Schedules](https://dev.wisecp.com/en/backup-schedules) - [Backup Diagnostics](https://dev.wisecp.com/en/backup-diagnostics) - [Adding a Scheduled Job](https://dev.wisecp.com/en/adding-a-scheduled-task) ## Backup Schedules https://dev.wisecp.com/en/backup-schedules The nine endpoints that set up backup schedules and manage the backups taken. ### Overview These nine endpoints handle two things. Five of them set up the **schedules**: what content, how often, to which target. The other four deal with the **backups already taken**. A schedule is only an intent. What actually makes a backup is the scheduled job that picks the work up when its turn comes. Writing calls **do not return a result straight away**: the job queues, a record opens, and the status moves along over time. Where backups go is a separate matter. Targets are set up through their own endpoints, and the `storage_id` here merely points at one of them. ### Reference #### Listing the Schedules get/api/v1/admin/settings/backup/schedules `Settings/GetBackupSchedules` admin Returns the backup schedules defined. Query 1 searchstringSearches by name. Response fields data[] — 14 idintThe schedule id. namestringThe schedule name. statusstringWhether it is on or off. frequencystringHow often it runs: `hourly`, `daily`, `weekly`, `monthly`. run_timestringThe time of day it runs. run_dowint | nullThe day of the week. Only meaningful at weekly frequency. run_domint | nullThe day of the month. Only meaningful at monthly frequency. contentsstring[]What goes into the backup: `database`, `files`, `uploads`. storage_idintThe target it gets sent to. Zero means the server itself. keep_localintWhether a copy also stays on the server. retention_countintHow many backups are kept. notify_adminintWhether an administrator gets told. next_run_atstring | nullWhen it next runs. created_atstring | nullWhen it was created. 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/settings/backup/schedules' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/schedules', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/schedules'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The next run time reads full on an OFF schedule too, so read the status as well. $due = array_filter( Api::Settings()->GetBackupSchedules()['data'], fn ($s) => $s['status'] === 'enabled', ); ``` #### Creating a Schedule post/api/v1/admin/settings/backup/schedules `Settings/CreateBackupSchedule` admin born switched off Sets up a new backup schedule. Body 11 namestringreqThe schedule name. Up to 150 characters. contentsstring[]reqWhat goes into the backup: `database`, `files`, `uploads`. At least one is needed. statusintSwitches the schedule on. Left out, it is born switched off. frequencystringHow often it runs: `hourly`, `daily`, `weekly`, `monthly`. Daily by default. run_timestringThe time of day it runs. Three in the morning by default. run_dowintThe day of the week (0-6). run_domintThe day of the month (1-31). storage_idintThe target it gets sent to. Zero means the server itself. keep_localintLeaves a copy on the server. Forced on when the target is the server itself. retention_countintHow many backups to keep. Seven by default. notify_adminintTells an administrator. Response fields 201 — data dataobjectThe schedule created. Same shape as a list item. Errors 3 name_required422The name is empty. contents_empty422No content was chosen. 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/settings/backup/schedules' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Gecelik veritabani","frequency":"daily","run_time":"03:00","contents":["database"],"status":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/schedules', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Nightly database', frequency: 'daily', run_time: '03:00', contents: ['database'], status: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/schedules'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Nightly database', 'frequency' => 'daily', 'run_time' => '03:00', 'contents' => ['database'], 'status' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // LEAVE THE STATUS OUT and the schedule is born off, so no night ever runs it. Api::Settings()->CreateBackupSchedule([ 'name' => 'Nightly database', 'contents' => ['database'], 'status' => 1, ]); ``` #### Reading One Schedule get/api/v1/admin/settings/backup/schedules/{id} `Settings/GetBackupSchedule` admin Returns a single schedule. Response fields data — 14 idintThe schedule id. namestringThe schedule name. statusstringWhether it is on or off. frequencystringHow often it runs: `hourly`, `daily`, `weekly`, `monthly`. run_timestringThe time of day it runs. run_dowint | nullThe day of the week. Only meaningful at weekly frequency. run_domint | nullThe day of the month. Only meaningful at monthly frequency. contentsstring[]What goes into the backup: `database`, `files`, `uploads`. storage_idintThe target it gets sent to. Zero means the server itself. keep_localintWhether a copy also stays on the server. retention_countintHow many backups are kept. notify_adminintWhether an administrator gets told. next_run_atstring | nullWhen it next runs. created_atstring | nullWhen it was created. Errors 2 not_found404No such schedule. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/settings/backup/schedules/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/schedules/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/schedules/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // When the day fields do not MATCH the frequency they read full and do nothing. $s = Api::Settings()->GetBackupSchedule(['id' => $id])['data']; $day = $s['frequency'] === 'weekly' ? $s['run_dow'] : null; ``` #### Updating a Schedule patch/api/v1/admin/settings/backup/schedules/{id} `Settings/UpdateBackupSchedule` admin Changes a schedule, recomputing the next run when a timing field moves. Body 11 namestringreqThe schedule name. Up to 150 characters. contentsstring[]reqWhat goes into the backup: `database`, `files`, `uploads`. At least one is needed. statusintSwitches the schedule on. Left out, it is born switched off. frequencystringHow often it runs: `hourly`, `daily`, `weekly`, `monthly`. Daily by default. run_timestringThe time of day it runs. Three in the morning by default. run_dowintThe day of the week (0-6). run_domintThe day of the month (1-31). storage_idintThe target it gets sent to. Zero means the server itself. keep_localintLeaves a copy on the server. Forced on when the target is the server itself. retention_countintHow many backups to keep. Seven by default. notify_adminintTells an administrator. Response fields data — 14 dataobjectThe schedule as it now stands. Same shape as a list item. next_run_atstringThe recomputed next run. Moving the frequency, the time of day, the weekday or the day of the month reschedules the job, so read the new moment here instead of keeping the old one. Errors 4 not_found404No such schedule. name_required422The name is empty. contents_empty422No content was chosen. 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/settings/backup/schedules/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"run_time":"04:30","retention_count":14}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/schedules/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ run_time: '04:30', retention_count: 14 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/schedules/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['run_time' => '04:30', 'retention_count' => 14]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // What you leave out is KEPT; moving the time shifts when the schedule next runs. Api::Settings()->UpdateBackupSchedule([ 'id' => $id, 'run_time' => '04:30', ]); ``` #### Deleting a Schedule delete/api/v1/admin/settings/backup/schedules/{id} `Settings/DeleteBackupSchedule` admin Removes a schedule and unhooks the backups it made. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the schedule removed. Errors 2 not_found404No such schedule. 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/settings/backup/schedules/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/schedules/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/schedules/' . $id); 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 // The backups are NOT removed, only unhooked; their files stay where they are. Api::Settings()->DeleteBackupSchedule(['id' => $id]); ``` #### Listing the Backups get/api/v1/admin/settings/backup/backups `Settings/GetBackups` admin Returns the history of backups taken. Query 1 searchstringSearches the records. Response fields data[] — 21 idintThe backup id. schedule_idint | nullThe schedule that made it. Empty for a backup taken by hand. storage_idintThe target it went to. keep_localintWhether a copy stayed on the server. contentsstringWhat went into the backup. labelstringThe backup label. file_namestringThe archive name. file_sizeintThe archive size. local_pathstring | nullWhere it sits on the server. remote_pathstring | nullWhere it sits on the target. upload_statusstringHow the upload went. Separate from the backup's own status. upload_errorstring | nullWhy the upload failed. uploaded_atstring | nullWhen the upload finished. statusstringThe backup status. Pending, running, ready, failed or expired. progressstring | nullHow far it got. error_messagestring | nullWhy the backup failed. duration_secondsintHow long it took. started_atstring | nullWhen it started. completed_atstring | nullWhen it finished. cdatestringWhen the record was opened. 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/settings/backup/backups' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/backups', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/backups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A ready backup can STILL have lost its offsite copy, so read both statuses together. $risky = array_filter( Api::Settings()->GetBackups()['data'], fn ($b) => $b['status'] === 'ready' && $b['upload_status'] === 'failed', ); ``` #### Taking a Backup by Hand post/api/v1/admin/settings/backup/backups `Settings/CreateManualBackup` admin gets queued Queues a backup job without waiting for a schedule. Body 3 contentsstring[]reqWhat goes into the backup: `database`, `files`, `uploads`. storage_idintThe target it gets sent to. Zero means the server itself. keep_localintLeaves a copy on the server. Response fields 201 — data — 2 queuedboolWhether the job got queued. It does not mean the backup finished. backup_idintThe id of the backup record opened. Errors 4 contents_empty422No content was chosen. system_disabled422The backup system is off. already_running422A backup is already running. 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/settings/backup/backups' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"contents":["database","files"],"storage_id":0}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/backups', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ contents: ['database', 'files'], storage_id: 0, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/backups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'contents' => ['database', 'files'], 'storage_id' => 0, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Only ONE backup runs at a time: a request landing in the nightly window is refused. $id = Api::Settings()->CreateManualBackup([ 'contents' => ['database'], ])['data']['backup_id']; ``` #### Retrying a Backup post/api/v1/admin/settings/backup/backups/{id}/retry `Settings/RetryBackup` admin Puts a failed backup back in the queue. Body — ——No body is needed, send an empty one. The backup comes from the id in the path. Response fields data — 2 requeuedboolWhether the job went back in the queue. backup_idintThe backup id. Errors 6 invalid_id422The id is not valid. not_found404No such backup. not_failed422The backup did not fail. Only failed ones can be retried. system_disabled422The backup system is off. already_running422A backup is already running. 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/settings/backup/backups/91/retry' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/backups/${id}/retry`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/backups/' . $id . '/retry'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A backup whose upload failed while it stayed READY cannot be retried from here. if ($backup['status'] === 'failed') Api::Settings()->RetryBackup(['id' => $backup['id']]); ``` #### Deleting a Backup delete/api/v1/admin/settings/backup/backups/{id} `Settings/DeleteBackup` admin the files go too Removes a backup record, its queued job and its files. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the backup removed. Errors 4 invalid_id422The id is not valid. not_found404No such backup. backup_running422The backup is running. 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/settings/backup/backups/91' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/settings/backup/backups/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/backups/' . $id); 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 // A running backup cannot go; wait for it to end, as NO endpoint cancels one. Api::Settings()->DeleteBackup(['id' => $id]); ``` ### Pitfalls > **A new schedule is born switched off** > > Leave the status field out and the schedule is created **switched off**. It shows in the list, its next run time even reads full, and no night ever runs it. Believing it works and finding no backups months later starts here, so send the status along when you create it. > **Only one backup runs at a time** > > While a backup runs, the take-by-hand and retry calls are **refused**. A script that lands in the nightly window reads that as a failure. The answer describes a busy moment rather than a lasting problem, so try again a little later. > **There are two separate statuses** > > A backup's own status and its upload status are **separate fields**. One can read as ready while its offsite copy failed: the archive sits on the server and never reached the target. On the day you lose that server the second field is what matters, so read both together. > **Retry is only for backups that failed** > > The retry endpoint **refuses** any backup whose status is not failed. One whose upload failed while it stayed ready cannot pass either, because what is missing there is the offsite copy rather than the backup. That case calls for taking a fresh backup. > **Deleting a schedule does not delete its backups** > > When a schedule goes, the backups it made are **unhooked** and stay put. Their files remain and no retention count counts them for anyone any more. Freeing space means deleting those backups one by one, and deleting a backup cancels its queued job and removes its files. ### Related Articles - [Backup Storage](https://dev.wisecp.com/en/backup-storage) - [Backup Diagnostics](https://dev.wisecp.com/en/backup-diagnostics) - [Adding a Scheduled Job](https://dev.wisecp.com/en/adding-a-scheduled-task) ## Backup Diagnostics https://dev.wisecp.com/en/backup-diagnostics The six endpoints that hold the backup settings and measure what the server can back up. ### Overview These six endpoints answer two questions. Two of them hold the **settings**: is the system on, and what stays out of a backup. The other four **measure**: can this server take a backup, is there room, and how much space each folder and table takes. The four that measure are read-only and change nothing. They also feed the exclusion screen in the panel: an operator walks the tree, sees the tables by size, and decides. The diagnostics are the real question to ask before taking a backup. They show today what would otherwise surface on the night of the run: a **critical** gap makes a backup impossible, while a **warning** leaves one weaker. ### Reference #### Reading the Backup Settings get/api/v1/admin/settings/backup/settings `Settings/GetBackupSettings` admin Returns the backup system's main switch and what it leaves out. Response fields data — 3 statusintWhether the backup system is on. excluded_pathsstringThe paths kept out of backups. A single string rather than a list. excluded_tablesstringThe tables kept out of backups. A single string rather than a list. 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/settings/backup/settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // With the main switch OFF the schedules stop and taking one by hand is refused. $live = Api::Settings()->GetBackupSettings()['data']['status'] === 1; ``` #### Writing the Backup Settings put/api/v1/admin/settings/backup/settings `Settings/UpdateBackupSettings` admin Switches the system on and writes the paths and tables to leave out. Body 3 statusintSwitches the backup system on or off. excluded_pathsstringThe paths to keep out. What you send replaces what was there. excluded_tablesstringThe tables to keep out. What you send replaces what was there. Response fields data — 3 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/settings/backup/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":1,"excluded_tables":"api_logs,mail_logs"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 1, excluded_tables: 'api_logs,mail_logs', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 1, 'excluded_tables' => 'api_logs,mail_logs', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list is REPLACED, not added to: read the old one first or the earlier entries go. $cur = Api::Settings()->GetBackupSettings()['data']['excluded_tables']; Api::Settings()->UpdateBackupSettings([ 'excluded_tables' => $cur . ',mail_logs', ]); ``` #### Reading the Diagnostics get/api/v1/admin/settings/backup/diagnostics `Settings/GetBackupDiagnostics` admin Tells you whether the server can actually take a backup. Response fields data — 4 criticalstring[]What makes a backup **impossible**: `phar` for archiving, `zlib` for compression, `spl` for walking directories. warningstring[]What leaves a backup **weaker**: `disk-monitor` for space measuring, `exec` for child processes, `streaming-pipe` for streaming. infostring[]What is merely **worth knowing**: `mysqldump-cli`, the external dump tool. okboolWhether all is well. True when the critical and warning lists are empty, and the info lines do not count. 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/settings/backup/diagnostics' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/diagnostics', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/diagnostics'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A full critical list means NO backup can run; the warning list only weakens one. $d = Api::Settings()->GetBackupDiagnostics()['data']; if ($d['critical']) throw new Exception('Backups cannot run on this server.'); ``` #### Reading the Disk Information get/api/v1/admin/settings/backup/disk `Settings/GetBackupDisk` admin Returns how much room is left on the disk the installation sits on. Response fields data — 4 freeintThe free space. totalintThe total space. usedintThe space in use. sourcestringWhere the measurement came from. Either a direct system call or the disk tool read from a shell. 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/settings/backup/disk' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/disk', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); if (data.total === undefined) console.warn('disk measurement unavailable'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/disk'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // When the server allows no measurement the fields DO NOT arrive, so check before reading. $disk = Api::Settings()->GetBackupDisk()['data']; $free = $disk['free'] ?? null; ``` #### Walking the Directory Tree get/api/v1/admin/settings/backup/directory-listing `Settings/GetBackupDirectoryListing` admin Returns the files and folders under the installation root. Query 1 pathstringA path below the root. Left empty, the root comes back. Response fields data — 2 + entries[] — 6 pathstringThe path asked for. entries[]object[]What that folder holds. Folders first, then files, each group sorted by name. entries[].namestringThe entry name. entries[].pathstringIts path below the root. entries[].typestringWhether it is a folder or a file. entries[].sizeint | nullThe file size. Empty for folders. entries[].has_childrenboolWhether it holds anything. entries[].has_subdirsboolWhether it holds further folders. Lets you show it as expandable without opening it. Errors 2 invalid_path422The path is not valid, or not a folder. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/settings/backup/directory-listing?path=templates' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/settings/backup/directory-listing'); url.searchParams.set('path', 'templates'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/directory-listing?' . http_build_query(['path' => 'templates'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The tree cannot leave the root; a path climbing upward comes back as a 422. $tree = Api::Settings()->GetBackupDirectoryListing([], ['path' => 'templates'])['data']; ``` #### Measuring the Tables get/api/v1/admin/settings/backup/tables `Settings/GetBackupTables` admin Returns the database tables with their row counts and sizes. Response fields data[] — 3 namestringThe table name. The list arrives sorted by name. rowsintThe row count. An estimate from the engine rather than a count. sizeintThe room the table takes. Data and index added together. 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/settings/backup/tables' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/settings/backup/tables', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/settings/backup/tables'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Feeding the exclusion screen with the fattest tables: size is NOT the archive size. $tables = Api::Settings()->GetBackupTables()['data']; usort($tables, fn ($a, $b) => $b['size'] <=> $a['size']); ``` ### Pitfalls > **The main switch comes before everything** > > While the backup system is off, schedules do not run and the take-by-hand call is **refused**. The schedules stay in the list, the targets stay put, and none of them raises an error. When no backup appears on an installation, look at this field before you go hunting through the schedules. > **The exclusion lists are replaced, not added to** > > The path and table lists are **strings**, and what you send replaces what was there. Sending only the one table you meant to add drops every earlier line. Read the current value first and append to it. > **The disk information may not arrive at all** > > When the server allows no space measurement this endpoint returns an **empty object** and raises nothing. Check the fields exist before reading them, or an absent value reads as zero and you warn that the disk is full. The same gap also shows in the diagnostics warning list. > **The directory tree does not show everything** > > Whatever is excluded by default is **filtered out** of the tree and never appears in the listing. The backup folder itself is one of those, and it keeps being filtered even after an operator renames it. That is deliberate: were the backups themselves included, every run would carry the one before it. > **The row count is an estimate and the size is not the archive** > > A table's row count is the engine's **estimate**. It can be tens of percent off, so keep it out of billing and reporting figures. The size adds data and index together, while a compressed dump comes out far smaller. Both are good enough for deciding what to exclude and no more than that. ### Related Articles - [Backup Schedules](https://dev.wisecp.com/en/backup-schedules) - [Backup Storage](https://dev.wisecp.com/en/backup-storage) - [Adding a Scheduled Job](https://dev.wisecp.com/en/adding-a-scheduled-task) # API / Admin API / Tickets ## Managing Tickets https://dev.wisecp.com/en/managing-tickets The nine endpoints that open, update, delete and reshape support tickets. ### Overview These nine endpoints deal with the **ticket itself**: opening one, reading it, changing its fields, deleting it and seeing what happened to it. Replies, notes and custom fields live at their own endpoints, and the detail call does not carry them. Three **reshaping** operations sit alongside. The bulk call closes or deletes many at once. Merging gathers a scattered conversation into one ticket. Splitting lifts a subject that wandered in out into a ticket of its own. This area **reaches the client**. Opening a ticket, marking one solved and notifying on a split each send an e-mail, and assigning staff tells the person assigned. Weigh that before writing a batch script. ### Reference #### Listing the Tickets get/api/v1/admin/tickets `Tickets/GetTickets` admin Returns support tickets, filtered and paged. Query 10 pageintWhich page. limitintRecords per page. Clamped between one and a hundred. searchstringSearches the subject, ticket number, work reference, client name or e-mail. statusstringFilters by status: `open`, `waiting`, `process`, `replied`, `solved`. department_idintFilters by department. client_idintFilters by client. assigned_idintFilters by the staff member assigned. priorityintFilters by priority. cdatestringFilters by the date opened. cdate_opstringWhich way the date comparison runs. Goes together with the date field. Response fields data[] — 15 + meta idintThe ticket number. referencestring | nullThe work reference. subjectstringThe ticket subject. statusstringThe ticket status: `open`, `waiting`, `process`, `replied`, `solved`. custom_status_idintAn installation's own status. Zero means the standard one. priorityintThe ticket priority. pipeintWhether the ticket arrived by e-mail. admin_unreadboolWhether staff have yet to read it. user_unreadboolWhether the client has yet to read it. created_atstring | nullWhen it was opened. last_reply_atstring | nullWhen the last reply landed. assigned_idintThe staff member assigned. Zero means nobody. departmentobjectThe department: its id and name. clientobjectThe client: id, name, company and e-mail. last_replyobjectA preview of the last reply. On an encrypted reply the text arrives empty. 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/tickets?status=waiting&limit=25' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tickets'); url.searchParams.set('status', 'waiting'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets?' . http_build_query(['status' => 'waiting', 'limit' => 25])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // On an encrypted reply the preview text arrives EMPTY; ciphertext never enters a list. $rows = Api::Tickets()->GetTickets([], ['status' => 'waiting'])['data']; $prev = $rows[0]['last_reply']['message'] ?? null; ``` #### Opening a Ticket post/api/v1/admin/tickets `Tickets/CreateTicket` admin a notice goes out Opens a ticket for a client on behalf of staff and writes the first message. Body 12 client_idintreqThe client the ticket belongs to. subjectstringreqThe ticket subject. messagestringreqThe first message. It can carry placeholders that fill from the client record. service_idintThe service it concerns. It has to be the client's own. statusstringThe status it opens with: `open`, `waiting`, `process`, `replied`, `solved`. custom_status_idintAn installation's own status. department_idintThe department it goes to. priorityintThe ticket priority. assigned_idintThe staff member to assign. Assigning also tells that person. lockedboolOpens the ticket locked. The client cannot reply. encryptboolStores the first message encrypted. attachmentsstring | object | arrayFiles to attach. Response fields 201 — data dataobjectThe ticket opened. Same shape as the detail endpoint. Errors 10 invalid_client422The client is not valid. subject_required422The subject is empty. message_required422The message is empty. invalid_custom_status422The custom status is not valid. invalid_service422The service does not belong to this client. invalid_department422The department is not valid. invalid_assigned422The staff member is not valid. blocked_by_gate422A hook refused to let it open. create_failed422The ticket could not be opened. 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/tickets' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"client_id":2,"subject":"Hos geldiniz","message":"Merhaba {FULL_NAME}, hizmetiniz hazir.","department_id":4}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ client_id: 2, subject: 'Welcome aboard', message: 'Hi {FULL_NAME}, your service {SERVICE} is ready.', department_id: 4, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'client_id' => 2, 'subject' => 'Welcome aboard', 'message' => 'Hi {FULL_NAME}, your service {SERVICE} is ready.', 'department_id' => 4, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Opening a ticket MAILS THE CLIENT; in an import loop every record means one more message. Api::Tickets()->CreateTicket([ 'client_id' => $uid, 'subject' => $subject, 'message' => 'Hi {FULL_NAME}, your service {SERVICE} is ready.', ]); ``` #### Reading One Ticket get/api/v1/admin/tickets/{id} `Tickets/GetTicket` admin Returns one ticket with everything it points at resolved. Response fields data — 20 idintThe ticket number. referencestring | nullThe work reference. subjectstringThe ticket subject. statusstringThe ticket status: `open`, `waiting`, `process`, `replied`, `solved`. custom_status_idintAn installation's own status. priorityintThe ticket priority. lockedboolWhether it is locked. A locked ticket takes no client reply. langstringThe ticket language. pipeintWhether it arrived by e-mail. admin_unreadboolWhether staff have yet to read it. user_unreadboolWhether the client has yet to read it. created_atstring | nullWhen it was opened. last_reply_atstring | nullWhen the last reply landed. senderobjectThe raw sender: name, e-mail, phone and address. Filled in for senders with no account. departmentobjectThe department: its id and name. clientobjectThe client: id, name, company and e-mail. assignedobject | nullThe staff member assigned: id, name and e-mail. serviceobject | nullThe service it concerns: id, type, name, status and domain. custom_fieldsarrayThe ticket's custom field values. Encrypted at rest and decoded on the way out. statsobjectA summary of speed and rating: average response time, response count, average score and how many scored it. Errors 2 not_found404No such ticket. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The detail CARRIES NO MESSAGES; replies, notes and history come from their own endpoints. $ticket = Api::Tickets()->GetTicket(['id' => $id])['data']; ``` #### Updating a Ticket patch/api/v1/admin/tickets/{id} `Tickets/UpdateTicket` admin a notice per field Applies the fields you send, and each changed field leaves its own trail. Body 8 statusstringThe new status: `open`, `waiting`, `process`, `replied`, `solved`. Solved and in-process both tell the client. custom_status_idintAn installation's own status. department_idintMoves the ticket to another department. priorityintThe new priority. assigned_idintAssigns a staff member. Zero unassigns, and assigning tells that person. service_idintThe service it concerns. Zero unlinks it. client_idintHands the ticket to another client. lockedboolSets the lock outright. It is a direct set rather than a toggle. Response fields data + meta dataobjectThe ticket as it now stands. appliedstring[]The fields that actually changed. It comes back under meta, and sending the same value does nothing. Errors 9 invalid_status422The status is not valid. invalid_custom_status422The custom status is not valid. invalid_department422The department is not valid. invalid_priority422The priority is not valid. invalid_assigned422The staff member is not valid. invalid_service422The service is not valid. invalid_client422The target client is not valid. not_found404No such ticket. 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/tickets/402' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"solved","priority":3}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'solved', priority: 3 }), }); const { meta } = await res.json(); console.log(meta.applied); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 'solved', 'priority' => 3]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Sending the same value is a NO-OP; meta.applied tells you what really changed. $r = Api::Tickets()->UpdateTicket(['id' => $id, 'status' => 'solved']); $changed = $r['meta']['applied']; ``` #### Deleting a Ticket delete/api/v1/admin/tickets/{id} `Tickets/DeleteTicket` admin attachments go too Removes a ticket along with its replies and files. Response fields data — 2 deletedboolWhether the delete ran. idintThe number of the ticket removed. Errors 2 not_found404No such ticket. 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/tickets/404' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id); 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 // There is NO wastebasket: replies, attachments and custom field files all go with it. Api::Tickets()->DeleteTicket(['id' => $id]); ``` #### Reading the History get/api/v1/admin/tickets/{id}/history `Tickets/GetTicketHistory` admin Returns what happened on a ticket, in order. Query 4 pageintWhich page. limitintRecords per page. searchstringSearches the events. cdatestringFilters by date. Response fields data[] — 5 + meta idintThe event id. eventstringWhat happened. A raw key, and turning it into readable text is up to you. dataobjectDetail belonging to that event. actorobjectWho did it: id, name and type. created_atstring | nullWhen it happened. Errors 2 not_found404No such ticket. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402/history' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/history`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/history'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The event name is a RAW key; turning it into readable text is your screen's job. $events = Api::Tickets()->GetTicketHistory(['id' => $id])['data']; ``` #### Acting on Many Tickets post/api/v1/admin/tickets/bulk `Tickets/BulkTicketActions` admin can block the client too Closes or deletes several tickets in one call. Body 2 idsint[]reqThe ticket numbers to work on. actionstringreqWhat to do: `close` marks them solved, `delete` removes them, while `block-close` and `block-delete` also **bar the client from opening tickets**. Response fields data — 3 actionstringWhat was done. processedint[]The tickets worked on. countintHow many were worked on. Errors 3 ids_required422No ticket was given. invalid_action422The action is not recognised. 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/tickets/bulk' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[405,406],"action":"close"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/bulk', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [405, 406], action: 'close' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/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' => [405, 406], 'action' => 'close']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The password step the panel asks for before deleting is NOT here; scope is the only gate. Api::Tickets()->BulkTicketActions(['ids' => $ids, 'action' => 'close']); ``` #### Merging Tickets post/api/v1/admin/tickets/merge `Tickets/MergeTickets` admin lowest number wins Gathers several tickets into one. Body 1 idsint[]reqThe tickets to merge. At least two, and the **lowest number** becomes the main one. Response fields data + meta — 2 dataobjectThe main ticket. primary_idintThe main ticket number. It comes back under meta. merged_idsint[]The tickets folded in and then removed. Errors 4 min_two_required422At least two tickets are needed. blocked_by_gate422A hook refused the merge. not_found404The main ticket was not found. 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/tickets/merge' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[419,418]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/merge', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [419, 418] }), }); const { meta } = await res.json(); console.log(meta.primary_id); // 418 ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/merge'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [419, 418]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // YOU DO NOT PICK the main ticket: the lowest number wins and the rest are folded in. $r = Api::Tickets()->MergeTickets(['ids' => [419, 418]]); $primary = $r['meta']['primary_id']; // 418 ``` #### Splitting a Ticket post/api/v1/admin/tickets/{id}/split `Tickets/SplitTicket` admin Moves the replies you pick into a new ticket. Body 5 reply_idsint[]reqThe replies to move. All of them have to belong to the source. subjectstringreqThe new ticket's subject. department_idintThe new ticket's department. priorityintThe new ticket's priority. notify_clientboolTells the client. Response fields 201 — data + meta — 2 dataobjectThe new ticket. source_idintThe source ticket number. It comes back under meta. moved_repliesintHow many replies moved. Errors 7 replies_required422No reply was picked. subject_required422The subject is empty. invalid_department422The department is not valid. invalid_replies422Some replies do not belong to this ticket. blocked_by_gate422A hook refused the split. create_failed422The new ticket could not be opened. 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/tickets/402/split' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"reply_ids":[846,847],"subject":"Fatura sorusu","department_id":4}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/split`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ reply_ids: [846, 847], subject: 'Billing question split out', department_id: 4, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/split'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'reply_ids' => [846, 847], 'subject' => 'Billing question split out', 'department_id' => 4, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The replies MOVE rather than copy: they no longer appear on the source ticket. $new = Api::Tickets()->SplitTicket([ 'id' => $id, 'reply_ids' => $ids, 'subject' => 'Billing question split out', ])['data']; ``` ### Pitfalls > **You do not choose the main ticket** > > In a merge the ticket with the **lowest number** becomes the main one, and the order of your array changes nothing. The others have their replies moved across and are then removed. Assuming the number you put first wins gathers the conversation the opposite way round. Read the main number that comes back to see what happened. > **An encrypted reply reads empty in a list** > > The last-reply preview in a ticket list arrives **empty** when that reply is encrypted. Ciphertext never enters a list, and that is a deliberate line. A client that prints the preview as it comes shows a blank row, so label the empty value as encrypted instead. > **Changing a field sends mail** > > On an update each field runs its own path: marking a ticket solved **notifies** the client, and assigning staff notifies the person assigned. Sending the same value again does nothing, so mail goes out exactly when a record really changes. Read the applied-field list that comes back to see what went. > **The blocking bulk actions reach past the ticket** > > The two blocking options do more than close and delete: they **bar the client from opening tickets**. That is a lasting decision which shuts that account out of support, and it is not the same as removing a ticket. When writing a spam clean-up, reach for the plain option rather than the blocking one. > **A delete cannot be undone** > > When a ticket goes, its replies, attachments, note attachments and custom field files **go with it**. There is no wastebasket and no undo. A script that deletes where it meant to close takes the client's history along; marking the status solved is all closing needs. ### Related Articles - [Ticket Replies](https://dev.wisecp.com/en/ticket-replies) - [Internal Notes and Secrets](https://dev.wisecp.com/en/internal-notes-and-secrets) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) ## Ticket Replies https://dev.wisecp.com/en/ticket-replies The eight endpoints that read and write a ticket conversation and set how replies are kept. ### Overview A ticket's conversation lives at these eight endpoints. Four deal with the reply itself, two change how a reply is **stored and seen**, and two handle attachments. Replies are walked with a **cursor** rather than a page number. The newest comes first; to reach older ones you hand back the oldest id you hold, and to poll for new ones you hand back the newest. Two different ideas of privacy live here and should not be confused. **Encryption** is about how the text sits in the database, and staff still read it. **Hiding** decides whether the client sees it at all. ### Reference #### Listing the Replies get/api/v1/admin/tickets/{id}/messages `Tickets/GetTicketMessages` admin cursor paging Returns a ticket's conversation, newest first. Query 3 before_reply_idintFetches what came before this reply. This is how you page backwards. after_reply_idintFetches what came after this reply. This is how you poll for new ones. limitintHow many replies to return. Clamped between one and fifty, ten by default. Response fields data[] — 15 + meta — 5 idintThe reply id. author_idintThe author id. author_namestringThe author name. is_adminboolWhether staff wrote it. messagestringThe reply text. It arrives decoded even when stored encrypted. encryptedboolWhether it is stored encrypted. hiddenboolWhether it is hidden from the client. Hidden replies come back in this list too. pipeboolWhether it arrived by e-mail. aiboolWhether an assistant wrote it. contactboolWhether it came from the contact form. ratingint | nullThe score the client gave. rated_atstring | nullWhen it was scored. ipstring | nullThe sender address. created_atstring | nullWhen it was written. attachmentsarrayThe attachments: id, shown name, stored name and size. The content lives at the download endpoint rather than here. totalintHow many replies the ticket holds. It comes back under meta. countintHow many came back on this page. oldest_reply_idintThe oldest reply on the page. Hand this to the next backwards page. newest_reply_idintThe newest reply on the page. Hand this to your polling call. has_moreboolWhether older ones remain. Errors 2 not_found404No such ticket or reply. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402/messages?limit=10' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL(`https://panel.example.com/api/v1/admin/tickets/${id}/messages`); url.searchParams.set('limit', '10'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data, meta } = await res.json(); // Bir sonraki sayfa: url.searchParams.set('before_reply_id', meta.oldest_reply_id) ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages?' . http_build_query(['limit' => 10])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // THERE IS NO PAGE NUMBER: you ask for the next page with the oldest id you hold. $page = Api::Tickets()->GetTicketMessages(['id' => $id], ['limit' => 10]); $next = Api::Tickets()->GetTicketMessages(['id' => $id], [ 'before_reply_id' => $page['meta']['oldest_reply_id'], ]); ``` #### Writing a Reply post/api/v1/admin/tickets/{id}/messages `Tickets/AddTicketMessage` admin reaches the client Adds a reply, moves the ticket to replied and tells the client. Body 7 messagestringreqThe reply text. signaturestringA signature appended to the text. encryptboolStores the reply encrypted. attachmentsstring | object | arrayFiles to attach. aiboolMarks the reply as written by an assistant. hiddenboolAdds it as a staff-only reply. The client never sees it, the status stays put and no notice goes out. author_namestringThe author name shown to the client. Left out, the key's owner appears. Response fields 201 — data dataobjectThe reply added. Same shape as a list item. Errors 6 ticket_locked422The ticket is locked. message_required422The message is empty. blocked_by_gate422A hook refused the reply. reply_failed422The reply could not be added. not_found404No such ticket or reply. 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/tickets/402/messages' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"message":"Sorununuz cozuldu.","signature":"Destek Ekibi"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Your issue is resolved. Please confirm.', signature: 'Support Team', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'message' => 'Your issue is resolved. Please confirm.', 'signature' => 'Support Team', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To leave a note, add it HIDDEN: otherwise the client gets an e-mail. Api::Tickets()->AddTicketMessage([ 'id' => $id, 'message' => 'Checked the server logs, nothing unusual.', 'hidden' => true, ]); ``` #### Editing a Reply patch/api/v1/admin/tickets/{id}/messages/{reply_id} `Tickets/UpdateTicketMessage` admin Changes the text of a reply already written. Body 1 messagestringreqThe new text. An encrypted reply stays encrypted. Response fields data dataobjectThe reply as it now reads. Same shape as a list item. Errors 3 message_required422The message is empty. not_found404No such ticket or reply. 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/tickets/402/messages/846' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"message":"Duzeltilmis yanit metni."}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Updated reply text.' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['message' => 'Updated reply text.']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Editing TELLS THE CLIENT NOTHING: the old text stands in the mail already sent. Api::Tickets()->UpdateTicketMessage([ 'id' => $id, 'reply_id' => $replyId, 'message' => 'Updated reply text.', ]); ``` #### Deleting a Reply delete/api/v1/admin/tickets/{id}/messages/{reply_id} `Tickets/DeleteTicketMessage` admin Removes a reply along with its attachments. Response fields data — 2 deletedboolWhether the delete ran. reply_idintThe id of the reply removed. Errors 2 not_found404No such ticket or reply. 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/tickets/402/messages/846' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId); 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 // The reply's attachments go too, and the ticket's last-reply summary shifts on the next read. Api::Tickets()->DeleteTicketMessage(['id' => $id, 'reply_id' => $replyId]); ``` #### Changing the Encryption put/api/v1/admin/tickets/{id}/messages/{reply_id}/encryption `Tickets/SetTicketMessageEncryption` admin Decides whether a reply is stored encrypted. Body 1 encryptedboolreqStores it encrypted, or takes the encryption off. Response fields data — 3 reply_idintThe reply id. encryptedboolHow it now stands. changedboolWhether anything moved. False means it already stood that way. Errors 2 not_found404No such ticket or reply. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/402/messages/846/encryption' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"encrypted":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}/encryption`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ encrypted: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId . '/encryption'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['encrypted' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Encryption changes only how it is STORED: staff read the text either way. $r = Api::Tickets()->SetTicketMessageEncryption([ 'id' => $id, 'reply_id' => $replyId, 'encrypted' => true, ])['data']; ``` #### Changing the Visibility put/api/v1/admin/tickets/{id}/messages/{reply_id}/visibility `Tickets/SetTicketMessageVisibility` admin Hides a staff reply from the client, or shows it again. Body 1 hiddenboolreqHides it, or shows it. Only staff replies can be hidden. Response fields data — 3 reply_idintThe reply id. hiddenboolHow it now stands. changedboolWhether anything moved. Errors 3 hide_only_staff422A client reply cannot be hidden. not_found404No such ticket or reply. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/402/messages/848/visibility' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"hidden":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/messages/${replyId}/visibility`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ hidden: true }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/messages/' . $replyId . '/visibility'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['hidden' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Hiding works after the fact, yet it cannot pull back the mail ALREADY SENT. Api::Tickets()->SetTicketMessageVisibility([ 'id' => $id, 'reply_id' => $replyId, 'hidden' => true, ]); ``` #### Downloading an Attachment get/api/v1/admin/tickets/{id}/attachments/{att_id} `Tickets/GetTicketAttachment` admin body arrives as text Returns an attachment's details together with its content. Response fields data — 6 idintThe attachment id. namestringThe name it shows under. file_namestringThe name it is stored under. file_sizeintThe file size. mimestringThe file type. content_base64stringThe file content. It arrives turned into text, because the response is a document. Errors 2 not_found404No such attachment, or its file is missing. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402/attachments/202' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/attachments/${attId}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const bytes = Uint8Array.from(atob(data.content_base64), (c) => c.charCodeAt(0)); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/attachments/' . $attId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Turning the content into text makes the body roughly A THIRD larger than the file. $att = Api::Tickets()->GetTicketAttachment(['id' => $id, 'att_id' => $attId])['data']; file_put_contents($path, base64_decode($att['content_base64'])); ``` #### Deleting an Attachment delete/api/v1/admin/tickets/{id}/attachments/{att_id} `Tickets/DeleteTicketAttachment` admin Removes an attachment from its record and from disk. Response fields data — 2 deletedboolWhether the delete ran. attachment_idintThe id of the attachment removed. Errors 2 not_found404No such ticket or attachment. 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/tickets/402/attachments/202' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/attachments/${attId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/attachments/' . $attId); 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 // This endpoint removes REPLY attachments; note attachments have endpoints of their own. Api::Tickets()->DeleteTicketAttachment(['id' => $id, 'att_id' => $attId]); ``` ### Pitfalls > **There is no page number** > > This list is walked with a cursor: you ask for the next page with the **oldest reply id** you hold. Sending a page number does nothing and returns the same first page forever. The meta block hands you both the oldest and the newest id, so keep them both. > **The same message reads blank in one place and open here** > > The last-reply preview in a ticket list arrives blank for encrypted messages, while this endpoint hands the text back **decoded**. The two do not contradict: a list sweeps many tickets and keeps ciphertext out of that surface. Encryption is about storage rather than about keeping staff out. > **Hidden replies come back in this list too** > > Staff-only replies never reach the client, yet this endpoint returns them **alongside the rest**. An integration that shows the conversation to a customer as it comes leaks the internal notes with it. Filter the hidden ones out yourself on any customer-facing surface. > **Writing a reply sends the mail at once** > > Adding a reply moves the ticket to replied and **e-mails the client**. Fixing the text afterwards does not pull that mail back, and neither does hiding it. When you only mean to record something, add the reply hidden: the status stays put and no mail goes out. > **Downloading an attachment inflates the body** > > Because the response is a document, the file content arrives **turned into text** and the body runs about a third larger than the file. A hundred-megabyte attachment means a hundred-and-thirty-megabyte response, and a client that loads it whole stops there. Stream large attachments, and leave them alone unless you need them. ### Related Articles - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Internal Notes and Secrets](https://dev.wisecp.com/en/internal-notes-and-secrets) - [Ticket Settings](https://dev.wisecp.com/en/ticket-settings) ## Internal Notes and Secrets https://dev.wisecp.com/en/internal-notes-and-secrets The nine endpoints for staff-only notes and the secrets kept on a ticket. ### Overview These nine endpoints deal with the side of a ticket the **client never sees**. Six manage staff-only notes and their files. Three hold labelled secrets, which is where things shared during a conversation, such as server access, belong. Both live on the ticket's **own row**, encrypted. Neither has a table of its own, so neither has paging or filtering: the list always arrives whole. Adding a note raises no notice and leaves the ticket status alone. When you want text that stays out of the client's view yet belongs to the **conversation itself**, what you want is a hidden reply rather than a note. ### Reference #### Listing the Notes get/api/v1/admin/tickets/{id}/notes `Tickets/GetTicketNotes` admin Returns the notes staff left on a ticket. Response fields data[] — 8 idstringThe note id. A sixteen-character string rather than a position. author_idintWho wrote it. messagestringThe note text. pinnedboolWhether it is pinned. Pinned notes sit at the top in the panel. aiboolWhether an assistant wrote it. The panel shows it under a different author. legacyboolWhether it came from the older format. created_atstring | nullWhen it was written. attachmentsarrayThe attachments: id, shown name, stored name and size. Errors 2 not_found404No such ticket. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402/notes' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is NO paging: every note comes at once, decoded from the ticket's own row. $notes = Api::Tickets()->GetTicketNotes(['id' => $id])['data']; ``` #### Adding a Note post/api/v1/admin/tickets/{id}/notes `Tickets/AddTicketNote` admin Leaves a staff-only note on a ticket. Body 4 messagestringreqThe note text. pinnedboolPins the note. aiboolMarks the note as written by an assistant. attachmentsstring | object | arrayFiles to attach. Response fields 201 — data dataobjectThe note added. Same shape as a list item. Errors 4 message_required422The note text is empty. blocked_by_gate422A hook refused the note. not_found404No such ticket. 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/tickets/402/notes' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"message":"Muhasebeye devredildi.","pinned":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Escalated to the billing team.', pinned: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'message' => 'Escalated to the billing team.', 'pinned' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A note NEVER REACHES THE CLIENT and raises no notice; only staff see it. $note = Api::Tickets()->AddTicketNote([ 'id' => $id, 'message' => 'Escalated to the billing team.', ])['data']; ``` #### Updating a Note patch/api/v1/admin/tickets/{id}/notes/{note_id} `Tickets/UpdateTicketNote` admin Changes a note's text or whether it is pinned. Body 2 messagestringThe new text. pinnedboolPins it, or unpins it. Response fields data dataobjectThe note as it now reads. Same shape as a list item. Errors 5 no_changes422No field was given. An empty body does not pass quietly. message_required422The note text is empty. update_failed422The update could not be written. not_found404No such ticket or note. 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/tickets/402/notes/1d9e35094420c723' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"pinned":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ pinned: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['pinned' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An empty body is an ERROR rather than a no-op; send at least one field. Api::Tickets()->UpdateTicketNote([ 'id' => $id, 'note_id' => $noteId, 'pinned' => false, ]); ``` #### Deleting a Note delete/api/v1/admin/tickets/{id}/notes/{note_id} `Tickets/DeleteTicketNote` admin Removes a note along with its files. Response fields data — 2 deletedboolWhether the delete ran. note_idstringThe id of the note removed. Errors 3 delete_failed422The delete could not be written. not_found404No such ticket or note. 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/tickets/402/notes/1d9e35094420c723' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId); 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 // The note's attachments go too: dropped from the record and removed from disk. Api::Tickets()->DeleteTicketNote(['id' => $id, 'note_id' => $noteId]); ``` #### Downloading a Note Attachment get/api/v1/admin/tickets/{id}/notes/{note_id}/attachments/{att_id} `Tickets/GetTicketNoteAttachment` admin Returns a file attached to a note, with its content. Response fields data — 6 idstringThe attachment id. namestringThe name it shows under. file_namestringThe name it is stored under. file_sizeintThe file size. mimestringThe file type. content_base64stringThe file content. It arrives turned into text. Errors 2 not_found404No such attachment, or its file is missing. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402/notes/1d9e35094420c723/attachments/71c918b7ff525444' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch( `https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}/attachments/${attId}`, { headers: { Authorization: `Bearer ${apiKey}` } }, ); const { data } = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId . '/attachments/' . $attId; $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 // Note attachments live on the ticket row and are NOT fetched from the reply endpoint. $att = Api::Tickets()->GetTicketNoteAttachment([ 'id' => $id, 'note_id' => $noteId, 'att_id' => $attId, ])['data']; ``` #### Deleting a Note Attachment delete/api/v1/admin/tickets/{id}/notes/{note_id}/attachments/{att_id} `Tickets/DeleteTicketNoteAttachment` admin Removes a note attachment from its record and from disk. Response fields data — 2 deletedboolWhether the delete ran. attachment_idstringThe id of the attachment removed. Errors 2 not_found404No such ticket, note or attachment. 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/tickets/402/notes/1d9e35094420c723/attachments/71c918b7ff525444' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch( `https://panel.example.com/api/v1/admin/tickets/${id}/notes/${noteId}/attachments/${attId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` } }, ); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tickets/' . $id . '/notes/' . $noteId . '/attachments/' . $attId; $ch = curl_init($url); 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 // The file goes while the note stays; deleting the note takes its files along. Api::Tickets()->DeleteTicketNoteAttachment([ 'id' => $id, 'note_id' => $noteId, 'att_id' => $attId, ]); ``` #### Listing the Secrets get/api/v1/admin/tickets/{id}/private-data `Tickets/GetTicketPrivateData` admin content comes back in the clear Returns the labelled secrets kept on a ticket. Response fields data[] — 5 idstringThe item id. labelstringWhat the item is. contentstringThe item itself. Stored encrypted and handed back decoded. reply_idintThe reply it belongs to. Zero means it belongs to the ticket as a whole. created_atstring | nullWhen it was added. Errors 2 not_found404No such ticket. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/402/private-data' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/private-data`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/private-data'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The content comes back IN THE CLEAR: a client that logs this call logs the password too. $items = Api::Tickets()->GetTicketPrivateData(['id' => $id])['data']; ``` #### Adding a Secret post/api/v1/admin/tickets/{id}/private-data `Tickets/AddTicketPrivateData` admin Adds a labelled secret to a ticket. Body 3 labelstringreqWhat the item is. contentstringreqThe item itself. It gets encrypted on the way in. reply_idintTies it to a particular reply. Response fields 201 — data dataobjectThe item added. Same shape as a list item. Errors 3 label_content_required422The label or the content is empty. not_found404No such ticket. 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/tickets/402/private-data' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"label":"Sunucu erisimi","content":"host: 203.0.113.10"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/private-data`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ label: 'Server SSH', content: 'host: 203.0.113.10\nuser: admin', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/private-data'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'label' => 'Server SSH', 'content' => "host: 203.0.113.10\nuser: admin", ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Give a reply id to tie it there; zero leaves it on the ticket as a whole. Api::Tickets()->AddTicketPrivateData([ 'id' => $id, 'label' => 'Server SSH', 'content' => $credentials, 'reply_id' => $replyId, ]); ``` #### Deleting a Secret delete/api/v1/admin/tickets/{id}/private-data/{item_id} `Tickets/DeleteTicketPrivateData` admin Removes one secret from a ticket. Response fields data — 2 deletedboolWhether the delete ran. item_idstringThe id of the item removed. Errors 2 not_found404No such ticket or item. 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/tickets/402/private-data/pd_65a4f0c8a1b23' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/${id}/private-data/${itemId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/' . $id . '/private-data/' . $itemId); 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 // REMOVING credentials once a ticket closes is a good habit; the record otherwise stays. Api::Tickets()->DeleteTicketPrivateData(['id' => $id, 'item_id' => $itemId]); ``` ### Pitfalls > **Secrets come back as plain text** > > These items sit encrypted in the database, yet the read endpoint hands the content back **decoded**. A key carrying this scope reads whatever server passwords are stored there. A client that logs its requests and responses logs the password with them, so keep the key narrow and keep this call's body out of your logs. > **All of it sits in one encrypted block** > > Notes and secrets are not separate rows but **one encrypted field** on the ticket row. Every write saves that whole block again. When two requests add a note at the same moment the second can write over the first, so avoid writing to one ticket in parallel during batch work. > **The ids are not positions** > > Notes and their attachments are addressed by a **string**, not by where they sit in the list. The older panel used the position, and adding or removing a note shifted it. Keep a position on your side and one day you will update a different note, so store the id that comes back. > **An empty update is an error** > > Send no field at all on a note update and the call is **refused**. The ticket update does not behave that way, where an unchanged field passes quietly. A sync script that forwards whatever it holds hits an error here when nothing changed. Check you really have a field before you send. > **A merge does not carry notes and secrets across** > > Merging tickets moves replies and attachments to the main ticket and then **deletes** the other ticket rows. Notes and secrets live on those rows, so they go with them. The same holds when you delete a ticket. If the only copy of a server password sits there, read it and write it onto the main ticket before you merge. ### Related Articles - [Ticket Replies](https://dev.wisecp.com/en/ticket-replies) - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Ticket Custom Field Definitions](https://dev.wisecp.com/en/ticket-custom-field-definitions) ## Ticket Custom Field Definitions https://dev.wisecp.com/en/ticket-custom-field-definitions The five endpoints that define and change the custom fields on the ticket form. ### Overview These five endpoints define the **extra form fields** a client fills in when opening a support ticket. The type, whether it is required, where it sits and which department it belongs to are structural; the label, description and option names are kept **per language**. What lives here are **definitions** rather than answers. The values a client types sit encrypted on the ticket's own record and show up in the ticket detail, and these endpoints never touch them. The write contract is unusual: the update wants the **whole definition** despite its name. Translations and options are written again for every language. ### Reference #### Listing the Fields get/api/v1/admin/tickets/custom-fields `Tickets/GetTicketCustomFields` admin Returns the custom fields on the ticket form, in order. Query 2 department_idintFilters by department. Zero gives the fields open to every department. statusstringFilters the live ones or the switched-off ones. Response fields data[] — 8 + meta idintThe field id. department_idintThe department it shows in. departmentstringThe department name. Empty when the field is open to all. statusstringWhether the field is live. rankintWhere it sits on the form. typestringThe field type: `text`, `textarea`, `password`, `select`, `radio`, `checkbox`. namestringThe field label. Only in the panel's current language. requiredboolWhether it has to be filled in. totalintHow many fields there are. It comes back under meta. Errors 2 invalid_status422The status filter is not recognised. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/custom-fields?status=active' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tickets/custom-fields'); url.searchParams.set('status', 'active'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-fields?' . http_build_query(['status' => 'active'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list carries ONE LANGUAGE; read a field on its own to see every translation. $fields = Api::Tickets()->GetTicketCustomFields([], ['status' => 'active'])['data']; ``` #### Creating a Field post/api/v1/admin/tickets/custom-fields `Tickets/CreateTicketCustomField` admin every language needed Adds a field to the ticket form and writes its translations. Body 7 typestringreqThe field type: `text`, `textarea`, `password`, `select`, `radio`, `checkbox`. department_idintThe department it shows in. Zero means every department. statusstringWhether the field is live. Live by default. requiredboolWhether the client has to fill it in. rankintWhere it sits on the form. Zero appends it to the end. translationsobjectreqThe label and description per language. The label is needed in every live language. optionsobjectThe option labels per language. Needed for the choice types, and the labels pair across languages by position. Response fields 201 — data + meta dataobjectThe field created. Same shape as the detail endpoint. created_idintThe new field id. It comes back under meta. Errors 6 invalid_type422The field type is not recognised. name_required422The label is missing in one of the live languages. option_label_required422An option row is filled in one language and empty in another. options_required422A choice-type field has no options. vetoed422A hook refused the save. 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/tickets/custom-fields' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"select","required":true,"translations":{"tr":{"name":"Sunucu bolgesi"}},"options":{"tr":["Avrupa","Kuzey Amerika"]}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/custom-fields', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'select', required: true, translations: { en: { name: 'Server location', description: '' } }, options: { en: ['Europe', 'North America'] }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-fields'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'select', 'required' => true, 'translations' => ['en' => ['name' => 'Server location', 'description' => '']], 'options' => ['en' => ['Europe', 'North America']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The ORDER of options is their identity: write the same option at the same place in each language. Api::Tickets()->CreateTicketCustomField([ 'type' => 'select', 'translations' => [ 'en' => ['name' => 'Server location'], 'tr' => ['name' => 'Sunucu bolgesi'], ], 'options' => [ 'en' => ['Europe', 'North America'], 'tr' => ['Avrupa', 'Kuzey Amerika'], ], ]); ``` #### Reading One Field get/api/v1/admin/tickets/custom-fields/{fid} `Tickets/GetTicketCustomField` admin Returns a field with all of its translations. Response fields data — 8 idintThe field id. department_idintThe department it shows in. statusstringWhether the field is live. rankintWhere it sits on the form. typestringThe field type: `text`, `textarea`, `password`, `select`, `radio`, `checkbox`. requiredboolWhether it has to be filled in. has_optionsboolWhether the type carries options. translationsobjectThe label, description and options per language. Options arrive with their own ids and names. Errors 2 not_found404No such field. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/custom-fields/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/custom-fields/${fid}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-fields/' . $fid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read this BEFORE updating: the write call wants the WHOLE definition. $field = Api::Tickets()->GetTicketCustomField(['fid' => $fid])['data']; ``` #### Updating a Field patch/api/v1/admin/tickets/custom-fields/{fid} `Tickets/UpdateTicketCustomField` admin wants the whole definition Writes a field's definition again. Body 7 typestringreqThe field type: `text`, `textarea`, `password`, `select`, `radio`, `checkbox`. department_idintThe department it shows in. Zero means every department. statusstringWhether the field is live. Live by default. requiredboolWhether the client has to fill it in. rankintWhere it sits on the form. Zero appends it to the end. translationsobjectreqThe label and description per language. The label is needed in every live language. optionsobjectThe option labels per language. Needed for the choice types, and the labels pair across languages by position. Response fields data — 8 dataobjectThe field as it now stands. Same shape as the detail endpoint. Errors 6 not_found404No such field. invalid_type422The field type is not recognised. name_required422The label is missing in one of the live languages. option_label_required422An option row is filled in one language and empty in another. options_required422A choice-type field has no options. vetoed422A hook refused the save. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PATCH 'https://panel.example.com/api/v1/admin/tickets/custom-fields/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"select","status":"inactive","translations":{"tr":{"name":"Sunucu bolgesi"}},"options":{"tr":["AB","ABD","Asya"]}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/custom-fields/${fid}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'select', status: 'inactive', translations: { en: { name: 'Server region', description: '' } }, options: { en: ['EU', 'US', 'Asia'] }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-fields/' . $fid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'select', 'status' => 'inactive', 'translations' => ['en' => ['name' => 'Server region', 'description' => '']], 'options' => ['en' => ['EU', 'US', 'Asia']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Even to flip one switch, send the WHOLE definition; whatever you leave out is dropped. $cur = Api::Tickets()->GetTicketCustomField(['fid' => $fid])['data']; $cur['fid'] = $fid; $cur['status'] = 'inactive'; Api::Tickets()->UpdateTicketCustomField($cur); ``` #### Deleting a Field delete/api/v1/admin/tickets/custom-fields/{fid} `Tickets/DeleteTicketCustomField` admin Removes a field and every one of its language records. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the field removed. Errors 3 not_found404No such field. vetoed422A hook refused the delete. 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/tickets/custom-fields/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/custom-fields/${fid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-fields/' . $fid); 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 // To take it off the form, SWITCH IT OFF rather than delete: old answers keep their label. Api::Tickets()->UpdateTicketCustomField($cur + ['status' => 'inactive']); ``` ### Pitfalls > **The update is not partial** > > Whatever the method name suggests, the body is the **whole definition**. Translations and options are written again for every language, so a language or an option you leave out is dropped. To change one key, read the detail first, adjust it and send the whole thing back. > **Options pair by position** > > An option's identity is **where it sits**: the first label in each language names the same option. Reordering one language renames a different option in that language, and nothing warns you. A row filled in one language and empty in another is refused outright. > **The label is needed in every live language** > > A save wants a label in **every live language** on the installation. A script that sends only your own language stops working the day a second one is switched on. Read the list from the settings rather than writing it into the code, because it changes. > **Two zeros, two different meanings** > > Zero in the department field means **every department**, so the field shows on every ticket form. Zero in the rank field does not mean first; it **appends to the end**. Mixing them up gives you a field everyone sees, sitting at the bottom of the list. > **Deleting a definition does not delete the answers** > > A delete removes only the definition and its translations. Values clients filled in earlier **stay** on their own ticket records, with no label left to name them. When you want the field off the form, switch it off rather than delete it: past tickets stay readable that way. ### Related Articles - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) - [Internal Notes and Secrets](https://dev.wisecp.com/en/internal-notes-and-secrets) ## Ticket Reference Lists https://dev.wisecp.com/en/ticket-reference-lists Four lists that supply the values a ticket write accepts, and one figures endpoint. ### Overview None of these five endpoints writes anything; they tell you **which values are valid** when you do. The department, staff, priority and status lists are where the values accepted by the ticket write endpoints come from. Four of them vary by installation. There are as many departments as an operator set up. Priority labels come from a language file, and an installation can add statuses of its own. So do not **write any of them into your code**: the list will not hold on another installation. The fifth is a **summary** for the period you pick. It covers how many tickets are open and solved, how long answers and solutions take, and what clients scored. ### Reference #### Listing the Departments get/api/v1/admin/tickets/departments `Tickets/GetTicketDepartments` admin Returns the departments a ticket can go to. Response fields data[] — 5 idintThe department id. This is what a ticket write takes. namestringThe department name. descriptionstringWhat it is for. iconstringIts icon. icon_typestringWhat kind of icon it is. 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/tickets/departments' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/departments', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/departments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The names arrive in the INSTALLATION's language; do not expect them translated for you. $departments = Api::Tickets()->GetTicketDepartments()['data']; ``` #### Listing the Staff You Can Assign get/api/v1/admin/tickets/assignable-staff `Tickets/GetTicketAssignableStaff` admin Returns the staff a ticket can be assigned to. Response fields data[] — 3 idintThe staff id. This is what an assignment takes. full_namestringTheir name. emailstringTheir e-mail address. 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/tickets/assignable-staff' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/assignable-staff', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/assignable-staff'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This is NOT every administrator: only people named as handlers on a department come back. $staff = Api::Tickets()->GetTicketAssignableStaff()['data']; ``` #### Listing the Priorities get/api/v1/admin/tickets/priorities `Tickets/GetTicketPriorities` admin Returns the priorities a ticket can carry. Response fields data[] — 2 valueintThe priority value. This is the number a ticket write takes. labelstringWhat it is called. 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/tickets/priorities' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/priorities', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/priorities'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The label comes from a language file; hard-code the numbers rather than the NAMES. $priorities = Api::Tickets()->GetTicketPriorities()['data']; ``` #### Listing the Statuses get/api/v1/admin/tickets/statuses `Tickets/GetTicketStatuses` admin Returns the standard statuses together with the installation's own. Response fields data[] — 8 keystringThe status key. A plain word for a standard one, and the base joined to an id for a custom one. namestringThe name it shows under. typestringWhether it is standard or custom. badgestringThe badge class. Standard statuses only. iconstringIts icon. Standard statuses only. idintThe custom status id. Custom statuses only. basestringThe standard status it sits on. Custom statuses only. colorstringIts colour. Custom statuses only. 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/tickets/statuses' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/statuses', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const standard = data.filter((s) => s.type === 'standard'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/statuses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // They go to two different fields: the standard one to the status, the custom one to its own. $all = Api::Tickets()->GetTicketStatuses()['data']; $custom = array_filter($all, fn ($s) => $s['type'] === 'custom'); ``` #### Reading the Figures get/api/v1/admin/tickets/stats `Tickets/GetTicketStats` admin Returns support counts and speed figures for the period you pick. Query 1 periodstringWhich period: `today`, `yesterday`, `this_week`, `last_week`, `this_month`, `last_month`, `last_3_months`, `last_6_months`, `this_year`, `last_year`, `all_time`. All time by default. Response fields data — 10 + meta openintHow many are open. pendingintHow many wait on the client. answeredintHow many have been answered. resolvedintHow many are solved. totalintHow many there were in the period. avg_responsestringThe average time to answer. Readable text rather than a number. avg_resolutionstringThe average time to solve. Readable text as well. resolution_ratenumberThe share that got solved. avg_ratingnumber | nullThe average satisfaction score. Empty when nobody scored. rating_countintHow many scores were given. periodstringThe period the figures cover. It comes back under meta. 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/tickets/stats?period=this_month' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tickets/stats'); url.searchParams.set('period', 'this_month'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/stats?' . http_build_query(['period' => 'this_month'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The time fields are READABLE TEXT ('2h 5m'); parse them yourself before charting. $s = Api::Tickets()->GetTicketStats([], ['period' => 'this_month'])['data']; $rate = $s['resolution_rate']; ``` ### Pitfalls > **Assignable staff is not every administrator** > > This list returns only the people **named as handlers** on a department. Not every administrator who can sign in appears here. Take an id from your own administrator list and the assignment fails as an invalid staff member. Pick the value from here instead. > **The status list feeds two different fields** > > The list returns standard statuses and the installation's own **together**, yet the two go to different fields. The standard one goes to the status, and the custom one by its id to the custom status field. Putting a custom key into the status field gives an invalid status error. > **The time fields are text, not numbers** > > The average answer and solve times arrive as **readable text**, so you cannot chart or add them as they come. Parse them yourself when you need a number. In the same summary the solve share and the score count are real numbers and go straight into a calculation. > **The labels come in the installation's language** > > Department names, priority labels and status names arrive in the **installation's current language**; they are text to show rather than an identity. Base decisions on the numeric values and keys instead of the names, or your logic quietly slips the day the language changes. > **An empty period comes back as zeros** > > For a period with no tickets the counts come back as zero, the times as zero minutes and the average score empty. Those are **not signs of success**: a zero solve time does not mean you were fast, it means there was no work. Check the total before you put any of it on a dashboard. ### Related Articles - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Ticket Settings](https://dev.wisecp.com/en/ticket-settings) - [Ticket Custom Field Definitions](https://dev.wisecp.com/en/ticket-custom-field-definitions) ## Ticket Announcements https://dev.wisecp.com/en/ticket-announcements The five endpoints that publish and manage the announcements shown in the support area. ### Overview Announcements are banners hung across the **support area as a whole** rather than on one ticket. They carry things like planned maintenance, a passing outage or news of a new service. A client sees them on reaching the support page. Who sees one is narrowed by **four targeting fields**: country, language, product group and server. Leave them all out and the announcement is open to everyone; fill one in and only clients matching it see it. When it shows comes from **two dates and a switch**. The dates draw the window and the switch turns the whole thing on or off, and the two work together. ### Reference #### Listing the Announcements get/api/v1/admin/tickets/announcements `Tickets/GetTicketAnnouncements` admin Returns the announcements shown in the support area. Response fields data[] — 13 idintThe announcement id. titlestringThe announcement title. messagestringThe announcement text. typestringThe banner tone: `info`, `warning`, `danger`, `success`. countrystring | nullThe country aimed at. Empty shows it to every country. langstring | nullThe language aimed at. Empty shows it in every language. product_groupintThe product group aimed at. Zero means every group. server_idintThe server aimed at. Zero means every server. start_datestring | nullWhen it starts showing. end_datestring | nullWhen it stops showing. is_popupboolWhether it opens as a window instead of a banner. statusboolWhether the announcement is live. created_atstring | nullWhen it was created. 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/tickets/announcements' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/announcements', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/announcements'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list is NOT filtered: past, future and switched-off announcements come back too. $all = Api::Tickets()->GetTicketAnnouncements()['data']; $live = array_filter($all, fn ($a) => $a['status']); ``` #### Creating an Announcement post/api/v1/admin/tickets/announcements `Tickets/CreateTicketAnnouncement` admin goes live at once Publishes a new announcement for clients to see. Body 11 titlestringreqThe announcement title. messagestringreqThe announcement text. typestringThe banner tone: `info`, `warning`, `danger`, `success`. The info tone by default. countrystringShows it to this country only. langstringShows it in this language only. product_groupintShows it to this product group only. server_idintShows it to clients on this server only. start_datestringWhen it starts showing. end_datestringWhen it stops showing. is_popupboolOpens it as a window. statusboolPuts the announcement live. On by default. Response fields 201 — data dataobjectThe announcement created. Same shape as a list item. Errors 3 title_required422The title is empty. message_required422The message is empty. 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/tickets/announcements' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Planli bakim","message":"02:00-03:00 arasi kesinti olacak.","type":"warning"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/announcements', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Scheduled maintenance', message: 'We will be down between 02:00 and 03:00.', type: 'warning', is_popup: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/announcements'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'title' => 'Scheduled maintenance', 'message' => 'We will be down between 02:00 and 03:00.', 'type' => 'warning', 'is_popup' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Leave the status out and the announcement is born LIVE and shows at once; set dates first. Api::Tickets()->CreateTicketAnnouncement([ 'title' => 'Scheduled maintenance', 'message' => $text, 'start_date' => '2026-02-01 02:00:00', 'end_date' => '2026-02-01 03:00:00', ]); ``` #### Reading One Announcement get/api/v1/admin/tickets/announcements/{aid} `Tickets/GetTicketAnnouncement` admin Returns a single announcement. Response fields data — 13 idintThe announcement id. titlestringThe announcement title. messagestringThe announcement text. typestringThe banner tone: `info`, `warning`, `danger`, `success`. countrystring | nullThe country aimed at. Empty shows it to every country. langstring | nullThe language aimed at. Empty shows it in every language. product_groupintThe product group aimed at. Zero means every group. server_idintThe server aimed at. Zero means every server. start_datestring | nullWhen it starts showing. end_datestring | nullWhen it stops showing. is_popupboolWhether it opens as a window instead of a banner. statusboolWhether the announcement is live. created_atstring | nullWhen it was created. Errors 2 not_found404No such announcement. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/announcements/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/announcements/${aid}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/announcements/' . $aid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Whether an announcement shows RIGHT NOW comes from the status and the dates together. $a = Api::Tickets()->GetTicketAnnouncement(['aid' => $aid])['data']; ``` #### Updating an Announcement patch/api/v1/admin/tickets/announcements/{aid} `Tickets/UpdateTicketAnnouncement` admin Changes the announcement fields you send. Body 11 titlestringreqThe announcement title. messagestringreqThe announcement text. typestringThe banner tone: `info`, `warning`, `danger`, `success`. The info tone by default. countrystringShows it to this country only. langstringShows it in this language only. product_groupintShows it to this product group only. server_idintShows it to clients on this server only. start_datestringWhen it starts showing. end_datestringWhen it stops showing. is_popupboolOpens it as a window. statusboolPuts the announcement live. On by default. Response fields data — 13 dataobjectThe announcement as it now stands. Same shape as a list item. Errors 4 title_required422The title was emptied. message_required422The message was emptied. not_found404No such announcement. 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/tickets/announcements/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/announcements/${aid}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: false }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/announcements/' . $aid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // What you leave out is KEPT; switch the status off to silence one without removing it. Api::Tickets()->UpdateTicketAnnouncement(['aid' => $aid, 'status' => false]); ``` #### Deleting an Announcement delete/api/v1/admin/tickets/announcements/{aid} `Tickets/DeleteTicketAnnouncement` admin Removes an announcement. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the announcement removed. Errors 2 not_found404No such announcement. 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/tickets/announcements/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/announcements/${aid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/announcements/' . $aid); 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 // Rather than delete a past announcement, give it an END DATE: the record stays, the showing stops. Api::Tickets()->DeleteTicketAnnouncement(['aid' => $aid]); ``` ### Pitfalls > **A new announcement goes live at once** > > Leave the status field out and the announcement is created **live**. With no dates either, clients begin seeing it that moment. There is no notion of a draft here. While preparing one, either send the status off or put the start date on a later day. > **The list arrives unfiltered** > > The listing endpoint returns **every announcement**, including ones that have expired, ones that have yet to start and ones switched off. It gives the set an operator manages rather than the set a client sees. To count what is live on a dashboard, weigh the status and both dates yourself. > **The targeting fields narrow rather than widen** > > Country, language, product group and server each **shrink** the audience. Filling more than one means all of them at once rather than any of them. A client who fails to match all four sees nothing. When an announcement reaches nobody, look at these fields first. > **The window option interrupts** > > An announcement that opens as a window **interrupts** the client and stands in front of them until closed. For news that is not truly urgent, the banner tone does the job. An old announcement left as a window turns up months later in front of someone it no longer concerns. An end date prevents that. > **Date it rather than delete it** > > Deleting a past announcement **wipes it out**, leaving no record of what you said and when. Giving it an end date or switching the status off stops the showing and leaves the record in place. When the same maintenance window comes round next month, updating the dates beats writing the text again. ### Related Articles - [Ticket Settings](https://dev.wisecp.com/en/ticket-settings) - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) ## Ticket Settings https://dev.wisecp.com/en/ticket-settings The six endpoints for the support area's settings, its e-mail setup and its custom statuses. ### Overview These six endpoints decide **how the support area behaves**. Two hold the general settings, two configure tickets arriving by e-mail, and two manage the statuses an installation defines for itself. Among the general settings are three **blocking switches**: for the blacklisted, for those with no service, and for unverified accounts. Each stops a client opening a ticket, so this is the first place to look when the support queue falls unexpectedly quiet. Custom statuses **sit on top of** the standard ones. A status such as "Awaiting parts" shows with its own colour and name, while for the workflow it behaves as whichever standard status it was built on. ### Reference #### Reading the Support Settings get/api/v1/admin/tickets/settings `Tickets/GetTicketSettings` admin Returns the general settings of the support area. Response fields data — 11 show_firstintWhich end of the conversation comes first. member_groupintThe client group allowed to open support requests. ticket_claimingboolWhether staff can take a ticket for themselves. assigned_tickets_onlyboolWhether staff see only what is assigned to them. block_blacklistedboolWhether a blacklisted client is kept from opening one. block_without_serviceboolWhether a client with no service is kept from opening one. verification_requiredboolWhether the account has to be verified first. technical_departmentintThe department technical matters go to. billing_departmentintThe department billing matters go to. listing_countintHow many tickets a panel page shows. refresh_timeintHow often the panel refreshes the list, in seconds. 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/tickets/settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Three blocking settings stop a CLIENT opening one; check these first when support goes quiet. $s = Api::Tickets()->GetTicketSettings()['data']; ``` #### Writing the Support Settings put/api/v1/admin/tickets/settings `Tickets/UpdateTicketSettings` admin Changes the general support settings. Body 11 show_firstintWhich end of the conversation comes first. member_groupintThe client group allowed to open support requests. ticket_claimingboolWhether staff can take a ticket for themselves. assigned_tickets_onlyboolWhether staff see only what is assigned to them. block_blacklistedboolWhether a blacklisted client is kept from opening one. block_without_serviceboolWhether a client with no service is kept from opening one. verification_requiredboolWhether the account has to be verified first. technical_departmentintThe department technical matters go to. billing_departmentintThe department billing matters go to. listing_countintTickets per page. Clamped between one and a hundred. refresh_timeintThe refresh interval. Clamped between five and three hundred seconds. Response fields data — 11 dataobjectThe settings as they now stand. Same shape as the read endpoint. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"listing_count":25,"refresh_time":60,"ticket_claiming":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ listing_count: 25, refresh_time: 60, ticket_claiming: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'listing_count' => 25, 'refresh_time' => 60, 'ticket_claiming' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An out-of-range value is NOT refused but pulled to the limit; read back what returned. $now = Api::Tickets()->UpdateTicketSettings(['refresh_time' => 1])['data']; // $now['refresh_time'] === 5 ``` #### Reading the Mail Settings get/api/v1/admin/tickets/pipe-settings `Tickets/GetTicketPipeSettings` admin Returns how tickets arriving by e-mail are set up. Response fields data — 5 enabledboolWhether tickets can arrive by e-mail. methodintHow an incoming message is matched to a client. spam_controlboolWhether incoming mail goes through a spam check. prefixstringThe reference prefix in the subject line. This is what brings a reply back to the right ticket. departmentsobjectThe mailbox settings per department: the sending address, the shown name and the provider. 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/tickets/pipe-settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/pipe-settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/pipe-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This is configuration ONLY; authorising a mailbox does not happen through this endpoint. $pipe = Api::Tickets()->GetTicketPipeSettings()['data']; ``` #### Writing the Mail Settings put/api/v1/admin/tickets/pipe-settings `Tickets/UpdateTicketPipeSettings` admin Changes how tickets arriving by e-mail are set up. Body 5 enabledboolLets tickets arrive by e-mail. methodintHow a client is matched. An unknown value falls back to zero. spam_controlboolTurns the spam check on. prefixstringThe reference prefix. Left empty, the default prefix is used. departmentsobjectThe mailbox per department: sending address, shown name and provider. Response fields data — 5 dataobjectThe mail settings as they now stand. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/pipe-settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true,"method":1,"spam_control":true,"prefix":"REF"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/pipe-settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true, method: 1, spam_control: true, prefix: 'REF', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/pipe-settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'enabled' => true, 'method' => 1, 'spam_control' => true, 'prefix' => 'REF', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // CHANGING the prefix leaves replies carrying the old subject unable to find their ticket. Api::Tickets()->UpdateTicketPipeSettings(['prefix' => 'REF']); ``` #### Listing the Custom Statuses get/api/v1/admin/tickets/custom-statuses `Tickets/GetTicketCustomStatuses` admin Returns the ticket statuses the installation defined for itself. Response fields data[] — 5 idintThe status id. typestringThe standard status it sits on: `open`, `waiting`, `process`, `replied`, `solved`. colorstringThe badge colour. namestringIts name in the current language. langsobjectIts name per language. 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/tickets/custom-statuses' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/custom-statuses', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-statuses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A custom status sits ON TOP of a standard one: the workflow follows the base. $custom = Api::Tickets()->GetTicketCustomStatuses()['data']; ``` #### Writing the Custom Statuses put/api/v1/admin/tickets/custom-statuses `Tickets/UpdateTicketCustomStatuses` admin wants the whole list Replaces every custom status with the list you send. Body 4 statusesarrayreqThe complete set of statuses. The old ones go and these are written. statuses[].typestringThe standard status it sits on: `open`, `waiting`, `process`, `replied`, `solved`. An unknown value falls back to the in-process one. statuses[].colorstringThe badge colour. statuses[].langsobjectIts name per language. Languages left empty are skipped. Response fields data[] — 5 dataobject[]The custom statuses as they now stand. Same shape as the listing endpoint. Errors 2 invalid_statuses422What was sent is not a list. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tickets/custom-statuses' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"statuses":[{"type":"process","color":"#3399ff","langs":{"tr":"Parca bekleniyor"}}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/custom-statuses', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ statuses: [ { type: 'process', color: '#3399ff', langs: { en: 'Awaiting parts', tr: 'Parca bekleniyor' }, }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/custom-statuses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'statuses' => [[ 'type' => 'process', 'color' => '#3399ff', 'langs' => ['en' => 'Awaiting parts'], ]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Even to add one, read the CURRENT list and append; the write is a full replacement. $all = Api::Tickets()->GetTicketCustomStatuses()['data']; $all[] = ['type' => 'waiting', 'color' => '#999999', 'langs' => ['en' => 'On hold']]; Api::Tickets()->UpdateTicketCustomStatuses(['statuses' => $all]); ``` ### Pitfalls > **Writing custom statuses replaces everything** > > The write endpoint **replaces the whole set** with what you send: the existing statuses go and yours are added in their place. Sending only the one you meant to add removes the rest. Read the current list first, append to it and send the whole thing back. > **Rewriting changes the status ids** > > Because a full replacement removes the old rows and inserts new ones, custom statuses **get new ids**. If you keep the id assigned to a ticket on your side, that number may now point at a different status. Resolve from the list each time rather than storing ids. > **Three switches quietly stop a client** > > With the blacklist, no-service and verification switches on, a client **cannot open a ticket at all**. Nothing shows as an error in the panel, the queue stays empty, and that is easy to read as a fault. When no tickets arrive on an installation, read these three fields first. > **Changing the prefix cuts old replies loose** > > Which ticket an incoming e-mail belongs to is read from the **reference prefix** in the subject line. Change the prefix and replies still carrying the old subject cannot find their ticket, landing as new ones instead. Leave the prefix alone while conversations are in flight. > **An out-of-range value is clipped rather than refused** > > The page size and the refresh interval are **pulled** into set ranges. Asking for a one-second refresh raises no error; the setting quietly lands on the lowest value allowed. Do not assume what you sent was written, and read the value that comes back. ### Related Articles - [Ticket Automatic Tasks](https://dev.wisecp.com/en/ticket-automatic-tasks) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) ## Ticket Automatic Tasks https://dev.wisecp.com/en/ticket-automatic-tasks The five endpoints that set up and manage the rules running on tickets by themselves. ### Overview Automatic tasks are rules that run on tickets **by themselves**. Every task has two halves: the **conditions** saying which tickets it looks at, and the **results** saying what it does to them. The conditions are department, status, priority and waiting time. The results are moving a ticket, turning its status, changing its priority, assigning it, locking it and replying to the client. A task has to carry **at least one field from each half**. The commonest use is closing solved tickets after a while. Take care when a task sends a reply: the rule **mails the client** on every ticket it matches. ### Reference #### Listing the Tasks get/api/v1/admin/tickets/auto-tasks `Tickets/GetTicketAutoTasks` admin Returns the rules that run on tickets by themselves. Response fields data[] — 14 idintThe task id. namestringThe task name. departmentsstring[]Condition: which departments. Empty means it does not care. statusesstring[]Condition: which statuses. prioritiesint[]Condition: which priorities. delay_timeintCondition: how long the ticket has been waiting. departmentintResult: moves the ticket to this department. statusstringResult: turns the status into this. priorityintResult: sets the priority to this. assign_tointResult: assigns the ticket to this person. mark_lockedboolResult: locks the ticket. templatestringResult: the prepared reply to use. repeat_actionboolWhether it runs again each time the condition holds. replyobjectResult: the reply text to send, per language. 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/tickets/auto-tasks' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/auto-tasks', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/auto-tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Each row carries both CONDITION and RESULT fields side by side; read them apart. $tasks = Api::Tickets()->GetTicketAutoTasks()['data']; ``` #### Creating a Task post/api/v1/admin/tickets/auto-tasks `Tickets/CreateTicketAutoTask` admin condition and result both needed Sets up a new rule to run on tickets. Body 13 namestringreqThe task name. departmentsstring[]**Condition**: tickets in these departments. statusesstring[]**Condition**: tickets in these statuses. prioritiesint[]**Condition**: tickets at these priorities. delay_timeint**Condition**: tickets waiting this long. departmentint**Result**: move to this department. statusstring**Result**: turn the status into this. priorityint**Result**: set the priority to this. assign_toint**Result**: assign it to this person. mark_lockedbool**Result**: lock the ticket. templatestring**Result**: use this prepared reply. replyobject**Result**: send this text, per language. repeat_actionboolRuns it again each time the condition holds. Response fields 201 — data dataobjectThe task created. Same shape as a list item. Errors 4 name_required422The task name is empty. trigger_required422No condition was given. action_required422No result was given. 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/tickets/auto-tasks' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Cozulenleri kapat","statuses":["solved"],"delay_time":72,"mark_locked":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tickets/auto-tasks', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Close solved tickets', statuses: ['solved'], // kosul delay_time: 72, // kosul mark_locked: true, // sonuc }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/auto-tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'name' => 'Close solved tickets', 'statuses' => ['solved'], 'delay_time' => 72, 'mark_locked' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // At least ONE condition and ONE result are needed; sending only conditions gives a 422. Api::Tickets()->CreateTicketAutoTask([ 'name' => 'Close solved tickets', 'statuses' => ['solved'], // condition 'delay_time' => 72, // condition 'mark_locked' => true, // result ]); ``` #### Reading One Task get/api/v1/admin/tickets/auto-tasks/{tid} `Tickets/GetTicketAutoTask` admin Returns a single automatic task. Response fields data — 14 idintThe task id. namestringThe task name. departmentsstring[]Condition: which departments. Empty means it does not care. statusesstring[]Condition: which statuses. prioritiesint[]Condition: which priorities. delay_timeintCondition: how long the ticket has been waiting. departmentintResult: moves the ticket to this department. statusstringResult: turns the status into this. priorityintResult: sets the priority to this. assign_tointResult: assigns the ticket to this person. mark_lockedboolResult: locks the ticket. templatestringResult: the prepared reply to use. repeat_actionboolWhether it runs again each time the condition holds. replyobjectResult: the reply text to send, per language. Errors 2 not_found404No such task. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tickets/auto-tasks/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/auto-tasks/${tid}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/auto-tasks/' . $tid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An empty condition field does NOT narrow the task, it widens it: every department matches. $task = Api::Tickets()->GetTicketAutoTask(['tid' => $tid])['data']; ``` #### Updating a Task patch/api/v1/admin/tickets/auto-tasks/{tid} `Tickets/UpdateTicketAutoTask` admin Changes the task fields you send. Body 13 namestringreqThe task name. departmentsstring[]**Condition**: tickets in these departments. statusesstring[]**Condition**: tickets in these statuses. prioritiesint[]**Condition**: tickets at these priorities. delay_timeint**Condition**: tickets waiting this long. departmentint**Result**: move to this department. statusstring**Result**: turn the status into this. priorityint**Result**: set the priority to this. assign_toint**Result**: assign it to this person. mark_lockedbool**Result**: lock the ticket. templatestring**Result**: use this prepared reply. replyobject**Result**: send this text, per language. repeat_actionboolRuns it again each time the condition holds. Response fields data — 14 dataobjectThe task as it now stands. Same shape as a list item. Errors 5 name_required422The task name was emptied. trigger_required422No condition remains after the change. action_required422No result remains after the change. not_found404No such task. 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/tickets/auto-tasks/3' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"delay_time":48}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/auto-tasks/${tid}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ delay_time: 48 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/auto-tasks/' . $tid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['delay_time' => 48]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The condition-and-result rule is checked on the MERGED set: emptying the last one fails. Api::Tickets()->UpdateTicketAutoTask(['tid' => $tid, 'delay_time' => 48]); ``` #### Deleting a Task delete/api/v1/admin/tickets/auto-tasks/{tid} `Tickets/DeleteTicketAutoTask` admin Removes an automatic task. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the task removed. Errors 2 not_found404No such task. 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/tickets/auto-tasks/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/tickets/auto-tasks/${tid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tickets/auto-tasks/' . $tid); 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 // There is NO switch to pause a task; deleting it is the only way to stop it. Api::Tickets()->DeleteTicketAutoTask(['tid' => $tid]); ``` ### Pitfalls > **A condition and a result are both needed** > > A task has to carry at least one **condition** and at least one **result**. Sending conditions alone gives a match that does nothing, and sending results alone would give a rule applying to every ticket. Either one missing means the call is refused. > **An empty condition widens rather than narrows** > > A condition field left empty **draws no distinction**: an empty department list matches tickets in every department. Reading that as "none" leads to writing a rule that touches the whole installation. To narrow the reach, fill the field in rather than leaving it empty. > **The reply result really sends mail** > > A task with the reply field filled writes that text on **every ticket** matching the condition, and mail goes to the client. Paired with a wide condition, one rule change reaches hundreds of clients at once. Try a new reply rule against a narrow condition first. > **The repeat option makes a rule ongoing** > > With repeat on, a task runs again **every time** the condition holds. On a rule that replies, that can mean the same text reaching the same client over and over. Leave repeat off for work that should happen once. > **A task cannot be paused** > > Automatic tasks have **no live switch**; deleting a rule is the only way to stop it. To pause one, save its definition on your side, delete it and create it again later. The recreated task comes back with a **new id**. ### Related Articles - [Ticket Settings](https://dev.wisecp.com/en/ticket-settings) - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Ticket Reference Lists](https://dev.wisecp.com/en/ticket-reference-lists) # API / Admin API / Tools ## Activity Logs https://dev.wisecp.com/en/activity-logs The eleven endpoints that read and clear sent messages, sign-ins, actions and module exchanges. ### Overview These eleven endpoints read and clear the books of **what the system did**: e-mails and messages sent, sign-ins, user actions, what modules exchanged with providers, and database queries. E-mail and message bodies are stored **encrypted** and do not come back in the list; a separate endpoint decodes them. The other books read plainly. Every clear shares one shape: you give a cut-off date and everything on and before it goes. The one exception is the query log, which is file-based and takes no date. ### Reference #### Listing the E-mail Log get/api/v1/admin/tools/logs/mail `Tools/GetMailLogs` admin paged Returns the record of e-mails sent. **The body is not in the list.** Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 9 idintId of the record. user_idintId of the client the mail went to. Zero when the mail was not tied to an account. reasonstringThe notification key that produced the mail. subjectstringThe mail subject. addressesstringThe recipient address or addresses. datastringThe template variables, serialised. ipstringThe address the send was triggered from. privateintOne when the body is withheld from the panel. ctimestringWhen it was sent, as `YYYY-MM-DD HH:MM:SS`. The body field is not among these; the list stays light because bodies are large. 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/tools/logs/mail' \ -H "Authorization: Bearer $API_KEY" \ -d limit=50 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/logs/mail'); url.searchParams.set('limit', '50'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/logs/mail?' . http_build_query(['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); ``` ```php // The body is stored encrypted and is NOT in the list; you get it from the decode endpoint. $logs = Api::Tools()->GetMailLogs([], ['limit' => 50])['data']; $content = Api::Tools()->GetLogPreview(['type' => 'mail', 'id' => $logs[0]['id']]); ``` #### Clearing the E-mail Log delete/api/v1/admin/tools/logs/mail `Tools/ClearMailLogs` admin cannot be undone Deletes e-mail records older than the date you give. Body 1 beforestringrequiredThe cut-off date. Records on and before it are deleted. Response fields data — 2 clearedboolWhether the clear ran. beforestringThe cut-off date that was used. Errors 2 invalid_date422No valid date was given. 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/tools/logs/mail' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"before":"2026-01-01"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/mail', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ before: '2026-01-01' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/mail'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['before' => '2026-01-01']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The response does not say HOW MANY were deleted; count them by listing first. Api::Tools()->ClearMailLogs(['before' => '2026-01-01']); ``` #### Listing the SMS Log get/api/v1/admin/tools/logs/sms `Tools/GetSmsLogs` admin paged Returns the record of text messages sent. The body is not in the list. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 11 idintId of the record. user_idintId of the client the message went to. Zero when it was not tied to an account. reasonstringThe notification key that produced the message. titlestringThe message title. numbersstringThe recipient number or numbers. datastringThe template variables, serialised. ipstringThe address the send was triggered from. privateintOne when the body is withheld from the panel. ownerstringThe sending side: `admin` or `client`. owner_idintThe account that triggered the send. ctimestringWhen it was sent, as `YYYY-MM-DD HH:MM:SS`. The body field is missing here for the same reason as the e-mail log. 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 'https://panel.example.com/api/v1/admin/tools/logs/sms' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/sms', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/sms'); 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::Tools()->GetSmsLogs(); ``` #### Clearing the SMS Log delete/api/v1/admin/tools/logs/sms `Tools/ClearSmsLogs` admin cannot be undone Deletes text message records older than the date you give. Body 1 beforestringrequiredThe cut-off date. Records on and before it are deleted. Response fields data — 2 clearedboolWhether the clear ran. beforestringThe cut-off date that was used. Errors 2 invalid_date422No valid date was given. 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/tools/logs/sms' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"before":"2026-01-01"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/sms', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ before: '2026-01-01' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/sms'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['before' => '2026-01-01']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php Api::Tools()->ClearSmsLogs(['before' => '2026-01-01']); ``` #### Listing the Sign-in Log get/api/v1/admin/tools/logs/login `Tools/GetLoginLogs` admin paged Returns the record of sign-ins to the panel and the client area. Query parameters 4 typestringWhich side: `client` or `admin`. It defaults to client; staff sign-ins are the admin side and have to be asked for by that name. pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 15 idintId of the sign-in record. owner_idintThe account the sign-in belongs to. user_idintThe same account, from the joined client row. full_namestringThe account holder's name. company_namestringThe company name on corporate accounts. blacklistintOne when the account is blacklisted. ipstringThe address the sign-in came from. portstringThe source port. country_codestringThe ISO country code resolved from the address. citystringThe city resolved from the address. latlngstringThe latitude and longitude resolved from the address. timezonestringThe time zone resolved from the address. user_agentstringThe browser user agent. tokenstringThe session token tied to the sign-in. ctimestringWhen the sign-in happened, as `YYYY-MM-DD HH:MM:SS`. The type filter works on the joined account, so a record always belongs to one side or the other. 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/tools/logs/login' \ -H "Authorization: Bearer $API_KEY" \ -d type=admin ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/logs/login'); url.searchParams.set('type', 'admin'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/logs/login?' . http_build_query(['type' => 'admin']); $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 // Without a type you get CLIENT sign-ins; staff sign-ins answer to 'admin', not 'staff'. $staff = Api::Tools()->GetLoginLogs([], ['type' => 'admin']); ``` #### Listing the Action Log get/api/v1/admin/tools/logs/actions `Tools/GetActionLogs` admin paged Returns the record of what users and the system did. Query parameters 4 typestringWhose actions: client, admin or system. It defaults to client. pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 9 idintId of the record. owner_idintThe account that performed the action. target_idintThe record the action was performed on. reasonstringThe action group: `update`, `deletion`, `create` and the like. detailstringThe action key. A translation key from the action language file, not a sentence: resolve it there rather than showing it raw. locale_detailstringReadable text, when the writer supplied one. Your fallback when the key cannot be resolved. datastringThe context of the action, serialised. ipstringThe address the action came from. ctimestringWhen it happened, as `YYYY-MM-DD HH:MM:SS`. Module records share this table but stay out of this listing; they have their own endpoint. 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/tools/logs/actions' \ -H "Authorization: Bearer $API_KEY" \ -d type=admin ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/logs/actions'); url.searchParams.set('type', 'admin'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/logs/actions?' . http_build_query(['type' => 'admin']); $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::Tools()->GetActionLogs([], ['type' => 'admin']); ``` #### Clearing the Action Log delete/api/v1/admin/tools/logs/actions `Tools/ClearActionLogs` admin cannot be undone Deletes action records older than the date you give. It leaves the module log alone. Body 1 beforestringrequiredThe cut-off date. Records on and before it are deleted. Response fields data — 2 clearedboolWhether the clear ran. beforestringThe cut-off date that was used. Errors 2 invalid_date422No valid date was given. 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/tools/logs/actions' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"before":"2026-01-01"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/actions', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ before: '2026-01-01' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/actions'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['before' => '2026-01-01']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This clear does NOT sweep the module log; that has its own endpoint. Api::Tools()->ClearActionLogs(['before' => '2026-01-01']); Api::Tools()->ClearModuleLogs(['before' => '2026-01-01']); ``` #### Listing the Module Log get/api/v1/admin/tools/logs/module `Tools/GetModuleLogs` admin paged Returns the record of what modules exchanged with providers. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 9 idintId of the record. owner_idintThe account tied to the call. Zero when the system triggered it. target_idintThe service the module call belonged to. reasonstringAlways `module-log` on this endpoint. detailstringThe module and the action recorded. locale_detailstringReadable text, when the writer supplied one. datastringThe request and answer of the module call, serialised. ipstringThe address the call came from. ctimestringWhen it happened, as `YYYY-MM-DD HH:MM:SS`. These records share a table with the action log, but clearing one leaves the other untouched. 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 'https://panel.example.com/api/v1/admin/tools/logs/module' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/module', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/module'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The module log only fills while it is ON in the log settings; an empty list is not 'all fine'. $settings = Api::Tools()->GetLogSettings()['data']; if ($settings['module_log']) { $logs = Api::Tools()->GetModuleLogs(); } ``` #### Clearing the Module Log delete/api/v1/admin/tools/logs/module `Tools/ClearModuleLogs` admin cannot be undone Deletes module records older than the date you give. Body 1 beforestringrequiredThe cut-off date. Records on and before it are deleted. Response fields data — 2 clearedboolWhether the clear ran. beforestringThe cut-off date that was used. Errors 2 invalid_date422No valid date was given. 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/tools/logs/module' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"before":"2026-01-01"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/module', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ before: '2026-01-01' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/module'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['before' => '2026-01-01']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php Api::Tools()->ClearModuleLogs(['before' => '2026-01-01']); ``` #### Decoding the Body post/api/v1/admin/tools/logs/preview `Tools/GetLogPreview` admin decrypts Decrypts and returns the stored body of one e-mail or text message record. Body 2 typestringrequiredWhich log: `mail` or `sms`. idintrequiredId of the record. Response fields data — 3 typestringThe log type. idintId of the record. contentstringThe decrypted body. The very text that went to the client. Errors 3 invalid_request422The type or the id was missing. not_found404No such record. 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/tools/logs/preview' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"mail","id":1240}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/preview', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'mail', id: 1240 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/preview'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['type' => 'mail', 'id' => 1240]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The decrypted body carries client data: do not write it to your own logs. $content = Api::Tools()->GetLogPreview([ 'type' => 'mail', 'id' => 1240, ])['data']['content']; ``` #### Deleting the Query Log delete/api/v1/admin/tools/logs/query `Tools/ClearQueryLogs` admin deletes files Deletes the database query log files. It takes no date and removes all of them. Response fields data — 2 clearedboolWhether the clear ran. deletedintHow many files were deleted. Errors 1 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/tools/logs/query' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/query', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/query'); 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 // This endpoint takes NO date: every file goes. Writing them is switched off in the settings. Api::Tools()->ClearQueryLogs(); Api::Tools()->SetLogSettings(['query_logging' => 0]); ``` ### Pitfalls > **The body is not in the list** > > The content of e-mail and message records is stored encrypted and **never** comes back from the listing endpoints. Seeing what was sent means calling the decode endpoint separately, one request per record. The decrypted text carries client data, so do not store it on your side. > **An empty book does not mean nothing happened** > > The module and query logs only fill while they are **on** in the log settings. With them off the lists come back empty, which does not mean nothing happened. Confirm in the settings that the log you need is on before investigating anything. > **The default side is the client** > > On the sign-in and action logs, leaving the type out gives you the **client** side. Looking for admin sign-ins or the system's own actions means sending the type explicitly, or you conclude the record you want never existed. > **The action clear leaves the module log** > > Clearing the action log **leaves** the module log where it is; they are separate endpoints. Making one call to free space, you may not notice the bigger book is still sitting there. > **The clear does not say how many it deleted** > > The date-based clears return only that they ran and which date they used; they give no count. To measure the effect, list the same range and count it first. ### Related Articles - [Error Logs](https://dev.wisecp.com/en/error-logs) - [Module Queue](https://dev.wisecp.com/en/module-queue) ## Error Logs https://dev.wisecp.com/en/error-logs The seven endpoints that read caught errors, clear them and manage the logging settings. ### Overview These seven endpoints read the errors that were caught, clear them, and set which books are kept. A row is not one event but **one problem**. The same error repeated a hundred times stays one row, with its counter raised and its last-seen time refreshed. That is why records are addressed by signature rather than by id. The records are a **history**: a row does not say "this is broken", it says "this was broken then". Before setting out to fix one, it is worth measuring whether it still happens. ### Reference #### Listing the Error Records get/api/v1/admin/tools/logs/errors `Tools/GetErrorLogs` admin many filters Returns the errors that were caught. Each row is not one event but **one problem**. Query parameters — filters 14 searchstringA general search over the records. typesstringFilters by record type. levelsstringFilters by severity. signaturestringNarrows to one signature. filestringFilters by the file the error came from. exception_classstringFilters by the class thrown. request_uristringFilters by the request address. http_methodstringFilters by the request method. user_idintFilters by user. ipstringFilters by address. statusstringFilters by the record status. min_occurrenceintOnly records repeated at least this many times. The easiest way to drop one-off noise. date_rangestringFilters by date range. messagestringSearches the error message. Query parameters — order 5 hide_noiseboolHides records counted as noise. orderstringThe sort field. It defaults to when it was last seen. directionstringThe sort direction. It defaults to descending. pageintDefaults to 1. limitintDefaults to 25, maximum 100. Response fields data[] — 15 idintThe row id. signaturestringThe 32-character identity of this error. This is what the detail and the delete are addressed by. typestringThe book it came from: `system` or `database`. levelstringThe severity, such as `error`, `warning` or `fatal`. countintHow many times this signature has been seen. A repeat raises this number instead of adding a row. first_seenstringWhen it was seen for the first time. last_seenstringWhen it was seen most recently. filestringThe source file, given relative to the install root. lineintThe source line. message_previewstringA shortened message. The full body is not in the list; the detail carries it. exception_classstringThe class thrown, when the error came from a throw. request_uristringThe request path. Query values outside the allow-list are replaced. request_methodstringThe request method. user_idintThe account tied to the request. Zero when nobody was signed in. ipstringThe address the request came from. 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/tools/logs/errors' \ -H "Authorization: Bearer $API_KEY" \ -d min_occurrence=5 \ -d hide_noise=1 ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/logs/errors'); url.searchParams.set('min_occurrence', '5'); url.searchParams.set('hide_noise', '1'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/logs/errors?' . http_build_query([ 'min_occurrence' => 5, 'hide_noise' => 1, ]); $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 // The list does not carry the BODY; read one record's detail to see the stack. $errors = Api::Tools()->GetErrorLogs([], [ 'min_occurrence' => 5, 'hide_noise' => 1, ])['data']; ``` #### Error Record Detail get/api/v1/admin/tools/logs/errors/{signature} `Tools/GetErrorLog` admin addressed by signature Returns one error together with its stack trace and the context of the request. Query parameters 1 typestringWhich book: `system` or `database`. It defaults to `system`, so a database error has to be asked for. Response fields data — 16 ——Every field of the list row, in the same shape as the listing endpoint. payloadobjectThe kept context: the stack as shapes, the allow-listed request fields and a subset of the server values. It belongs to the **first** sighting, not the latest, because a repeat only moves the counter and the last-seen time. Errors 3 signature_required422The signature is not a 32-character hex string. not_found404No record under that signature. You may be looking in the wrong book. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/logs/errors/a1b2c3d4e5f600112233445566778899' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/errors/a1b2c3d4e5f600112233445566778899', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/errors/a1b2c3d4e5f600112233445566778899'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The context belongs to the FIRST sighting: later repeats raise the counter but leave the body. $log = Api::Tools()->GetErrorLog(['signature' => 'a1b2c3d4e5f600112233445566778899'])['data']; ``` #### Deleting Error Records delete/api/v1/admin/tools/logs/errors `Tools/DeleteErrorLogs` admin by a list of signatures Deletes the errors you name, by their signatures. Body 1 signaturesstring[]requiredThe signatures to delete. Each has to be a 32-character hex string. Response fields data — 2 deletedboolWhether the delete ran. countintHow many records were deleted. It can be fewer than you sent: signatures that match nothing are skipped quietly. Errors 2 signature_required422Not one valid signature was given. 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/tools/logs/errors' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"signatures":["a1b2c3d4e5f600112233445566778899"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/errors', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ signatures: ['a1b2c3d4e5f600112233445566778899'] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/errors'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['signatures' => ['a1b2c3d4e5f600112233445566778899']]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Compare the deleted count with what you sent: signatures matching nothing are skipped quietly. $sent = array_column($errors, 'signature'); $response = Api::Tools()->DeleteErrorLogs(['signatures' => $sent]); $missed = count($sent) - $response['data']['count']; ``` #### Clearing by Date post/api/v1/admin/tools/logs/errors/cleanup `Tools/CleanupErrorLogs` admin cannot be undone Deletes error records older than the date you give, sweeping both books at once. Body 1 beforestringrequiredThe cut-off date. Everything on and before it goes. Response fields data — 3 clearedboolWhether the clear ran. beforestringThe cut-off date that was used. deletedobjectHow many were deleted per book: system and database. Errors 2 invalid_date422No valid date was given. 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/tools/logs/errors/cleanup' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"before":"2026-01-01"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/errors/cleanup', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ before: '2026-01-01' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/errors/cleanup'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['before' => '2026-01-01']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Clearing settled history makes every record after it a REAL signal. $response = Api::Tools()->CleanupErrorLogs(['before' => '2026-01-01']); $system = $response['data']['deleted']['system']; ``` #### Importing the Leftover Files post/api/v1/admin/tools/logs/errors/rebuild `Tools/RebuildErrorLogs` admin a one-off migration Reads the old error files left on disk and moves them into the records. Body — ——No body is needed. There is nothing to narrow: whatever is left on disk is taken. Send an empty body. Response fields data — 2 rebuiltboolWhether it ran. countintHow many records were imported. Zero when no files are left to take. Errors 1 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/tools/logs/errors/rebuild' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/errors/rebuild', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/errors/rebuild'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This is not an index rebuild; it imports from the old file store. // What comes in passes the same scrubbing as a fresh capture. $response = Api::Tools()->RebuildErrorLogs(); ``` #### Reading the Logging Settings get/api/v1/admin/tools/logs/settings `Tools/GetLogSettings` admin Returns which books are being kept and whether debugging is on. Response fields data — 6 error_logboolWhether errors are recorded. error_debugboolWhether error detail is shown. developmentboolWhether development mode is on. module_logboolWhether module exchanges are recorded. query_loggingboolWhether database queries are recorded. query_logging_ipsstring[]The addresses query logging is limited to. 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/tools/logs/settings' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/settings', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/settings'); 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::Tools()->GetLogSettings(); ``` #### Writing the Logging Settings put/api/v1/admin/tools/logs/settings `Tools/UpdateLogSettings` admin careful in production Writes which books to keep and whether debugging is on. Body 6 error_logintTurns error recording on or off. error_debugintShows the error detail. It leaks internals to visitors; keep it off in production. developmentintTurns development mode on. module_logintRecords module exchanges. It grows fast. query_loggingintRecords database queries. It grows very fast; pair it with the address limit. query_logging_ipsstring[] | stringThe addresses query logging is kept for. Give your own and only your requests are recorded. Response fields data — 6 dataobjectThe settings as they now stand. Same shape as the read endpoint. Only the keys you send are changed, but the whole set comes back, so it tells you where the install ended up. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tools/logs/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"error_log":1,"module_log":0}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/logs/settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ error_log: 1, module_log: 0 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/logs/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'error_log' => 1, 'module_log' => 0, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Turn query logging on for your own address only: left open it fills the disk fast. Api::Tools()->UpdateLogSettings([ 'query_logging' => 1, 'query_logging_ips' => ['203.0.113.5'], ]); ``` ### Pitfalls > **A record is history, not the present** > > A long error list does not mean that many open problems: most rows may already be fixed. To tell whether one still happens, read its counter, send a few requests to the surface in question, and read the counter again. If it has not moved there is nothing to fix. > **The context belongs to the first sighting** > > The stack trace, the address and the user on the detail come from the **first** time the error was caught; later repeats only update the counter and the last-seen time. So you may be reading today's repeat through a context from months ago. > **There are two separate books** > > System errors and database errors are kept apart. The detail endpoint looks in the **system** book by default, so giving a database error's signature without naming the type finds nothing. The date-based clear, on the other hand, sweeps both. > **Query logging fills the disk fast** > > Database query logging writes every request and grows fast when left on. Pair it with the address limit: give only your own address and it stays confined to your requests. In the same way, leaving error detail on in production shows internals to visitors. > **The rebuild is not an index operation** > > Despite the name this endpoint rebuilds no index: it imports from the **old file store** left on disk. With nothing left to take it returns zero, which is not an error. The old bodies it takes in pass the same scrubbing as a fresh capture. ### Related Articles - [Activity Logs](https://dev.wisecp.com/en/activity-logs) - [Module Queue](https://dev.wisecp.com/en/module-queue) ## Reminders and Tasks https://dev.wisecp.com/en/reminders-and-tasks The ten endpoints behind personal reminders and the team's task planner. ### Overview These ten endpoints run two separate books. A **reminder** is personal: you leave yourself a note and it comes back at the right time. A **task** is shared: it is assigned to an admin, can be tied to a client and a department, and its status is followed. The important difference is visibility. Reminders are **scoped to their owner** and no one else's are visible to the key; tasks are a team-wide list. ### Reference #### Listing the Reminders get/api/v1/admin/tools/reminders `Tools/GetReminders` admin your own records only Returns the reminders of the admin the key belongs to. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 7 idintId of the reminder. notestringThe note to be reminded of. statusstring`active` or `inactive`. periodstring`onetime` fires once, `recurring` repeats. scheduled_atstring | nullWhen a one-off reminder fires. recurringobject | null 3 fieldsThe pattern of a repeating reminder. timestringWhat time of day it fires. monthintWhich month. Minus one means every month. dayintWhich day of the month. Minus one means every day. created_atstring | nullWhen it was created. 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/tools/reminders' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/reminders', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/reminders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Reminders belong to their OWNER: another admin's are not visible with this key. $reminders = Api::Tools()->GetReminders()['data']; ``` #### Reminder Detail get/api/v1/admin/tools/reminders/{id} `Tools/GetReminder` admin Returns one reminder. The schema is the same as a list item. Response fields data — 7 idintId of the reminder. notestringThe note to be reminded of. statusstring`active` or `inactive`. periodstring`onetime` fires once, `recurring` repeats. scheduled_atstring | nullWhen a one-off reminder fires. recurringobject | null 3 fieldsThe pattern of a repeating reminder. timestringWhat time of day it fires. monthintWhich month. Minus one means every month. dayintWhich day of the month. Minus one means every day. created_atstring | nullWhen it was created. Errors 3 invalid_id422The id is not valid. not_found404No such reminder. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/reminders/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/reminders/12', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/reminders/12'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Someone else's reminder answers 'not found' rather than 'not allowed'. $response = Api::Tools()->GetReminder(['id' => 12]); ``` #### Creating a Reminder post/api/v1/admin/tools/reminders `Tools/CreateReminder` admin 201 Opens a reminder for the admin the key belongs to. Body 7 notestringrequiredThe note to be reminded of. statusstring`active` or `inactive`. It defaults to active. periodstring`onetime` or `recurring`. It defaults to one-off. scheduled_atstringWhen it fires. **Required** on a one-off. timestringThe time of day. Used on a repeating one. monthintWhich month. Minus one means every month. dayintWhich day. Minus one means every day. Response fields data dataobjectThe reminder created. Same shape as a list item. Errors 5 note_required422The note was empty. invalid_status422The status is neither of the two values. invalid_period422The period is neither of the two values. scheduled_at_required422A one-off reminder was given no time. 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/tools/reminders' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"note":"Follow up with client","period":"onetime","scheduled_at":"2026-02-01 09:00:00"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/reminders', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ note: 'Follow up with client', period: 'onetime', scheduled_at: '2026-02-01 09:00:00', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/reminders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'note' => 'Follow up with client', 'period' => 'onetime', 'scheduled_at' => '2026-02-01 09:00:00', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // For the first of every month: day 1, month -1 (every month). Api::Tools()->CreateReminder([ 'note' => 'Monthly reconciliation', 'period' => 'recurring', 'time' => '09:00', 'month' => -1, 'day' => 1, ]); ``` #### Updating a Reminder patch/api/v1/admin/tools/reminders/{id} `Tools/UpdateReminder` admin Applies the fields you send and leaves the rest as they are. Body 7 notestringrequiredThe note to be reminded of. statusstring`active` or `inactive`. It defaults to active. periodstring`onetime` or `recurring`. It defaults to one-off. scheduled_atstringWhen it fires. **Required** on a one-off. timestringThe time of day. Used on a repeating one. monthintWhich month. Minus one means every month. dayintWhich day. Minus one means every day. Response fields data dataobjectThe reminder updated. Same shape as a list item. Errors 3 not_found404No such reminder. note_required422The note you sent was empty. 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/tools/reminders/12' \ -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/tools/reminders/12', { 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/tools/reminders/12'); 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 // Switching the status off does not DELETE it: the record stays and simply does not fire. Api::Tools()->UpdateReminder(['id' => 12, 'status' => 'inactive']); ``` #### Deleting a Reminder delete/api/v1/admin/tools/reminders/{id} `Tools/DeleteReminder` admin Deletes the reminder. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted reminder. Errors 2 not_found404No such reminder. 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/tools/reminders/12' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/reminders/12', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/reminders/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::Tools()->DeleteReminder(['id' => 12]); ``` #### Listing the Tasks get/api/v1/admin/tools/tasks `Tools/GetTasks` admin every admin Returns the records in the task planner. Unlike reminders, all of them are visible. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 14 idintId of the task. titlestringThe task title. descriptionstringThe task description. statusstring`waiting`, `inprocess`, `postponed` or `completed`. status_notestringA note on the status. c_datestring | nullThe task date. due_datestring | nullWhen it is due. owner_idintId of the admin who opened it. admin_idintId of the admin it is assigned to. admin_namestring | nullName of the assigned admin. user_idintId of the client it concerns. user_namestring | nullThe client's name. user_company_namestring | nullThe client's company name. departmentsint[]The departments the task belongs to. 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/tools/tasks' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/tasks', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Unlike reminders, tasks are not scoped to their owner: the list carries everyone's. $mine = array_filter( Api::Tools()->GetTasks()['data'], fn (array $t): bool => $t['admin_id'] === $adminId, ); ``` #### Task Detail get/api/v1/admin/tools/tasks/{id} `Tools/GetTask` admin Returns one task. The schema is the same as a list item. Response fields data — 14 idintId of the task. titlestringThe task title. descriptionstringThe task description. statusstring`waiting`, `inprocess`, `postponed` or `completed`. status_notestringA note on the status. c_datestring | nullThe task date. due_datestring | nullWhen it is due. owner_idintId of the admin who opened it. admin_idintId of the admin it is assigned to. admin_namestring | nullName of the assigned admin. user_idintId of the client it concerns. user_namestring | nullThe client's name. user_company_namestring | nullThe client's company name. departmentsint[]The departments the task belongs to. Errors 3 invalid_id422The id is not valid. not_found404No such task. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/tasks/8' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/tasks/8', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/tasks/8'); 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::Tools()->GetTask(['id' => 8]); ``` #### Creating a Task post/api/v1/admin/tools/tasks `Tools/CreateTask` admin 201 Opens a task and assigns it to an admin. Body 10 titlestringrequiredThe task title. c_datestringrequiredThe task date. statusstringThe task status. It defaults to waiting. descriptionstringThe task description. admin_idintThe admin to assign it to. Left out, it goes to the key's owner. user_idintThe client the task concerns. departmentsint[]The departments to attach the task to. due_datestringWhen it is due. Left out, today is used. status_notestringA note to leave on the status. notifyboolSends the assigned admin a notification. Response fields data dataobjectThe task created. Same shape as a list item. Errors 4 title_required422The title was empty. c_date_required422The date was empty. invalid_status422The status is not one of the four values. 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/tools/tasks' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"title":"Renew SSL","c_date":"2026-02-01","user_id":42,"notify":true}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/tasks', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ title: 'Renew SSL', c_date: '2026-02-01', user_id: 42, notify: true, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'title' => 'Renew SSL', 'c_date' => '2026-02-01', 'user_id' => 42, 'notify' => true, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // With no assignee the task lands on the KEY'S OWNER, which matters on an integration key. Api::Tools()->CreateTask([ 'title' => 'Renew SSL', 'c_date' => '2026-02-01', 'admin_id' => 3, 'notify' => true, ]); ``` #### Updating a Task patch/api/v1/admin/tools/tasks/{id} `Tools/UpdateTask` admin Applies the fields you send; the title and the date cannot be emptied. Body 10 titlestringrequiredThe task title. c_datestringrequiredThe task date. statusstringThe task status. It defaults to waiting. descriptionstringThe task description. admin_idintThe admin to assign it to. Left out, it goes to the key's owner. user_idintThe client the task concerns. departmentsint[]The departments to attach the task to. due_datestringWhen it is due. Left out, today is used. status_notestringA note to leave on the status. notifyboolSends the assigned admin a notification. Response fields data dataobjectThe task updated. Same shape as a list item; the assignee and the client names are joined in. Errors 5 not_found404No such task. title_required422The title you sent was empty. c_date_required422The date you sent was empty. invalid_status422The status is not one of the four values. 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/tools/tasks/8' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"completed","status_note":"Done"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/tasks/8', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'completed', status_note: 'Done' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/tasks/8'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'status' => 'completed', 'status_note' => 'Done', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A completed task does not LEAVE the list; filter by status on your side. Api::Tools()->UpdateTask([ 'id' => 8, 'status' => 'completed', ]); ``` #### Deleting a Task delete/api/v1/admin/tools/tasks/{id} `Tools/DeleteTask` admin Deletes the task. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted task. Errors 2 not_found404No such task. 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/tools/tasks/8' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/tasks/8', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/tasks/8'); 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 // The delete does not check ownership: you can remove another admin's task too. Api::Tools()->DeleteTask(['id' => 8]); ``` ### Pitfalls > **The two books differ in visibility** > > The reminder endpoints see only the records of the admin who **owns the key**; someone else's answers "not found". The task endpoints have no such boundary: the list carries everyone's and the delete does not check ownership. Bear in mind that an integration key can remove another admin's task. > **An unassigned task lands on the key's owner** > > Creating a task with no assignee puts it on the **key's owner**. On an integration key that means piling tasks onto an account nobody actually watches. Name the assignee explicitly. > **A one-off reminder needs a time** > > When the period is one-off the time field is **required** and the request is refused without it. A repeating reminder reads its pattern from the time, the month and the day instead, where minus one means "every": month minus one with day one is the first of every month. > **A completed task does not leave the list** > > Marking a task complete does not remove it from the list; the record stays with its status. Counting open work means filtering by status on your side. ### Related Articles - [Activity Logs](https://dev.wisecp.com/en/activity-logs) - [Scheduled Bulk Tasks](https://dev.wisecp.com/en/scheduled-bulk-tasks) ## Add-on Modules https://dev.wisecp.com/en/addon-modules The seven endpoints that list, configure, test and remove the installed add-on modules. ### Overview Add-on modules are pieces that extend the installation: accounting integrations, chat, translation. These seven endpoints list them, configure them, test them, run their own methods and remove them. Every module supports different things. The capability list on the detail says what is possible. Whether it has settings, whether it offers a connection test, which methods can be called. **Read the method name from that list rather than guessing it.** ### Reference #### Listing the Add-ons get/api/v1/admin/tools/addons `Tools/GetAddons` admin Returns the add-on modules installed. Query parameters 3 statusstringFilters by status: enabled or disabled. searchstringSearches the modules. include_premiumintAlso brings modules available from the official store. Those come back under `meta`, not in the installed list. Response fields data[] — 9 keystringThe module key. This goes in the path; dashes, dots and spaces become underscores. namestringThe module name. descriptionstringWhat it does. authorstringIts author. versionstringIts version. statusboolWhether it is switched on. premiumboolWhether it is a paid module. logostringThe address of its logo. installedboolWhether it is installed. 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/tools/addons' \ -H "Authorization: Bearer $API_KEY" \ -d status=enabled ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/addons'); url.searchParams.set('status', 'enabled'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/addons?' . http_build_query(['status' => 'enabled']); $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 // Store modules are not in the INSTALLED list; they come separately under meta. $response = Api::Tools()->GetAddons([], ['include_premium' => 1]); $installed = $response['data']; $buyable = $response['meta']['premium'] ?? []; ``` #### Add-on Detail get/api/v1/admin/tools/addons/{module} `Tools/GetAddon` admin passwords masked Returns an add-on's settings, its form definition and what it can do. Response fields data — 14 keystringThe module key. This goes in the path; dashes, dots and spaces become underscores. namestringThe module name. descriptionstringWhat it does. authorstringIts author. versionstringIts version. statusboolWhether it is switched on. premiumboolWhether it is a paid module. logostringThe address of its logo. installedboolWhether it is installed. opening_typestringHow the module opens in the panel. access_psstring[]The privilege groups that can reach the module. settingsobjectThe module's stored settings. fieldsobjectThe definition of the settings form. Values on password-type fields come back masked. capabilitiesobject 6 fieldsWhat the module supports. has_settingsboolWhether it has settings. has_testboolWhether it supports a connection test. has_uninstallboolWhether it can be uninstalled. has_admin_areaboolWhether it has its own page in the panel. has_client_areaboolWhether it appears in the client area. methodsstring[]The names of its callable methods. The run endpoint accepts only what is on this list. Errors 3 module_required422No module key was given. not_found404No such module. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/addons/Parasut' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/addons/Parasut', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/addons/Parasut'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list of callable methods comes from HERE; do not guess a method name. $module = Api::Tools()->GetAddon(['module' => 'Parasut'])['data']; $methods = $module['capabilities']['methods']; ``` #### Changing the Status put/api/v1/admin/tools/addons/{module}/status `Tools/UpdateAddonStatus` admin Switches the add-on on or off. Body 1 statusintrequired`1` switches it on, `0` switches it off. Response fields data dataobjectThe add-on summary after the change. Same shape as a list item. Errors 4 status_required422No status was given. not_supported422The module does not support changing its status. status_change_failed422The status could not be changed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tools/addons/Parasut/status' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/addons/Parasut/status', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 1 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/addons/Parasut/status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Switching one on runs the module's own setup: it can create tables and register hooks. Api::Tools()->UpdateAddonStatus(['module' => 'Parasut', 'status' => 1]); ``` #### Saving the Settings put/api/v1/admin/tools/addons/{module}/settings `Tools/UpdateAddonSettings` admin do not send the mask back Writes the module's settings and, if you like, changes its status in the same request. Body 3 fieldsobjectThe values of the settings form. The field names come from the form definition on the detail. access_psstring[]The privilege groups that can reach the module. statusintThe module status. Send it and it changes alongside the settings. Response fields data — 4 keystringThe module key. statusboolThe status afterwards. access_psstring[]The privilege list that was stored. settingsobjectThe settings that were stored. Errors 5 not_supported422The module has no settings. settings_invalid422The settings were refused. settings_failed422The settings could not be stored. status_change_failed422The status could not be changed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tools/addons/Parasut/settings' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"fields":{"api_key":"xxxx"},"status":1}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/addons/Parasut/settings', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ fields: { api_key: secret }, status: 1, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/addons/Parasut/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'fields' => ['api_key' => $secret], 'status' => 1, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Password fields read from the detail are MASKED: send them back as they are and you store the mask. $module = Api::Tools()->GetAddon(['module' => 'Parasut'])['data']; $fields = $module['settings']; unset($fields['api_key']); // leave it out when it should not change $fields['webhook_url'] = $url; Api::Tools()->UpdateAddonSettings(['module' => 'Parasut', 'fields' => $fields]); ``` #### Testing the Connection post/api/v1/admin/tools/addons/{module}/test-connection `Tools/TestAddonConnection` admin Tries whether the module can reach its service with the settings it has. Body — ——No body is needed; send an empty one. The credentials come from the module's saved settings, never from the request. Response fields data — 2 keystringThe module key. connectedboolWhether the connection was made. Errors 3 test_not_supported422The module does not support a test. test_failed422The connection could not be made. 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/tools/addons/Parasut/test-connection' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/addons/Parasut/test-connection', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/addons/Parasut/test-connection'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The test uses the STORED settings: save first, then try. Api::Tools()->UpdateAddonSettings(['module' => 'Parasut', 'fields' => $fields]); $test = Api::Tools()->TestAddonConnection(['module' => 'Parasut']); ``` #### Running a Module Method post/api/v1/admin/tools/addons/{module}/methods/{method} `Tools/RunAddonMethod` admin bound to an allow list Runs one of the add-on's own methods. Path segments 2 modulestringThe module key. methodstringThe method name. It comes from the callable list on the detail and is written without the prefix. Body — ——No body is needed; send an empty one. The module method is called with no arguments, so nothing you send reaches it. Response fields data — 3 keystringThe module key. methodstringThe method that ran. resultmixedWhat the method returned. Its shape depends entirely on the module. Errors 4 method_required422No method name was given. method_not_found422The module has no such method. method_failed422The method failed or returned nothing. 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/tools/addons/Parasut/methods/sync' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/addons/Parasut/methods/sync', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/addons/Parasut/methods/sync'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An empty return counts as FAILURE: a method that returns nothing answers 'method_failed'. $module = Api::Tools()->GetAddon(['module' => 'Parasut'])['data']; if (in_array('sync', $module['capabilities']['methods'], true)) { Api::Tools()->RunAddonMethod(['module' => 'Parasut', 'method' => 'sync']); } ``` #### Deleting an Add-on delete/api/v1/admin/tools/addons/{module} `Tools/DeleteAddon` admin the directory goes Uninstalls the module and deletes its files from disk. Response fields data — 2 deletedboolWhether the delete succeeded. keystringKey of the deleted module. Errors 5 not_found404No such module. blocked_by_gate422A hook vetoed the removal. uninstall_failed422The module's own uninstall step failed. removal_failed422The files could not be deleted. 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/tools/addons/Parasut' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/addons/Parasut', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/addons/Parasut'); 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 // This endpoint also deletes the module's DIRECTORY: coming back means uploading it again. // To stop it for a while, switch the status off instead of deleting. Api::Tools()->UpdateAddonStatus(['module' => 'Parasut', 'status' => 0]); ``` ### Pitfalls > **Do not send the masked password back** > > The detail returns password-type fields **masked**. Write those settings straight back and you store the mask in place of the real password. The module can then no longer reach its service. Leave password fields you are not changing out of the body entirely. > **Deleting removes the directory too** > > The delete does not merely switch the module off: it runs the module's own uninstall and **deletes its files from disk**. Coming back means uploading it again. To stop a module for a while, switch its status off instead. > **The test uses the stored settings** > > The connection test takes no body; it runs with the settings the module **already has**. Trying a new key means saving it first, so a failed test leaves the wrong setting already written. > **An empty return counts as a failure** > > The run endpoint answers `method_failed` when the method returns nothing. So a method that genuinely ran but produced no result also looks like an error. Without knowing what the module returns, do not read that code as a definite failure. > **Store modules are not in the installed list** > > When you ask for the purchasable modules they are **not mixed** into the main list. They come back in a field of their own. Every row in the main list is installed, so do not try to tell them apart by the installed field. ### Related Articles - [Module Queue](https://dev.wisecp.com/en/module-queue) - [Activity Logs](https://dev.wisecp.com/en/activity-logs) ## Module Queue https://dev.wisecp.com/en/module-queue The nine endpoints that watch, retry and clear the work modules do in the background. ### Overview Provisioning, suspending or cancelling a service means the module has to talk to a provider. To keep the request from waiting, that conversation goes into a **queue** and runs in the background. These nine endpoints watch that queue and step into it. A job gets a set number of tries; on reaching it the job **fails** and is never tried again on its own. Putting a failed job back in line, or running it without waiting, is what these endpoints are for. ### Reference #### Listing the Queue get/api/v1/admin/tools/module-queue `Tools/GetModuleQueue` admin paged Returns the jobs handed to modules to run in the background. Query parameters 6 statusstringFilters by status. module_namestringFilters by module name. actionstringFilters by operation. pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 17 idintId of the queue item. module_typestringThe module type. module_namestringThe module name. actionstringThe operation to run. statusstring`pending` is queued, `processing` is running, `completed` finished, `failed` did not. service_idintThe service the operation concerns. service_namestring | nullThe service name. user_idintThe client id. user_full_namestring | nullThe client's name. addon_idint | nullThe add-on id. addon_namestring | nullThe add-on name. server_idint | nullThe server id. attemptsintHow many times it was tried. max_attemptsintHow many tries it gets. On reaching it the item fails and is not retried on its own. created_atstring | nullWhen it entered the queue. updated_atstring | nullWhen it last changed. next_retrystring | nullWhen the next try is due. 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/tools/module-queue' \ -H "Authorization: Bearer $API_KEY" \ -d status=failed ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/module-queue'); url.searchParams.set('status', 'failed'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/module-queue?' . http_build_query(['status' => 'failed']); $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 // The list carries no logs: read an item's detail to see why a job failed. $failed = Api::Tools()->GetModuleQueue([], ['status' => 'failed'])['data']; ``` #### The Queue Counters get/api/v1/admin/tools/module-queue/stats `Tools/GetModuleQueueStats` admin Returns how many jobs are in the queue, counted by status. Response fields data — 5 totalintTotal jobs in the queue. pendingintHow many are queued. processingintHow many are running. completedintHow many finished. failedintHow many failed. 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/tools/module-queue/stats' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/stats', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/stats'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This is the cheapest endpoint to watch: it gives the queue's health without pulling the list. $stats = Api::Tools()->GetModuleQueueStats()['data']; $stuck = $stats['failed'] > 0 || $stats['pending'] > 100; ``` #### Item Detail get/api/v1/admin/tools/module-queue/{id} `Tools/GetModuleQueueItem` admin the logs are here Returns one job together with the record of what was exchanged with the provider. Response fields data — 19 idintId of the queue item. module_typestringThe module type. module_namestringThe module name. actionstringThe operation to run. statusstring`pending` is queued, `processing` is running, `completed` finished, `failed` did not. service_idintThe service the operation concerns. service_namestring | nullThe service name. user_idintThe client id. user_full_namestring | nullThe client's name. addon_idint | nullThe add-on id. addon_namestring | nullThe add-on name. server_idint | nullThe server id. attemptsintHow many times it was tried. max_attemptsintHow many tries it gets. On reaching it the item fails and is not retried on its own. created_atstring | nullWhen it entered the queue. updated_atstring | nullWhen it last changed. next_retrystring | nullWhen the next try is due. api_logsarrayThe requests and responses exchanged with the provider. Not in the list; it is heavy, so only here. process_logsarrayThe job's own step-by-step record. Errors 3 invalid_id422The id is not valid. not_found404No such queue item. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/module-queue/101' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/101', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/101'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read the logs BEFORE retrying: with the same condition in place the job fails at the same point. $item = Api::Tools()->GetModuleQueueItem(['id' => 101])['data']; $last = end($item['api_logs']); ``` #### Retrying an Item post/api/v1/admin/tools/module-queue/{id}/retry `Tools/RetryModuleQueueItem` admin queues it Puts a failed job back in the queue and resets its try counter. Body — ——No body is needed. The job is addressed by the path parameter; send an empty body. Response fields data dataobjectThe job as it stands after the reset. Same shape as the item detail endpoint. Errors 3 not_found404No such queue item. not_failed422Only failed jobs can be retried. 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/tools/module-queue/101/retry' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/101/retry', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/101/retry'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A retry does not run the job NOW: it queues it and the background worker takes over. // To see it without waiting, use the run endpoint. Api::Tools()->RetryModuleQueueItem(['id' => 101]); ``` #### Running an Item Now post/api/v1/admin/tools/module-queue/{id}/run `Tools/RunModuleQueueItem` admin runs immediately Runs the job there and then, without waiting for the background worker, and returns the outcome. Body — ——No body is needed. The job is addressed by the path parameter; send an empty body. Response fields data — 3 task_successboolWhether the job succeeded. task_messagestringThe error message when it did not. itemobjectThe job as it stands after the run. Errors 3 not_found404No such queue item. not_runnable422Only queued or failed jobs can be run. 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/tools/module-queue/101/run' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/101/run', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/101/run'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The request answers 200 even when the job FAILED: success is in 'task_success'. $result = Api::Tools()->RunModuleQueueItem(['id' => 101])['data']; if (!$result['task_success']) { $why = $result['task_message']; } ``` Response 200 ```json { "data": { "task_success": false, "task_message": "Provider refused: quota exceeded.", "item": { "id": 101, "status": "failed", "attempts": 3, "max_attempts": 3 } } } ``` #### Deleting an Item delete/api/v1/admin/tools/module-queue/{id} `Tools/DeleteModuleQueueItem` admin hook-guarded Takes a job out of the queue. The work itself stays undone. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted item. Errors 3 not_found404No such queue item. blocked_by_gate422The `gate:module.queue_intervene` 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/tools/module-queue/101' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/101', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/101'); 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 // Deleting CANCELS the job: a service waiting to be provisioned keeps waiting. Api::Tools()->DeleteModuleQueueItem(['id' => 101]); ``` #### Deleting in Bulk post/api/v1/admin/tools/module-queue/bulk-delete `Tools/BulkDeleteModuleQueue` admin hook-guarded Takes several jobs out of the queue. Body 1 idsint[]requiredIds of the items to delete. Response fields data — 3 deletedboolWhether the delete ran. countintHow many ids were accepted. It is the length of the list below, not a fresh count from the database. idsint[]The ids that were accepted for deletion. Ids that no longer exist come back too, so this is not a confirmation of what was removed. Errors 3 ids_required422No id was given. blocked_by_gate422The `gate:module.queue_intervene` hook vetoed the operation. 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/tools/module-queue/bulk-delete' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[101,102,103]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/bulk-delete', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [101, 102, 103] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/bulk-delete'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [101, 102, 103]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The id list comes back as you sent it: ids that no longer exist are echoed too, never dropped. $response = Api::Tools()->BulkDeleteModuleQueue(['ids' => [101, 102, 103]]); $echoed = $response['data']['ids']; ``` #### Retrying Everything That Failed post/api/v1/admin/tools/module-queue/retry-all-failed `Tools/RetryAllFailedModuleQueue` admin the whole queue Puts every failed job in the queue back in line. Body — ——No body is needed. The action covers the whole queue and cannot be narrowed; send an empty body. Response fields data — 1 retriedboolWhether it ran. It does not say how many were queued. Errors 1 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/tools/module-queue/retry-all-failed' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/retry-all-failed', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/retry-all-failed'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The endpoint does not say how many were queued; measure it with the counters either side. $before = Api::Tools()->GetModuleQueueStats()['data']['failed']; Api::Tools()->RetryAllFailedModuleQueue(); $after = Api::Tools()->GetModuleQueueStats()['data']['failed']; ``` #### Clearing What Finished post/api/v1/admin/tools/module-queue/clear-completed `Tools/ClearCompletedModuleQueue` admin Deletes the finished jobs from the queue. Body — ——No body is needed. The action covers the whole queue and cannot be narrowed; send an empty body. Response fields data — 1 clearedboolWhether the clear ran. Errors 1 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/tools/module-queue/clear-completed' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/module-queue/clear-completed', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/module-queue/clear-completed'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The clear takes what FINISHED; failed jobs stay put and wait to be looked at. Api::Tools()->ClearCompletedModuleQueue(); ``` ### Pitfalls > **A failed run comes back inside a 200** > > The run endpoint answers `200` even when the job failed; whether it worked is a field in the response. A client reading only the status code counts a failed provisioning as a success. The error message comes back in the same response. > **Retrying and running are not the same** > > A retry **queues** the job: the try counter resets and the background worker runs it when its turn comes. The run endpoint does the work **there and then** and returns the outcome. Use the second when you want to see what happened. > **Deleting cancels the work** > > Deleting a queue item does not merely tidy a record: that job is **never done**. A service waiting to be provisioned keeps waiting and nothing reminds you. That is why the delete is guarded by a hook, and your installation may refuse the request through it. > **Why it failed is not in the list** > > The record of what was exchanged with the provider is heavy, so it is left out of the list and appears only on a single item. Read it before retrying a job, because with the same condition in place it fails at the same point again. > **The bulk endpoints give no count** > > The retry-all and clear-completed endpoints say only that they ran; they **do not report** how many jobs they touched. To measure the effect, read the counters before and after. ### Related Articles - [Notification Queue](https://dev.wisecp.com/en/notification-queue) - [Activity Logs](https://dev.wisecp.com/en/activity-logs) - [Service Lifecycle](https://dev.wisecp.com/en/service-lifecycle) ## Notification Queue https://dev.wisecp.com/en/notification-queue The nine endpoints that watch, rescue and clear the e-mails and messages waiting to go out. ### Overview E-mails and text messages are not sent straight away; they go into a **queue** and are worked through in the background. These nine endpoints watch that queue, rescue what got stuck and clear what piled up. The message itself is stored encrypted and comes back from **no endpoint**. What you get here is the subject, the recipient and the status. To read a message that went out, the place is the activity logs. The shape is almost the same as the module queue, but two endpoints differ. Retry here also accepts queued items, while sending now accepts **only** queued ones. ### Reference #### Listing the Queue get/api/v1/admin/tools/notification-queue `Tools/GetNotificationQueue` admin the message never returns Returns the e-mails and text messages waiting to go out. Query parameters 5 statusstringFilters by status. channelstringFilters by channel: e-mail or text message. pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the records. Response fields data[] — 16 idintId of the queue item. channelstring`mail` or `sms`. recipientstringThe recipient's address or number. recipient_namestringThe recipient's name. subjectstringThe subject of the notice. statusstring`pending` is queued, `processing` is going out, `sent` went, `failed` did not. priorityintThe sending priority. user_idintId of the client it concerns. user_full_namestring | nullThe client's name. attemptsintHow many times it was tried. max_attemptsintHow many tries it gets. batch_idstring | nullThe batch id. It ties together the notices from one bulk send. created_atstring | nullWhen it entered the queue. scheduled_atstring | nullWhen it is due to go. processed_atstring | nullWhen it was processed. next_retrystring | nullWhen the next try is due. 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/tools/notification-queue' \ -H "Authorization: Bearer $API_KEY" \ -d status=failed \ -d channel=mail ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/notification-queue'); url.searchParams.set('status', 'failed'); url.searchParams.set('channel', 'mail'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/notification-queue?' . http_build_query([ 'status' => 'failed', 'channel' => 'mail', ]); $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 // The message itself comes back from NO endpoint; beyond the subject and recipient there is no content. $failed = Api::Tools()->GetNotificationQueue([], ['status' => 'failed'])['data']; ``` #### The Queue Counters get/api/v1/admin/tools/notification-queue/stats `Tools/GetNotificationQueueStats` admin Returns how many notices are waiting and how many went out. Response fields data — 5 totalintTotal notices in the queue. pendingintHow many are queued. processingintHow many are going out. sentintHow many were sent. failedintHow many failed. 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/tools/notification-queue/stats' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/stats', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/stats'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A pending count that keeps growing is the sign that the sending pipeline has stopped. $stats = Api::Tools()->GetNotificationQueueStats()['data']; ``` #### Item Detail get/api/v1/admin/tools/notification-queue/{id} `Tools/GetNotificationQueueItem` admin the step log Returns one notice together with the record of its sending attempts. Response fields data — 17 idintId of the queue item. channelstring`mail` or `sms`. recipientstringThe recipient's address or number. recipient_namestringThe recipient's name. subjectstringThe subject of the notice. statusstring`pending` is queued, `processing` is going out, `sent` went, `failed` did not. priorityintThe sending priority. user_idintId of the client it concerns. user_full_namestring | nullThe client's name. attemptsintHow many times it was tried. max_attemptsintHow many tries it gets. batch_idstring | nullThe batch id. It ties together the notices from one bulk send. created_atstring | nullWhen it entered the queue. scheduled_atstring | nullWhen it is due to go. processed_atstring | nullWhen it was processed. next_retrystring | nullWhen the next try is due. process_logsarrayThe step-by-step record of the attempts. Why a notice did not go is written here. Errors 3 invalid_id422The id is not valid. not_found404No such queue item. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/notification-queue/201' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/201', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/201'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Even the detail does NOT carry the body: to read a sent message, look in the activity log. $item = Api::Tools()->GetNotificationQueueItem(['id' => 201])['data']; $why = end($item['process_logs']); ``` #### Retrying an Item post/api/v1/admin/tools/notification-queue/{id}/retry `Tools/RetryNotificationQueueItem` admin Puts a notice back in line to be sent. Body — ——No body is needed. The notice is addressed by the path parameter; send an empty body. Response fields data dataobjectThe notice as it stands after the reset. Same shape as the item detail endpoint. Errors 3 not_found404No such queue item. not_retryable422Only failed or queued notices can be retried. One that already went cannot be sent again from here. 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/tools/notification-queue/201/retry' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/201/retry', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/201/retry'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The retry also accepts QUEUED items, which is how you move one up the line. Api::Tools()->RetryNotificationQueueItem(['id' => 201]); ``` #### Sending an Item Now post/api/v1/admin/tools/notification-queue/{id}/run `Tools/RunNotificationQueueItem` admin goes immediately Sends the notice there and then, without waiting for the background worker, and returns the outcome. Body — ——No body is needed. The notice is addressed by the path parameter; send an empty body. Response fields data — 3 task_successboolWhether the send succeeded. task_messagestringThe error message when it did not. itemobjectThe notice as it stands after the send. Errors 3 not_found404No such queue item. not_pending422Only queued notices can be sent. A failed one has to be retried into the queue first. 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/tools/notification-queue/201/run' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/201/run', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/201/run'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A failed notice cannot be sent directly: retry it into the queue first, then run it. Api::Tools()->RetryNotificationQueueItem(['id' => 201]); $result = Api::Tools()->RunNotificationQueueItem(['id' => 201])['data']; if (!$result['task_success']) { $why = $result['task_message']; } ``` #### Deleting an Item delete/api/v1/admin/tools/notification-queue/{id} `Tools/DeleteNotificationQueueItem` admin Takes a notice out of the queue. One that had not gone never goes. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted item. Errors 2 not_found404No such queue item. 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/tools/notification-queue/201' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/201', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/201'); 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 // A notice already going out CANNOT be deleted; wait until the send finishes. Api::Tools()->DeleteNotificationQueueItem(['id' => 201]); ``` #### Deleting in Bulk post/api/v1/admin/tools/notification-queue/bulk-delete `Tools/BulkDeleteNotificationQueue` admin Takes several notices out of the queue. Body 1 idsint[]requiredIds of the notices to delete. Response fields data — 2 deletedboolWhether the delete ran. countintHow many were deleted. It can be fewer than you sent, because ones going out are skipped. Errors 2 ids_required422No id was given. 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/tools/notification-queue/bulk-delete' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[201,202]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/bulk-delete', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [201, 202] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/bulk-delete'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [201, 202]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Ones going out are skipped quietly: compare the deleted count with what you sent. $ids = [201, 202]; $response = Api::Tools()->BulkDeleteNotificationQueue(['ids' => $ids]); $skipped = count($ids) - $response['data']['count']; ``` #### Retrying Everything That Failed post/api/v1/admin/tools/notification-queue/retry-all-failed `Tools/RetryAllFailedNotificationQueue` admin the whole queue Puts every failed notice in the queue back in line. Body — ——No body is needed. The action covers the whole queue and cannot be narrowed; send an empty body. Response fields data — 1 retriedintHow many were queued again. Errors 1 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/tools/notification-queue/retry-all-failed' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/retry-all-failed', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/retry-all-failed'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Unlike the module queue, this endpoint does return a COUNT. $count = Api::Tools()->RetryAllFailedNotificationQueue()['data']['retried']; ``` #### Clearing What Was Sent post/api/v1/admin/tools/notification-queue/clear-sent `Tools/ClearSentNotificationQueue` admin Deletes the sent notices from the queue. Body — ——No body is needed. The action covers the whole queue and cannot be narrowed; send an empty body. Response fields data — 1 clearedboolWhether the clear ran. Errors 1 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/tools/notification-queue/clear-sent' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/notification-queue/clear-sent', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/notification-queue/clear-sent'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Clearing the queue does not delete the SENT RECORD: what went out stays in the activity log. Api::Tools()->ClearSentNotificationQueue(); ``` ### Pitfalls > **The message itself comes back from no endpoint** > > The queue endpoints give the subject, the recipient and the status. The body is stored encrypted and is absent **even from the detail**. To see what a client was actually sent, look in the activity logs rather than the queue. > **The two endpoints accept different statuses** > > The retry takes both failed and queued notices; sending now takes **only queued** ones. So sending a failed notice immediately means retrying it into the queue first and running it after. Trying it in one step answers `not_pending`. > **An item going out cannot be deleted** > > A notice currently going out cannot be deleted and is **skipped quietly** in a bulk delete. The gap between how many ids you sent and the count you get back is what was skipped. Without checking it, a notice you thought you cancelled may already be on its way. > **The two queues differ on the bulk endpoints** > > On the notification queue the retry-all endpoint **returns** how many notices were queued again. The same endpoint on the module queue does not. Writing one helper for both queues means accounting for that. > **Clearing the queue does not delete the sent record** > > Clearing the sent notices removes queue rows only. What was sent stays in the activity logs, so no history is lost. Confusing the two leads to treating data as gone when it is not. ### Related Articles - [Module Queue](https://dev.wisecp.com/en/module-queue) - [Activity Logs](https://dev.wisecp.com/en/activity-logs) - [Bulk Messaging](https://dev.wisecp.com/en/bulk-messaging) ## Bulk Messaging https://dev.wisecp.com/en/bulk-messaging The six endpoints that send one message to many: choosing the audience, counting it, testing and sending. ### Overview These six endpoints run every step of sending one message to many people: choosing who to target, seeing how many that is, trying it on yourself, and sending it. The audience comes from one of two places. **Filters** pick clients by group, country or the products they hold. A **newsletter list** is a hand-kept list of addresses whose members need not be clients. Give a newsletter key and the filters are **ignored**. Sending is not immediate: the messages go into the notification queue and are worked through in the background. The batch id on the response is how you find that batch's rows in the queue. ### Reference #### Reading the Filter Options get/api/v1/admin/tools/bulk/lookups `Tools/GetBulkLookups` admin where the filters come from Returns the values you can use in the recipient filters. Response fields data — 7 user_groupsarrayThe client groups. departmentsarrayThe departments. countriesobject[]The countries, each with an id, a name and a country code. languagesobject[]The languages, each with a key and a name. tldsarrayThe domain extensions. productsarrayThe products, with their category tree. service_statusesstring[]The service statuses. 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/tools/bulk/lookups' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/lookups', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/lookups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Take the filter values from HERE: guessing ids quietly produces an empty audience. $lookups = Api::Tools()->GetBulkLookups()['data']; $groupIds = array_column($lookups['user_groups'], 'id'); ``` #### Counting the Recipients post/api/v1/admin/tools/bulk/contacts `Tools/GetBulkContacts` admin sends nothing Tells you how many people your filters reach, without sending anything. Body — who 4 user_typestringThe audience: `client` or `staff`. It defaults to clients. notification_typestringThe channel: `mail` or `sms`. The channel changes who counts as reachable. newsletterstringA newsletter key. Give it and the newsletter list is used instead of the filters. fullintAlso returns the recipients themselves, alongside the count. Body — filters 11 user_groupsarrayFilters by client group. departmentsarrayFilters by department. countriesarrayFilters by country. languagesarrayFilters by language. servicesarrayFilters by the products held. serversarrayFilters by the servers services sit on. addonsarrayFilters by the add-ons held. services_statusarrayFilters by service status. client_statusarrayFilters by client status. without_productsintTargets clients with no product at all. birthday_marketingintTargets clients whose birthday it is. Response fields data — 3 countintHow many recipients matched. contactsarrayThe recipients. Filled only when you asked for them. sourcestringWhere the recipients came from: the filters or the newsletter. Errors 1 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/tools/bulk/contacts' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"user_type":"client","notification_type":"mail","user_groups":[1]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/contacts', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ user_type: 'client', notification_type: 'mail', user_groups: [1], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/contacts'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'user_type' => 'client', 'notification_type' => 'mail', 'user_groups' => [1], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Count BEFORE sending: give the same body to the submit endpoint and that many people get it. $audience = ['user_type' => 'client', 'user_groups' => [1]]; $count = Api::Tools()->GetBulkContacts($audience)['data']['count']; if ($count > 0 && $count < 5000) { Api::Tools()->SubmitBulkNotification($audience + [ 'subject' => 'Notice', 'message' => $html, ]); } ``` #### Sending a Test post/api/v1/admin/tools/bulk/test `Tools/TestBulkNotification` admin goes only to you Sends the message to the addresses you give and to department staff, not to clients. Body 5 messagestringrequiredThe message body. notification_typestringThe channel. It defaults to e-mail. subjectstringThe subject. Left empty, a fixed heading is used. departmentsint[]The departments. It goes to the staff assigned to them. emailsstringOutside addresses. One per line. Response fields data — 1 sentintHow many tests went out. Errors 2 body_required422The message body was empty. 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/tools/bulk/test' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"subject":"Test","message":"Hello.","emails":"qa@example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/test', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject: 'Test', message: 'Hello.', emails: 'qa@example.com', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/test'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'subject' => 'Test', 'message' => $html, 'emails' => 'qa@example.com', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The test ignores the FILTERS: it checks how the message looks, not who it would reach. Api::Tools()->TestBulkNotification([ 'subject' => 'Notice', 'message' => $html, 'emails' => 'qa@example.com', ]); ``` #### Sending the Bulk Notification post/api/v1/admin/tools/bulk/submit `Tools/SubmitBulkNotification` admin cannot be recalled Queues the message for everyone who matched. This is the real send. Body — the message 5 messagestringrequiredThe message body. subjectstringThe subject. Required on e-mail. notification_typestringThe channel. It defaults to e-mail. ccstringAddresses to copy in. One per line. deliverystringThe envelope: `single` one message each, `multiple` all in one envelope. In one envelope the recipients can see each other. Body — who 3 user_typestringThe audience: clients or staff. newsletterstringA newsletter key. Give it and the newsletter list is used instead of the filters. *array | intThe same eleven filters as on the counting endpoint apply here too. Response fields data — 3 queuedboolWhether it was queued. recipientsintHow many recipients were queued. batch_idstringThe id of this send. It is how you find this send's rows in the notification queue. Errors 4 subject_required422The subject was empty on an e-mail. body_required422The message body was empty. no_recipients422No recipient matched the filters. 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/tools/bulk/submit' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"notification_type":"mail","user_type":"client","subject":"Notice","message":"Important update.","delivery":"single","user_groups":[1]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/submit', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ notification_type: 'mail', user_type: 'client', subject: 'Notice', message: 'Important update.', delivery: 'single', user_groups: [1], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/submit'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'subject' => 'Notice', 'message' => $html, 'delivery' => 'single', 'user_groups' => [1], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A queued send CANNOT be recalled; cancelling in bulk means deleting the queue rows. $batch = Api::Tools()->SubmitBulkNotification($audience + [ 'subject' => 'Notice', 'message' => $html, ])['data']; // It went out wrong: pull the queued ones back. $queued = Api::Tools()->GetNotificationQueue([], ['status' => 'pending'])['data']; $ids = array_column( array_filter($queued, fn (array $n): bool => $n['batch_id'] === $batch['batch_id']), 'id', ); Api::Tools()->BulkDeleteNotificationQueue(['ids' => $ids]); ``` Response 200 422 ```json { "data": { "queued": true, "recipients": 1842, "batch_id": "b7f3c1a9" } } ``` ```json { "error": { "code": "no_recipients", "message": "No recipient matched the filters." } } ``` #### Reading a Newsletter List get/api/v1/admin/tools/bulk/newsletters `Tools/GetBulkNewsletters` admin per language Returns the newsletter subscribers, who need not be clients at all. Query parameters 2 typestringThe channel: `email` or `sms`. It defaults to e-mail. langstringA language code. Leave it out and every language comes back as a summary, in a different response shape. Response fields data entriesstring[]The addresses or numbers on the list. Returned when a language is given. countintHow many records are on the list. by_langobjectThe count and the list keyed by language. This comes back instead when no language is given. 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/tools/bulk/newsletters' \ -H "Authorization: Bearer $API_KEY" \ -d type=email \ -d lang=en ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/bulk/newsletters'); url.searchParams.set('type', 'email'); url.searchParams.set('lang', 'en'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/bulk/newsletters?' . http_build_query([ 'type' => 'email', 'lang' => 'en', ]); $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 // The response CHANGES SHAPE with the language: give one and you get a flat list, omit it and you get a summary per language. $one = Api::Tools()->GetBulkNewsletters([], ['lang' => 'en'])['data']['entries']; $all = Api::Tools()->GetBulkNewsletters()['data']['by_lang']; ``` #### Writing a Newsletter List put/api/v1/admin/tools/bulk/newsletters `Tools/SaveBulkNewsletters` admin the list is written whole Rewrites the newsletter list for one language and channel. Body 4 langstringrequiredWhich language's list to write. typestringThe channel. It defaults to e-mail. entriesstring[]The whole list. What you send becomes the truth. contentstringThe same list as text, one per line. Used instead of the array. Response fields data — 3 langstringThe language written. typestringThe channel written. countintHow many records the list holds. Errors 2 lang_required422No language was given. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/tools/bulk/newsletters' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"lang":"en","type":"email","entries":["a@example.com","b@example.com"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/newsletters', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ lang: 'en', type: 'email', entries: ['a@example.com', 'b@example.com'], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/newsletters'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'lang' => 'en', 'type' => 'email', 'entries' => $entries, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To ADD one address send the existing list too: what you send replaces what was there. $entries = Api::Tools()->GetBulkNewsletters([], ['lang' => 'en'])['data']['entries']; $entries[] = 'new@example.com'; Api::Tools()->SaveBulkNewsletters(['lang' => 'en', 'entries' => $entries]); ``` ### Pitfalls > **A queued send cannot be recalled** > > The submit endpoint has **no undo**. The only way to stop a wrong message is to take the batch id from the response, find the pending rows in the notification queue and delete them; whatever already went is gone. That is why counting first is not a formality but the only safety you have. > **The test does not verify the audience** > > The test endpoint **never reads** the filters: it sends only to the addresses you give and to department staff. So it verifies how the message looks, not who would get it. Verifying the audience is the counting endpoint's job, and the two go together. > **One envelope lets recipients see each other** > > Choosing the shared envelope merges the message into one send. That is faster, but the recipient addresses can become visible to each other, and handing out a client list is a data leak. When in doubt, pick the per-recipient form. > **The newsletter list is written whole** > > The write does not merge: the set you send **replaces** that language's list. Adding one address means reading the current list and appending to it, or the rest of the subscribers are deleted. > **The newsletter read changes shape with the language** > > Give a language and you get a flat list; omit it and you get a summary grouped per language. Code expecting one shape reads an empty list when the language parameter is forgotten, and takes that for "no subscribers". ### Related Articles - [Notification Queue](https://dev.wisecp.com/en/notification-queue) - [Bulk Templates](https://dev.wisecp.com/en/bulk-templates) - [Scheduled Bulk Tasks](https://dev.wisecp.com/en/scheduled-bulk-tasks) ## Bulk Templates https://dev.wisecp.com/en/bulk-templates The six endpoints that manage reusable bulk notification campaigns. ### Overview A bulk template is a campaign **saved for reuse**: the message, the subject, the channel and the audience filters kept together. Instead of rebuilding the same announcement each time, you write it once and store it. A template **sends nothing** on its own. Sending happens on a separate endpoint, and sending one on a regular basis is what scheduled tasks are for. ### Reference #### Listing the Templates get/api/v1/admin/tools/bulk/templates `Tools/GetBulkTemplates` admin paged Returns the saved bulk notification campaigns. Query parameters 4 template_typestringFilters by channel. pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the templates. Response fields data[] — 10 idintId of the template. template_namestringThe template name. For you only; the client never sees it. template_typestringThe channel: `mail` or `sms`. typestringThe audience: `member` for clients, `staff` for staff. subjectstringThe message subject. submission_typestringThe envelope: `single` one each, `multiple` all together. newsletterstringThe newsletter key. When filled, the newsletter list is used instead of the filters. created_atstring | nullWhen it was created. updated_atstring | nullWhen it last changed. last_sentstring | nullWhen it was last sent. 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/tools/bulk/templates' \ -H "Authorization: Bearer $API_KEY" \ -d template_type=mail ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/tools/bulk/templates'); url.searchParams.set('template_type', 'mail'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }); const body = await res.json(); ``` ```php $url = 'https://panel.example.com/api/v1/admin/tools/bulk/templates?' . http_build_query(['template_type' => 'mail']); $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 // The list carries neither the message body nor the filters; those come on the detail only. $templates = Api::Tools()->GetBulkTemplates([], ['template_type' => 'mail'])['data']; ``` #### Template Detail get/api/v1/admin/tools/bulk/templates/{id} `Tools/GetBulkTemplate` admin the filters are here Returns a campaign with its message and its audience filters. Response fields data — 24 idintId of the template. template_namestringThe template name. For you only; the client never sees it. template_typestringThe channel: `mail` or `sms`. typestringThe audience: `member` for clients, `staff` for staff. subjectstringThe message subject. submission_typestringThe envelope: `single` one each, `multiple` all together. newsletterstringThe newsletter key. When filled, the newsletter list is used instead of the filters. created_atstring | nullWhen it was created. updated_atstring | nullWhen it last changed. last_sentstring | nullWhen it was last sent. messagestringThe message body. ccstringAddresses to copy in. without_productsintTargets clients with no product at all. birthday_marketingintTargets clients whose birthday it is. auto_submissionintWhether the template is sent on its own by a scheduled task. user_groupsarrayThe client group filter. departmentsarrayThe department filter. countriesarrayThe country filter. languagesarrayThe language filter. servicesarrayThe product filter. serversarrayThe server filter. addonsarrayThe add-on filter. services_statusarrayThe service status filter. client_statusarrayThe client status filter. Errors 3 invalid_id422The id is not valid. not_found404No such template. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/tools/bulk/templates/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/templates/5', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/templates/5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To see how many a template would reach, hand its filters to the counting endpoint. $tpl = Api::Tools()->GetBulkTemplate(['id' => 5])['data']; $count = Api::Tools()->GetBulkContacts([ 'user_type' => $tpl['type'] === 'staff' ? 'staff' : 'client', 'user_groups' => $tpl['user_groups'], 'countries' => $tpl['countries'], ])['data']['count']; ``` #### Creating a Template post/api/v1/admin/tools/bulk/templates `Tools/CreateBulkTemplate` admin 201 Saves a campaign to reuse. Saving sends nothing. Body 11 template_namestringrequiredThe template name. messagestringrequiredThe message body. subjectstringThe message subject. Required on e-mail. template_typestringThe channel. It defaults to e-mail. typestringThe audience. Whatever you send for clients is stored as `member`. submission_typestringThe envelope form. newsletterstringThe newsletter key. ccstringAddresses to copy in. without_productsintTargets clients with no product at all. birthday_marketingintTargets clients whose birthday it is. *arrayThe nine filter arrays: groups, departments, countries, languages, products, servers, add-ons, service status and client status. Response fields data — 24 dataobjectThe campaign saved, with the filter arrays already decoded. Same shape as the detail endpoint. To send it, hand the new id to the submit endpoint or give it a schedule. Errors 4 template_name_required422The template name was empty. subject_required422The subject was empty on an e-mail. body_required422The message body was empty. 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/tools/bulk/templates' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"template_name":"Welcome Campaign","template_type":"mail","type":"member","subject":"Welcome","message":"Hello!","user_groups":[1]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/templates', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ template_name: 'Welcome Campaign', template_type: 'mail', type: 'member', subject: 'Welcome', message: 'Hello!', user_groups: [1], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/templates'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'template_name' => 'Welcome Campaign', 'subject' => 'Welcome', 'message' => $html, 'user_groups' => [1], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Saving a template SENDS NOTHING: call the bulk submit endpoint separately to send it. $tpl = Api::Tools()->CreateBulkTemplate([ 'template_name' => 'Welcome Campaign', 'subject' => 'Welcome', 'message' => $html, 'user_groups' => [1], ])['data']; ``` #### Updating a Template patch/api/v1/admin/tools/bulk/templates/{id} `Tools/UpdateBulkTemplate` admin Applies the fields you send; the name, the message and the subject cannot be emptied. Body 11 template_namestringrequiredThe template name. messagestringrequiredThe message body. subjectstringThe message subject. Required on e-mail. template_typestringThe channel. It defaults to e-mail. typestringThe audience. Whatever you send for clients is stored as `member`. submission_typestringThe envelope form. newsletterstringThe newsletter key. ccstringAddresses to copy in. without_productsintTargets clients with no product at all. birthday_marketingintTargets clients whose birthday it is. *arrayThe nine filter arrays: groups, departments, countries, languages, products, servers, add-ons, service status and client status. Response fields data — 24 dataobjectThe campaign as it now stands, with the filter arrays already decoded. Same shape as the detail endpoint. Errors 5 not_found404No such template. template_name_required422The name you sent was empty. subject_required422The subject you sent was empty. body_required422The message you sent was empty. 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/tools/bulk/templates/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"subject":"Welcome (updated)","message":"Hi there!"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/templates/5', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject: 'Welcome (updated)', message: 'Hi there!', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/templates/5'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'subject' => 'Welcome (updated)', 'message' => $html, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // If a scheduled task points at this template, the change takes effect on its NEXT run. Api::Tools()->UpdateBulkTemplate([ 'id' => 5, 'subject' => 'Welcome (updated)', ]); ``` #### Deleting a Template delete/api/v1/admin/tools/bulk/templates/{id} `Tools/DeleteBulkTemplate` admin its records go too Deletes the campaign and the send records belonging to it. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted template. Errors 2 not_found404No such template. 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/tools/bulk/templates/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/templates/5', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/templates/5'); 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 // The delete also removes the campaign's SEND HISTORY; when it went and to whom is lost. Api::Tools()->DeleteBulkTemplate(['id' => 5]); ``` #### Deleting in Bulk post/api/v1/admin/tools/bulk/templates/bulk-delete `Tools/BulkDeleteBulkTemplates` admin the panel asks for a password Deletes several campaigns. Body 1 idsint[]requiredIds of the templates to delete. Response fields data — 2 deletedboolWhether the delete ran. countintHow many templates were deleted. Errors 2 ids_required422No id was given. 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/tools/bulk/templates/bulk-delete' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[5,6,7]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/templates/bulk-delete', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [5, 6, 7] }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/templates/bulk-delete'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [5, 6, 7]]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The panel asks for the admin password here; on the API the key's scope is enough. Api::Tools()->BulkDeleteBulkTemplates(['ids' => [5, 6, 7]]); ``` ### Pitfalls > **Saving is not sending** > > Creating or updating a template sends no message; it only writes the record. Sending the campaign means calling the bulk submit endpoint separately, and sending it regularly means defining a scheduled task. > **The filters are not in the list** > > The list endpoint **does not return** the message body or the nine filter arrays; those come only on a single template's detail. Seeing who a campaign would reach means reading the detail and handing its filters to the counting endpoint. > **Deleting takes the send history too** > > Deleting a template removes not only the campaign but the **send records** that belong to it. Which announcement went when, and to whom, is lost. If you are merely done with a campaign, consider leaving it in place rather than deleting it. > **A change takes effect on the next run** > > When a scheduled task points at a template, updating it sends nothing at that moment; the new text is used on the **next** run. Correcting a wrong text does nothing about the sends that already went out. > **The panel asks for a password, the API for a scope** > > The bulk delete asks for the admin password in the panel. The API has no such second step: the key's scope is enough. Hand out a key carrying this scope knowing exactly who holds it. ### Related Articles - [Bulk Messaging](https://dev.wisecp.com/en/bulk-messaging) - [Scheduled Bulk Tasks](https://dev.wisecp.com/en/scheduled-bulk-tasks) ## Scheduled Bulk Tasks https://dev.wisecp.com/en/scheduled-bulk-tasks The six endpoints that send saved campaigns by themselves at a set time. ### Overview A scheduled task sends a saved campaign **by itself** at the time you set. The task holds the schedule, not the message: the text and the audience live on the template. There are two patterns. A **one-off** task runs on a date and is done. A **repeating** one is built from a month, a day and a time. Leaving a part out means "every", so day one with no month is the first of every month. ### Reference #### Listing the Tasks get/api/v1/admin/tools/bulk/scheduled-tasks `Tools/GetBulkScheduledTasks` admin paged Returns the plan for the bulk notifications that send themselves. Query parameters 3 pageintDefaults to 1. limitintDefaults to 25, maximum 100. searchstringSearches the tasks. Response fields data[] — 12 idintId of the task. template_namestringName of the campaign it sends. template_typestringThe channel: `mail` or `sms`. statusstring`active` is running, `paused` is stopped. periodstring`onetime` fires once, `recurring` repeats. period_datetimestring | nullWhen a one-off task fires. period_monthint | nullThe month of a repeating task. period_dayint | nullThe day of a repeating task. period_hourint | nullThe hour of a repeating task. period_minuteint | nullThe minute of a repeating task. created_atstring | nullWhen it was created. last_execstring | nullWhen it last ran. Empty means it never has. 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/tools/bulk/scheduled-tasks' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An empty last-run time says the task has NEVER run; it may be paused. $tasks = Api::Tools()->GetBulkScheduledTasks()['data']; $never = array_filter($tasks, fn (array $t): bool => $t['last_exec'] === null); ``` #### Creating a Task post/api/v1/admin/tools/bulk/scheduled-tasks `Tools/CreateBulkScheduledTask` admin 201 Opens a task that sends an existing campaign by itself at the time you set. Body 6 template_idintrequiredId of the campaign to send. The campaign must not already be marked for automatic sending. schedule_periodstring`onetime` or `recurring`. It defaults to one-off. sending_timestringWhen to send. **Required** on a one-off. period_monthintThe month of a repeating task. period_dayintThe day of a repeating task. period_timestringThe time of a repeating task. Response fields data — 12 idintId of the task. template_namestringName of the campaign it sends. template_typestringThe channel: `mail` or `sms`. statusstring`active` is running, `paused` is stopped. periodstring`onetime` fires once, `recurring` repeats. period_datetimestring | nullWhen a one-off task fires. period_monthint | nullThe month of a repeating task. period_dayint | nullThe day of a repeating task. period_hourint | nullThe hour of a repeating task. period_minuteint | nullThe minute of a repeating task. created_atstring | nullWhen it was created. last_execstring | nullWhen it last ran. Empty means it never has. Errors 4 template_required422No campaign was given. not_found404The source campaign was not found. schedule_required422No schedule was given. 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/tools/bulk/scheduled-tasks' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"template_id":5,"schedule_period":"onetime","sending_time":"2026-02-15 10:00:00"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ template_id: 5, schedule_period: 'onetime', sending_time: '2026-02-15 10:00:00', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'template_id' => 5, 'schedule_period' => 'onetime', 'sending_time' => '2026-02-15 10:00:00', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The first of every month at 08:00: day 1, month left out (every month). Api::Tools()->CreateBulkScheduledTask([ 'template_id' => 5, 'schedule_period' => 'recurring', 'period_day' => 1, 'period_time' => '08:00', ]); ``` #### Changing the Schedule patch/api/v1/admin/tools/bulk/scheduled-tasks/{id} `Tools/UpdateBulkScheduledTask` admin the schedule only Changes when the task runs. The campaign itself is untouched. Body 5 schedule_periodstring`onetime` or `recurring`. It defaults to one-off. sending_timestringWhen to send. **Required** on a one-off. period_monthintThe month of a repeating task. period_dayintThe day of a repeating task. period_timestringThe time of a repeating task. Response fields data — 12 idintId of the task. template_namestringName of the campaign it sends. template_typestringThe channel: `mail` or `sms`. statusstring`active` is running, `paused` is stopped. periodstring`onetime` fires once, `recurring` repeats. period_datetimestring | nullWhen a one-off task fires. period_monthint | nullThe month of a repeating task. period_dayint | nullThe day of a repeating task. period_hourint | nullThe hour of a repeating task. period_minuteint | nullThe minute of a repeating task. created_atstring | nullWhen it was created. last_execstring | nullWhen it last ran. Empty means it never has. Errors 3 not_found404No such scheduled task. schedule_required422No schedule was given. 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/tools/bulk/scheduled-tasks/3' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"schedule_period":"recurring","period_day":1,"period_time":"08:00"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3', { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ schedule_period: 'recurring', period_day: 1, period_time: '08:00', }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'schedule_period' => 'recurring', 'period_day' => 1, 'period_time' => '08:00', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To change the message use the template update endpoint, NOT this one. Api::Tools()->UpdateBulkTemplate(['id' => 5, 'subject' => 'Updated']); Api::Tools()->UpdateBulkScheduledTask(['id' => 3, 'period_time' => '09:00']); ``` #### Pausing a Task post/api/v1/admin/tools/bulk/scheduled-tasks/{id}/pause `Tools/PauseBulkScheduledTask` admin Stops the task. The plan stops but is not deleted. Body — ——No body is needed. The id in the path names the task; send an empty body. Response fields data — 12 idintId of the task. template_namestringName of the campaign it sends. template_typestringThe channel: `mail` or `sms`. statusstring`active` is running, `paused` is stopped. periodstring`onetime` fires once, `recurring` repeats. period_datetimestring | nullWhen a one-off task fires. period_monthint | nullThe month of a repeating task. period_dayint | nullThe day of a repeating task. period_hourint | nullThe hour of a repeating task. period_minuteint | nullThe minute of a repeating task. created_atstring | nullWhen it was created. last_execstring | nullWhen it last ran. Empty means it never has. Errors 2 not_found404No such scheduled task. 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/tools/bulk/scheduled-tasks/3/pause' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3/pause', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3/pause'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Pausing does not stop notices already IN THE QUEUE: if the send started, delete them from the queue. Api::Tools()->PauseBulkScheduledTask(['id' => 3]); ``` #### Resuming a Task post/api/v1/admin/tools/bulk/scheduled-tasks/{id}/resume `Tools/ResumeBulkScheduledTask` admin a past time is missed Puts a paused task back into service. Body — ——No body is needed. The id in the path names the task; send an empty body. Response fields data — 12 idintId of the task. template_namestringName of the campaign it sends. template_typestringThe channel: `mail` or `sms`. statusstring`active` is running, `paused` is stopped. periodstring`onetime` fires once, `recurring` repeats. period_datetimestring | nullWhen a one-off task fires. period_monthint | nullThe month of a repeating task. period_dayint | nullThe day of a repeating task. period_hourint | nullThe hour of a repeating task. period_minuteint | nullThe minute of a repeating task. created_atstring | nullWhen it was created. last_execstring | nullWhen it last ran. Empty means it never has. Errors 2 not_found404No such scheduled task. 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/tools/bulk/scheduled-tasks/3/resume' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3/resume', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3/resume'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A one-off whose time has passed does not run on resume: move the schedule forward first. $task = Api::Tools()->ResumeBulkScheduledTask(['id' => 3])['data']; if ($task['period'] === 'onetime' && $task['period_datetime'] < $now) { Api::Tools()->UpdateBulkScheduledTask([ 'id' => 3, 'sending_time' => $tomorrow, ]); } ``` #### Deleting a Task delete/api/v1/admin/tools/bulk/scheduled-tasks/{id} `Tools/DeleteBulkScheduledTask` admin its records go too Deletes the scheduled task and its run records. The campaign stays. Response fields data — 2 deletedboolWhether the delete succeeded. idintId of the deleted task. Errors 2 not_found404No such scheduled task. 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/tools/bulk/scheduled-tasks/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3', { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/tools/bulk/scheduled-tasks/3'); 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 // Deleting the task does not delete the CAMPAIGN: the template stays and can still be sent by hand. Api::Tools()->DeleteBulkScheduledTask(['id' => 3]); ``` ### Pitfalls > **A task whose time has passed does not run on resume** > > Pause a one-off task, resume it after its send time has passed, and it **never runs**. A past time does not come round again, and nothing tells you. Move the time forward first. > **The task does not hold the message** > > These endpoints change the **schedule** and nothing else. Correcting the text, the subject or the audience means going to the template endpoints. A change there takes effect on the next run. > **Pausing does not stop notices already sent** > > Pausing stops the **next** runs only. After a run the notices are already in the queue and keep going out. Stopping them means deleting the pending rows in the notification queue. > **The campaign must not already send itself** > > Creating a task requires that the source campaign is not already marked for automatic sending. Otherwise the same announcement can go out by two routes at once. The campaign's state for this is on the template detail. > **Leaving a part out means "every"** > > On a repeating task, a month or a day left out counts as **every** value. Giving only a time and forgetting the day sends the announcement daily. Read the task back from the list and check the pattern after creating it. ### Related Articles - [Bulk Templates](https://dev.wisecp.com/en/bulk-templates) - [Bulk Messaging](https://dev.wisecp.com/en/bulk-messaging) - [Notification Queue](https://dev.wisecp.com/en/notification-queue) # API / Admin API / Website ## Website Pages https://dev.wisecp.com/en/website-pages The seven endpoints for pages, contracts, news, blog posts and references. ### Overview The panel's **Pages, Contracts, News, Blog and References** tabs are all one content type. All five are managed through these seven endpoints, and the only thing separating them is the **type** you send. Content is kept **per language**: title, address, body and search-engine fields each live separately in every language. The list gives you the current language while the detail gives you all of them, so translation work starts at the detail. Images are uploaded through their own endpoints and named by **kind**. Which kinds are valid depends on the page type. The same call can work on one type and be refused on another. ### Reference #### Listing the Pages get/api/v1/admin/website/pages `Website/GetPages` admin Returns the pages of the content type you pick. Query 4 typestringWhich content type: `normal`, `contract`, `news`, `articles`, `references`. The plain page by default. searchstringSearches the titles. pageintWhich page. limitintRecords per page. A hundred at most. Response fields data[] — 6 + meta — 5 idintThe page id. typestringThe content type. titlestringIts title in the current language. routestringIts address in the current language. categorystringThe name of its category. created_atstringWhen it was created. totalintHow many there are. It comes back under meta. pageintThe page you are on. limitintThe page size. type stringThe content type filtered on. next_pageintThe next page. Zero means you are on the last one. Errors 2 invalid_type422The content type is not recognised. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/pages?type=news&limit=25' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/website/pages'); url.searchParams.set('type', 'news'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages?' . http_build_query(['type' => 'news', 'limit' => 25])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Leave the type out and only PLAIN pages come back; news lives at the same endpoint. $news = Api::Website()->GetPages([], ['type' => 'news'])['data']; ``` #### Reading One Page get/api/v1/admin/website/pages/{id} `Website/GetPage` admin Returns one page with all its languages and images. Response fields data — 12 idintThe page id. typestringThe content type: `normal`, `contract`, `news`, `articles`, `references`. categoryintThe category it belongs to. sidebarstringWhether the sidebar shows. statusstringWhether the page is live. visibilitystring | nullWhether it appears in listings. visible_to_userint | nullWhether it shows in the client panel. rankint | nullWhere it sits in the listing. optionsobjectThe options belonging to that type. The contract switches and the search-engine preference live here. created_atstring | nullWhen it was created. languagesobjectThe content per language: title, route, body, search-engine fields and, on a reference, its extra texts. imagesobjectThe image address per kind. Only the kinds that type allows. Errors 2 page_not_found404No such page. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/pages/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/pages/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list gives ONE language and the detail gives them ALL; translation work starts here. $page = Api::Website()->GetPage(['id' => $id])['data']; $en = $page['languages']['en'] ?? null; ``` #### Creating a Page post/api/v1/admin/website/pages `Website/CreatePage` admin the type is fixed Opens a new page and writes its languages. Body 15 typestringreqThe content type: `normal`, `contract`, `news`, `articles`, `references`. It cannot be changed later. languagesobjectreqThe content per language: title, route, body and search-engine fields. A title is needed in each language you send. statusstringWhether the page is live. Live by default. sidebarstringWhether the sidebar shows. categoryintThe category the page belongs to. seo_indexintLets search engines index the page. imagesobjectImages to upload in the same call. A kind the type does not allow fails the call. show_during_account_registrationintShows during sign-up. Contract type only. show_during_purchaseintShows during checkout. Contract type only. show_in_account_preferencesintShows in account preferences. Contract type only. mandatory_account_preferencesintMakes acceptance required in account preferences. Contract type only. visibilitystringWhether the page appears in listings. News and reference types only. visible_to_userintShows in the client panel as well. News type only. rankintWhere it sits in the listing. Reference type only. websitestringThe reference's address. Reference type only. Response fields 201 — data — 12 dataobjectThe page created. Same shape as the detail endpoint. Errors 7 invalid_type422The content type is not recognised. languages_required422No language was sent. title_required422A title is empty in one language. route_exists422That address is already in use in that language. invalid_kind422The image kind does not suit this type. create_failed500The page could not be created. 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/website/pages' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"normal","languages":{"tr":{"title":"Hakkimizda","content":"

    Merhaba

    "}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/pages', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'normal', languages: { en: { title: 'About Us', content: '

    Hello

    ' }, }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'normal', 'languages' => ['en' => ['title' => 'About Us', 'content' => '

    Hello

    ']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Leave the address out and it is built from the title; a clash in that language returns 422. Api::Website()->CreatePage([ 'type' => 'news', 'languages' => ['en' => ['title' => 'Maintenance notice']], ]); ``` #### Updating a Page patch/api/v1/admin/website/pages/{id} `Website/UpdatePage` admin Changes the page fields and languages you send. Body 14 languagesobjectreqThe content per language: title, route, body and search-engine fields. A title is needed in each language you send. statusstringWhether the page is live. Live by default. sidebarstringWhether the sidebar shows. categoryintThe category the page belongs to. seo_indexintLets search engines index the page. imagesobjectImages to upload in the same call. A kind the type does not allow fails the call. show_during_account_registrationintShows during sign-up. Contract type only. show_during_purchaseintShows during checkout. Contract type only. show_in_account_preferencesintShows in account preferences. Contract type only. mandatory_account_preferencesintMakes acceptance required in account preferences. Contract type only. visibilitystringWhether the page appears in listings. News and reference types only. visible_to_userintShows in the client panel as well. News type only. rankintWhere it sits in the listing. Reference type only. websitestringThe reference's address. Reference type only. Response fields data — 12 dataobjectThe page as it now stands. Same shape as the detail endpoint. Errors 5 page_not_found404No such page. title_required422A title was emptied in one language. route_exists422That address is already in use in that language. invalid_kind422The image kind does not suit this type. 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/website/pages/5' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"inactive","languages":{"tr":{"title":"Sirketimiz"}}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/pages/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'inactive', languages: { en: { title: 'About Our Company' } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages/' . $id); 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', 'languages' => ['en' => ['title' => 'About Our Company']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Only the language you send is written and the rest are KEPT; the type field is ignored. Api::Website()->UpdatePage([ 'id' => $id, 'languages' => ['en' => ['title' => 'About Our Company']], ]); ``` #### Deleting a Page delete/api/v1/admin/website/pages/{id} `Website/DeletePage` admin Removes a page along with all its languages. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the page removed. Errors 3 page_not_found404No such page. blocked_by_gate422A hook refused the delete. 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/website/pages/5' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/pages/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages/' . $id); 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 // To take a page down, switch its STATUS off rather than delete: the address survives. Api::Website()->UpdatePage(['id' => $id, 'status' => 'inactive']); ``` #### Uploading a Page Image put/api/v1/admin/website/pages/{id}/images/{kind} `Website/UploadPageImage` admin Uploads a page image and puts it in place of the one before. Path 2 idintThe page id. kindstringThe image kind: `header-background`, `cover`, `mockup`. The cover suits news, blog posts and references, while the mock-up suits references alone. Body 1 imagestringreqThe image to upload. Either an address or the data itself. Response fields 201 — data — 2 kindstringThe kind uploaded. urlstringThe image's public address. Errors 3 page_not_found404No such page. invalid_kind422The image kind does not suit this type. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/pages/5/images/header-background' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://ornek.com/afis.jpg"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/pages/${id}/images/header-background`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://example.com/banner.jpg' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages/' . $id . '/images/header-background'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['image' => 'https://example.com/banner.jpg']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The cover and mock-up kinds also BUILD a thumbnail; the banner kind leaves one file. Api::Website()->UploadPageImage([ 'id' => $id, 'kind' => 'cover', 'image' => $data, ]); ``` #### Removing a Page Image delete/api/v1/admin/website/pages/{id}/images/{kind} `Website/DeletePageImage` admin Removes a page image along with its thumbnail. Path 2 idintThe page id. kindstringThe image kind to remove: `header-background`, `cover`, `mockup`. Response fields data — 3 deletedboolWhether the delete ran. idintThe page id. kindstringThe kind removed. Errors 3 page_not_found404No such page. invalid_kind422The image kind does not suit this type. 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/website/pages/5/images/header-background' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/pages/${id}/images/header-background`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/pages/' . $id . '/images/header-background'); 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 // To REPLACE an image there is no need to delete first; the upload takes its place. Api::Website()->DeletePageImage(['id' => $id, 'kind' => 'cover']); ``` ### Pitfalls > **The type is chosen only at creation** > > The content type is part of what a record is, and the **update ignores it**. A record opened as a plain page cannot become a news item. You have to create it again with the right type and remove the old one. Getting the type wrong costs the address and the images as well. > **The address is unique per language** > > The same address can sit side by side in **different languages**, yet two pages in one language cannot share it. The clash error names the language it happened in. Leave the address out and it is built from the title, so two news items with similar titles can collide unexpectedly. > **The image kind depends on the page type** > > The banner suits every type. The **cover** is taken only on news, blog posts and references, and the **mock-up** only on references. A kind that does not suit comes back as a 422. This holds at creation too: an invalid kind in the body means the page is never created at all. > **Type-bound fields sit silently on the others** > > The contract acceptance switches work on contracts alone. So do showing a news item in the client panel, and a reference's rank and address. Sent on another type they raise **no error** and do nothing. When behaviour does not follow, check the record's type first. > **The update merges languages** > > On an update the languages you send are written and the ones you leave out are **kept**. Correcting one language does not mean carrying the others along. This differs from some other write endpoints, where the body counts as the whole definition and whatever is missing gets dropped. ### Related Articles - [Website Categories](https://dev.wisecp.com/en/website-categories) - [Website Menus](https://dev.wisecp.com/en/website-menus) - [Homepage Slides](https://dev.wisecp.com/en/homepage-slides) ## Website Categories https://dev.wisecp.com/en/website-categories The seven endpoints that create, move and remove blog and reference categories. ### Overview Categories exist for **two content types**: blog posts and references. Plain pages, contracts and news carry no category, so they never appear at these endpoints. Categories form a **tree**. Each record can have a parent, and zero puts it at the root. That information comes back only at the detail endpoint, while the list gives records in a flat order. The shape matches that of pages: content is kept **per language**, the type is fixed at creation, and the address is unique within each language. Images are simpler here, as a category has one header image. ### Reference #### Listing the Categories get/api/v1/admin/website/categories `Website/GetCategories` admin Returns the categories of the type you pick. Query 4 typestringWhich type: `articles`, `references`. Blog categories by default. searchstringSearches the titles. pageintWhich page. limitintRecords per page. A hundred at most. Response fields data[] — 6 + meta — 5 idintThe category id. typestringThe category type. statusstringWhether the category is live. titlestringIts title in the current language. routestringIts address in the current language. created_atstringWhen it was created. totalintHow many there are. It comes back under meta. pageintThe page you are on. limitintThe page size. type stringThe type filtered on. next_pageintThe next page. Zero means you are on the last one. Errors 2 invalid_type422The category type is not recognised. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/categories?type=articles' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/website/categories'); url.searchParams.set('type', 'articles'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/categories?' . http_build_query(['type' => 'articles'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list carries NO PARENT: building the tree means reading each record on its own. $cats = Api::Website()->GetCategories([], ['type' => 'articles'])['data']; ``` #### Reading One Category get/api/v1/admin/website/categories/{id} `Website/GetCategory` admin Returns one category with all its languages. Response fields data — 9 idintThe category id. typestringThe category type: `articles`, `references`. parentintThe parent category. Zero says it sits at the root. rankintWhere it sits in the listing. statusstringWhether the category is live. optionsobjectThe category's options. The search-engine preference lives here. created_atstring | nullWhen it was created. languagesobjectTitle, address, description and search-engine fields per language. imagestring | nullThe header image address. Errors 2 category_not_found404No such category. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/categories/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/categories/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/categories/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The parent id comes back ONLY here; the tree is built from this field. $cat = Api::Website()->GetCategory(['id' => $id])['data']; $isRoot = $cat['parent'] === 0; ``` #### Creating a Category post/api/v1/admin/website/categories `Website/CreateCategory` admin the type is fixed Opens a new category and writes its languages. Body 7 typestringreqThe category type: `articles`, `references`. It cannot be changed later. languagesobjectreqThe content per language: title, address, description and search-engine fields. A title is needed in each language you send. statusstringWhether the category is live. Live by default. rankintWhere it sits in the listing. seo_indexintLets search engines index the category. parentintThe parent category. Zero puts it at the root. imagestringThe header image to upload in the same call. Either an address or the data itself. Response fields 201 — data — 9 dataobjectThe category created. Same shape as the detail endpoint. Errors 6 invalid_type422The category type is not recognised. languages_required422No language was sent. title_required422A title is empty in one language. route_exists422That address is already in use in that language. create_failed500The category could not be created. 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/website/categories' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"articles","languages":{"tr":{"title":"Duyurular"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/categories', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'articles', languages: { en: { title: 'Announcements' } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/categories'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'type' => 'articles', 'languages' => ['en' => ['title' => 'Announcements']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // To nest a category give the PARENT id; zero puts it at the root. Api::Website()->CreateCategory([ 'type' => 'articles', 'parent' => $parentId, 'languages' => ['en' => ['title' => 'Release notes']], ]); ``` #### Updating a Category patch/api/v1/admin/website/categories/{id} `Website/UpdateCategory` admin Changes the category fields and languages you send. Body 6 languagesobjectreqThe content per language: title, address, description and search-engine fields. A title is needed in each language you send. statusstringWhether the category is live. Live by default. rankintWhere it sits in the listing. seo_indexintLets search engines index the category. parentintThe parent category. Zero puts it at the root. imagestringThe header image to upload in the same call. Either an address or the data itself. Response fields data — 9 dataobjectThe category as it now stands. Same shape as the detail endpoint. Errors 4 category_not_found404No such category. title_required422A title was emptied in one language. route_exists422That address is already in use in that language. 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/website/categories/3' \ -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/website/categories/${id}`, { 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/website/categories/' . $id); 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 // Changing the parent MOVES the branch; whatever sits below comes along. Api::Website()->UpdateCategory(['id' => $id, 'parent' => $newParent]); ``` #### Deleting a Category delete/api/v1/admin/website/categories/{id} `Website/DeleteCategory` admin sub-categories go too Removes a category and every category beneath it. Response fields data — 3 deletedboolWhether the delete ran. idintThe id of the category removed. removedarrayEvery id removed. The sub-categories appear in this list too. Errors 2 category_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/website/categories/3' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/categories/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); console.log(data.removed); // [3, 7, 8] ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/categories/' . $id); 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 // Read the RETURNED list to see how many went; a call you read as one can take a branch. $gone = Api::Website()->DeleteCategory(['id' => $id])['data']['removed']; ``` #### Uploading a Category Image put/api/v1/admin/website/categories/{id}/image `Website/UploadCategoryImage` admin Uploads the category header image, in place of the one before. Body 1 imagestringreqThe image to upload. A thumbnail is built as well. Response fields 201 — data — 2 kindstringThe image kind. A category has one kind. urlstringThe image's public address. Errors 2 category_not_found404No such category. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/categories/3/image' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://ornek.com/kapak.jpg"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/categories/${id}/image`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://example.com/cover.jpg' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/categories/' . $id . '/image'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['image' => 'https://example.com/cover.jpg']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A category has ONE image; unlike a page, there is no kind to choose. Api::Website()->UploadCategoryImage(['id' => $id, 'image' => $data]); ``` #### Removing a Category Image delete/api/v1/admin/website/categories/{id}/image `Website/DeleteCategoryImage` admin Removes the category header image. Response fields data — 2 deletedboolWhether the delete ran. idintThe category id. Errors 2 category_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/website/categories/3/image' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/categories/${id}/image`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/categories/' . $id . '/image'); 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 // To REPLACE the image there is no need to delete first; the upload takes its place. Api::Website()->DeleteCategoryImage(['id' => $id]); ``` ### Pitfalls > **A delete takes the whole branch** > > Deleting a category also deletes **every category beneath it**. What you read as removing one record can take a branch of the tree. The removed list in the response tells you what actually went, so read it before counting the call a success. Reading the detail first to see the parent links is the safest course. > **The list does not show the tree** > > The listing endpoint **does not return** the parent field and gives records in a flat order. You cannot build a tree from it. When you need the hierarchy, read each record from the detail endpoint; there are rarely many categories, so this seldom hurts. > **The type is fixed at creation** > > The category type is part of what a record is, and the **update ignores it**. A category opened for blog posts will not become one for references. Fixing a category opened under the wrong type means creating a new one and moving what sits inside. > **The address is unique per language** > > The same address can sit side by side in **different languages**, yet two categories in one language cannot share it. Categories and pages draw on the same address space, so a category can clash with a page as well. Leave the address out and it is built from the title. > **Changing the parent moves the branch** > > Changing the parent on an update moves **everything beneath it** along. Putting a category under one of its own descendants closes the tree on itself, and that branch drops out of the listings. Before moving one, make sure the target does not sit inside the branch you are moving. ### Related Articles - [Website Pages](https://dev.wisecp.com/en/website-pages) - [Website Menus](https://dev.wisecp.com/en/website-menus) - [Customer Testimonials](https://dev.wisecp.com/en/customer-testimonials) ## Homepage Slides https://dev.wisecp.com/en/homepage-slides The eight endpoints for the homepage slider, its images and its videos. ### Overview Slides feed the rotating area at the top of the homepage. Every slide has a **main image**, and that image is required; the texts, the link and the video are not. Texts are kept **per language**: title, description and link live separately in each. Leave the text out in one language and the slide quietly shows there as image alone. The image and the video have **endpoints of their own**. The image is always there, while the video is an optional layer, and removing it leaves the slide back on its image. ### Reference #### Listing the Slides get/api/v1/admin/website/slides `Website/GetSlides` admin Returns the slides in the homepage slider. Query 3 searchstringSearches the titles. pageintWhich page. limitintRecords per page. A hundred at most. Response fields data[] — 4 + meta — 4 idintThe slide id. titlestringIts title in the current language. linkstringIts link in the current language. created_atstringWhen it was created. totalintHow many slides there are. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/website/slides' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/slides', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list carries NO STATUS and NO RANK: read the detail to see which slide is live. $slides = Api::Website()->GetSlides()['data']; ``` #### Reading One Slide get/api/v1/admin/website/slides/{id} `Website/GetSlide` admin Returns one slide with its texts, image and video. Response fields data — 7 idintThe slide id. statusstringWhether the slide is live. rankintWhere it sits in the slider. created_atstring | nullWhen it was created. languagesobjectThe title, description and link per language. imagestring | nullThe main image address. videoobject | nullThe video: its address and length. Empty when no video was set. Errors 2 slide_not_found404No such slide. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/slides/2' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/slides/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The video field can come back EMPTY, so check it exists before reading. $slide = Api::Website()->GetSlide(['id' => $id])['data']; $clip = $slide['video']['url'] ?? null; ``` #### Creating a Slide post/api/v1/admin/website/slides `Website/CreateSlide` admin the image is required Adds a new slide to the homepage slider. Body 4 imagestringreqThe slide's main image. A slide cannot be created without one. statusstringWhether the slide is live. Live by default. rankintWhere it sits in the slider. languagesobjectThe title, description and link per language. None of the texts is required. Response fields 201 — data — 7 dataobjectThe slide created. Same shape as the detail endpoint. Errors 3 image_required422No main image was sent. create_failed500The slide could not be created. 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/website/slides' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://ornek.com/slayt.jpg","languages":{"tr":{"title":"Hos geldiniz"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/slides', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://example.com/slide.jpg', languages: { en: { title: 'Welcome', link: 'https://example.com/' } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'image' => 'https://example.com/slide.jpg', 'languages' => ['en' => ['title' => 'Welcome', 'link' => 'https://example.com/']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A slide is born LIVE and reaches the homepage at once; send it off while preparing. Api::Website()->CreateSlide([ 'image' => $data, 'status' => 'inactive', ]); ``` #### Updating a Slide patch/api/v1/admin/website/slides/{id} `Website/UpdateSlide` admin Changes a slide's status, its place and its texts. Body 4 statusstringWhether the slide is live. rankintWhere it sits in the slider. languagesobjectThe title, description and link per language. imagestringReplaces the main image in the same call. Response fields data — 7 dataobjectThe slide as it now stands. Same shape as the detail endpoint. Errors 2 slide_not_found404No such slide. 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/website/slides/2' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"inactive","rank":1}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/slides/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'inactive', rank: 1 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides/' . $id); 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', 'rank' => 1]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A rank does NOT push the others aside; give two slides the same number and the order blurs. foreach ($order as $i => $slideId) Api::Website()->UpdateSlide(['id' => $slideId, 'rank' => $i]); ``` #### Deleting a Slide delete/api/v1/admin/website/slides/{id} `Website/DeleteSlide` admin Removes a slide along with its image and video. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the slide removed. Errors 3 slide_not_found404No such slide. delete_failed500The slide could not be removed. 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/website/slides/2' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/slides/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides/' . $id); 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 // Rather than delete a seasonal slide, switch its STATUS off: image and texts survive. Api::Website()->UpdateSlide(['id' => $id, 'status' => 'inactive']); ``` #### Replacing the Slide Image put/api/v1/admin/website/slides/{id}/image `Website/SetSlideImage` admin Puts a new main image in place of the slide's current one. Body 1 imagestringreqThe new main image. A thumbnail is built as well. Response fields 201 — data — 1 urlstringThe new image's public address. Errors 2 slide_not_found404No such slide. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/slides/2/image' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://ornek.com/yeni.jpg"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/slides/${id}/image`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://example.com/new.jpg' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides/' . $id . '/image'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['image' => 'https://example.com/new.jpg']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The update call does this too; the separate endpoint is for changing the image alone. Api::Website()->SetSlideImage(['id' => $id, 'image' => $data]); ``` #### Setting a Slide Video put/api/v1/admin/website/slides/{id}/video `Website/SetSlideVideo` admin Sets a background video on a slide. Body 2 videostringreqThe video to upload. One format is accepted. video_durationintHow long the video runs. The slider times its change by this. Response fields 201 — data — 2 urlstringThe video's public address. durationintThe length stored. Errors 2 slide_not_found404No such slide. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/slides/2/video' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"video":"https://ornek.com/klip.mp4","video_duration":12}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/slides/${id}/video`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ video: 'https://example.com/clip.mp4', video_duration: 12, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides/' . $id . '/video'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'video' => 'https://example.com/clip.mp4', 'video_duration' => 12, ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // LEAVE THE LENGTH OUT and the slider change falls out of step; send the seconds with it. Api::Website()->SetSlideVideo([ 'id' => $id, 'video' => $data, 'video_duration' => 12, ]); ``` #### Removing a Slide Video delete/api/v1/admin/website/slides/{id}/video `Website/DeleteSlideVideo` admin Removes a slide's video, leaving it back on its image. Response fields data — 2 deletedboolWhether the call ran. It comes back true even with no video there. idintThe slide id. Errors 2 slide_not_found404No such slide. 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/website/slides/2/video' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/slides/${id}/video`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/slides/' . $id . '/video'); 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 // On a slide with no video the call passes QUIETLY; there is no need to check first. Api::Website()->DeleteSlideVideo(['id' => $id]); ``` ### Pitfalls > **A new slide reaches the homepage at once** > > Leave the status field out and the slide is created **live**, so visitors begin seeing it that moment. The homepage is the most visible place on the site, so create a slide you are still preparing switched off and turn it on once the texts are done. > **The list carries no status and no rank** > > The listing gives the id, title, link and date, and carries **no status and no rank field**. From the list alone you cannot tell which slide is live or in what order they appear. Read each slide from the detail endpoint for that; there are rarely many. > **A rank does not push the others aside** > > Giving one slide a rank **does not shift** the others. Give two slides the same number and which comes first is left open, so the order can change from one page load to the next. When reordering, write increasing numbers across all of them. > **Without the length the change falls out of step** > > The slider times its move to the next slide by the **length you give**. Upload a video without it and the change falls out of step: the clip is cut off, or a frozen frame stays once it ends. Send the length together with the video. > **The image cannot be removed, only replaced** > > The video has an endpoint that removes it and the **image does not**. The main image is what makes a slide a slide: you can replace it, yet you cannot leave it empty. Letting go of the image means deleting the slide, and that takes its image and video with it. ### Related Articles - [Website Pages](https://dev.wisecp.com/en/website-pages) - [Customer Testimonials](https://dev.wisecp.com/en/customer-testimonials) - [Website Menus](https://dev.wisecp.com/en/website-menus) ## Customer Testimonials https://dev.wisecp.com/en/customer-testimonials The seven endpoints that add, approve and remove the testimonials shown on the site. ### Overview Testimonials are the reference texts shown on the site. Each carries a **status**: pending, approved or rejected. **Only the approved ones** appear on the site. The text is kept **per language**, while the name, company and avatar are shared. What a visitor submits starts as pending and stays off the site until an administrator approves it. These endpoints also let you **add a testimonial through the panel**: a reference that arrived on paper or by e-mail can be typed in and created approved outright. ### Reference #### Listing the Testimonials get/api/v1/admin/website/cfeedbacks `Website/GetCfeedbacks` admin Returns the customer testimonials on the site. Query 3 searchstringSearches the name, company, e-mail and sending address. pageintWhich page. limitintRecords per page. A hundred at most. Response fields data[] — 7 + meta — 4 idintThe testimonial id. full_namestringThe name of whoever left it. company_namestringThe person's company. emailstringTheir e-mail address. statusstringThe testimonial status: `pending`, `approved`, `rejected`. rankintWhere it sits in the listing. created_atstring | nullWhen it was submitted. totalintHow many there are. It comes back under meta. 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 'https://panel.example.com/api/v1/admin/website/cfeedbacks' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/cfeedbacks', { headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); const waiting = data.filter((f) => f.status === 'pending'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is NO status filter; sift the list yourself to find what waits for a decision. $all = Api::Website()->GetCfeedbacks()['data']; $waiting = array_filter($all, fn ($f) => $f['status'] === 'pending'); ``` #### Reading One Testimonial get/api/v1/admin/website/cfeedbacks/{id} `Website/GetCfeedback` admin Returns one testimonial with its text and avatar. Response fields data — 11 idintThe testimonial id. full_namestringThe name of whoever left it. company_namestringThe person's company. emailstringTheir e-mail address. statusstringThe testimonial status: `pending`, `approved`, `rejected`. rankintWhere it sits in the listing. unreadintThe unread mark. It is one on an approved testimonial. ipstringThe address it came from. created_atstring | nullWhen it was submitted. languagesobjectThe testimonial text per language. picturestring | nullThe avatar address. Errors 2 cfeedback_not_found404No such testimonial. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/cfeedbacks/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/cfeedbacks/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The TEXT and the sending ADDRESS come back only here; the list carries neither. $fb = Api::Website()->GetCfeedback(['id' => $id])['data']; $en = $fb['languages']['en']['message'] ?? ''; ``` #### Creating a Testimonial post/api/v1/admin/website/cfeedbacks `Website/CreateCfeedback` admin Adds a customer testimonial through the panel. Body 7 full_namestringreqThe name of whoever left it. languagesobjectreqThe testimonial text per language. Text is needed in each language you send. company_namestringThe person's company. emailstringTheir e-mail address. It is not shown on the site. statusstringThe testimonial status: `pending`, `approved`, `rejected`. Pending by default. rankintWhere it sits in the listing. imagestringThe avatar to upload in the same call. Response fields 201 — data — 11 dataobjectThe testimonial created. Same shape as the detail endpoint. Errors 6 full_name_required422The name is empty. languages_required422No language was sent. message_required422The text is empty in one language. invalid_status422The status is not recognised. create_failed500The testimonial could not be created. 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/website/cfeedbacks' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"full_name":"Ayse Yilmaz","status":"approved","languages":{"tr":{"message":"Harika hizmet!"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/cfeedbacks', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ full_name: 'John Doe', company_name: 'Example Inc.', status: 'approved', languages: { en: { message: 'Great service!' } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'full_name' => 'John Doe', 'status' => 'approved', 'languages' => ['en' => ['message' => 'Great service!']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Leave the status out and it is born PENDING, unseen on the site; approve it as well. Api::Website()->CreateCfeedback([ 'full_name' => 'John Doe', 'status' => 'approved', 'languages' => ['en' => ['message' => 'Great service!']], ]); ``` #### Updating a Testimonial patch/api/v1/admin/website/cfeedbacks/{id} `Website/UpdateCfeedback` admin approval happens here Changes a testimonial's status, text and details. Body 7 full_namestringThe name of whoever left it. Left out, the current value is kept. languagesobjectreqThe testimonial text per language. Text is needed in each language you send. company_namestringThe person's company. emailstringTheir e-mail address. It is not shown on the site. statusstringThe testimonial status: `pending`, `approved`, `rejected`. Pending by default. rankintWhere it sits in the listing. imagestringThe avatar to upload in the same call. Response fields data — 11 dataobjectThe testimonial as it now stands. Same shape as the detail endpoint. Errors 5 cfeedback_not_found404No such testimonial. full_name_required422The name was emptied. message_required422The text was emptied in one language. invalid_status422The status is not recognised. 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/website/cfeedbacks/4' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"status":"approved"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/cfeedbacks/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ status: 'approved' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['status' => 'approved']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Approval is a STATUS change rather than a switch; it is the one thing that publishes. Api::Website()->UpdateCfeedback(['id' => $id, 'status' => 'approved']); ``` #### Deleting a Testimonial delete/api/v1/admin/website/cfeedbacks/{id} `Website/DeleteCfeedback` admin Removes a testimonial. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the testimonial removed. Errors 3 cfeedback_not_found404No such testimonial. delete_failed500The testimonial could not be removed. 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/website/cfeedbacks/4' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/cfeedbacks/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks/' . $id); 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 // Rather than delete, REJECT it: the record stays off the site while the words survive. Api::Website()->UpdateCfeedback(['id' => $id, 'status' => 'rejected']); ``` #### Uploading the Avatar put/api/v1/admin/website/cfeedbacks/{id}/picture `Website/SetCfeedbackPicture` admin Uploads the avatar shown beside a testimonial. Body 1 imagestringreqThe avatar to upload. Either an address or the data itself. Response fields 201 — data — 1 urlstringThe avatar's public address. Errors 2 cfeedback_not_found404No such testimonial. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/cfeedbacks/4/picture' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"image":"https://ornek.com/avatar.jpg"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/cfeedbacks/${id}/picture`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ image: 'https://example.com/avatar.jpg' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks/' . $id . '/picture'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['image' => 'https://example.com/avatar.jpg']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The avatar is NOT required; without one the theme puts its own placeholder there. Api::Website()->SetCfeedbackPicture(['id' => $id, 'image' => $data]); ``` #### Removing the Avatar delete/api/v1/admin/website/cfeedbacks/{id}/picture `Website/DeleteCfeedbackPicture` admin Removes a testimonial's avatar. Response fields data — 2 deletedboolWhether the delete ran. idintThe testimonial id. Errors 2 cfeedback_not_found404No such testimonial. 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/website/cfeedbacks/4/picture' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/cfeedbacks/${id}/picture`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/cfeedbacks/' . $id . '/picture'); 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 // To REPLACE the avatar there is no need to delete first; the upload takes its place. Api::Website()->DeleteCfeedbackPicture(['id' => $id]); ``` ### Pitfalls > **A new testimonial is born pending** > > Leave the status field out and the testimonial is created **pending**, unseen on the site. For a reference typed in through the panel that is rarely what you want, so send the status approved at creation or update it straight after. For what a visitor submits, pending is the right start. > **You cannot filter by status** > > The listing takes only a search term, a page and a page size, and offers **no status filter**. Finding what waits for approval means pulling the list and sifting it yourself. On an installation with many testimonials, plan for walking the pages and sifting each one. > **The text is not in the list** > > The list gives the name, company, e-mail and status, yet **not the testimonial itself**. Deciding what to approve means reading each record from the detail endpoint. Weigh that when writing an approval flow: the list alone cannot carry the decision. > **The e-mail and the address stay on the record** > > A testimonial record carries the sender's e-mail and the **address it came from**. Neither is shown on the site, yet both can be read from the detail endpoint. That is personal data, so take care not to carry these two fields along when writing an integration that moves testimonials elsewhere. > **Reject rather than delete** > > A rejected testimonial stays off the site while the **record remains**: who wrote what, when, and what was decided all stay readable. Deleting takes all of that, and if the same person writes again you have no history to look at. ### Related Articles - [Website Pages](https://dev.wisecp.com/en/website-pages) - [Homepage Slides](https://dev.wisecp.com/en/homepage-slides) - [The Contact Inbox](https://dev.wisecp.com/en/contact-inbox) ## Website Menus https://dev.wisecp.com/en/website-menus The seven endpoints that build, order and edit the trees of five menu groups. ### Overview Website menus live in **five separate groups**: the header, the footer, the client panel, mobile and the sidebar. Each group is its own tree, and items do not move between groups. An item either **points at a page** or carries a link written by hand. One pointing at a page follows that page when its address changes, while a hand-written link stays as it is. The title, description, badge and mega-menu content are kept **per language**. The order and the nesting are written through an endpoint of their own, in one call. ### Reference #### Reading the Menu Tree get/api/v1/admin/website/menus `Website/GetMenus` admin Returns a whole menu group as a nested tree. Query 1 groupstringWhich menu: `header`, `footer`, `clientArea`, `mobile`, `sidebar`. The header menu by default. Response fields data[] — 11 + meta — 2 idintThe item id. parentintThe parent item. Zero says it sits at the top level. typestringThe menu group it belongs to. iconstringThe item icon. rankintWhere it sits among its siblings. targetboolWhether the link opens in a new tab. statusstringWhether the item is live. pagestringThe page it points at. Empty means the link was written by hand. only_client_areaboolWhether the item shows in the client panel alone. languagesobjectThe title, link, description, badge and mega-menu content per language. childrenarrayThe items beneath it. Same shape, nesting further down. groupstringThe menu group asked for. It comes back under meta. countintHow many items sit at the top level. Errors 2 invalid_group422The menu group is not recognised. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/menus?group=header' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/website/menus'); url.searchParams.set('group', 'header'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus?' . http_build_query(['group' => 'header'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Each group is a SEPARATE tree; seeing them all takes five calls. $header = Api::Website()->GetMenus([], ['group' => 'header'])['data']; $footer = Api::Website()->GetMenus([], ['group' => 'footer'])['data']; ``` #### Reading the Page Options get/api/v1/admin/website/menus/page-options `Website/GetMenuPageOptions` admin Returns the pages a menu item can point at. Response fields data dataarrayThe list of pages you can point at. The keys for the page field come from here. 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/website/menus/page-options' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/menus/page-options', { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus/page-options'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Do not write the keys into your code: the list follows the installation's own pages. $options = Api::Website()->GetMenuPageOptions()['data']; ``` #### Reordering the Menu put/api/v1/admin/website/menus/reorder `Website/ReorderMenus` admin order and parent together Writes the order and the nesting of menu items in one call. Body 1 ordersarrayreqThe new arrangement. Each entry carries an id, a position and a parent, and children nest through their own list. Response fields data — 1 reorderedintHow many items were written. The nested ones count too. Errors 2 orders_required422No item was sent. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/menus/reorder' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"orders":[{"id":10,"position":0,"parentId":0,"submenuOrder":[{"id":11,"position":0,"parentId":10}]}]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/menus/reorder', { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ orders: [ { id: 10, position: 0, parentId: 0, submenuOrder: [{ id: 11, position: 0, parentId: 10 }], }, ], }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus/reorder'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'orders' => [[ 'id' => 10, 'position' => 0, 'parentId' => 0, 'submenuOrder' => [['id' => 11, 'position' => 0, 'parentId' => 10]], ]], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An item you leave out STAYS PUT: a partial list can leave the tree half-moved. $r = Api::Website()->ReorderMenus(['orders' => $tree]); $written = $r['data']['reordered']; ``` #### Reading One Menu Item get/api/v1/admin/website/menus/{id} `Website/GetMenu` admin Returns one menu item with its languages. Response fields data — 11 idintThe item id. parentintThe parent item. Zero says it sits at the top level. typestringThe menu group it belongs to. iconstringThe item icon. rankintWhere it sits among its siblings. targetboolWhether the link opens in a new tab. statusstringWhether the item is live. pagestringThe page it points at. Empty means the link was written by hand. only_client_areaboolWhether the item shows in the client panel alone. languagesobjectThe title, link, description, badge and mega-menu content per language. childrenarrayThe items beneath it. Same shape, nesting further down. Errors 2 menu_not_found404No such menu item. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/menus/10' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/menus/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The children list comes back EMPTY here; use the group endpoint to see the tree. $item = Api::Website()->GetMenu(['id' => $id])['data']; ``` #### Adding a Menu Item post/api/v1/admin/website/menus `Website/CreateMenu` admin Adds a new item to a menu. Body 7 groupstringreqThe menu the item goes into: `header`, `footer`, `clientArea`, `mobile`, `sidebar`. parentintThe parent item. Left out, the item joins the top level. pagestringThe page to point at. Left empty, you write the link by hand in the language field. iconstringThe item icon. targetintOpens the link in a new tab. rankintWhere it sits among its siblings. languagesobjectThe content per language: title, link, description, badge text with its colours, and mega-menu content. Response fields 201 — data — 11 dataobjectThe menu item created. Same shape as the detail endpoint. Errors 3 invalid_group422The menu group is not recognised. create_failed500The item could not be created. 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/website/menus' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"group":"header","page":"home","languages":{"tr":{"title":"Ana Sayfa"}}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/menus', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ group: 'header', page: 'home', languages: { en: { title: 'Home', description: 'Back to homepage', label: 'New' }, }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'group' => 'header', 'page' => 'home', 'languages' => ['en' => ['title' => 'Home']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The title is written PER LANGUAGE; a language you leave out shows an empty label. Api::Website()->CreateMenu([ 'group' => 'header', 'page' => 'home', 'languages' => ['en' => ['title' => 'Home'], 'tr' => ['title' => 'Ana Sayfa']], ]); ``` #### Updating a Menu Item patch/api/v1/admin/website/menus/{id} `Website/UpdateMenu` admin Changes the menu item fields you send. Body 6 iconstringThe item icon. pagestringThe page to point at. targetintOpens the link in a new tab. rankintWhere it sits among its siblings. parentintThe parent item. Moves the item to another branch. languagesobjectThe content per language: title, link, description, badge text with its colours, and mega-menu content. Response fields data — 11 dataobjectThe item as it now stands. Same shape as the detail endpoint. Errors 2 menu_not_found404No such menu item. 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/website/menus/10' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"rank":1,"languages":{"tr":{"title":"Anasayfa"}}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/menus/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rank: 1, languages: { en: { title: 'Homepage' } }, }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'rank' => 1, 'languages' => ['en' => ['title' => 'Homepage']], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An item cannot move to another GROUP; the group is given at creation alone. Api::Website()->UpdateMenu(['id' => $id, 'parent' => $newParent]); ``` #### Deleting a Menu Item delete/api/v1/admin/website/menus/{id} `Website/DeleteMenu` admin children go too Removes a menu item and everything beneath it. Response fields data — 3 deletedboolWhether the delete ran. idintThe id of the item removed. removedarrayEvery id removed. The children appear in this list too. Errors 2 menu_not_found404No such menu item. 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/website/menus/10' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/menus/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const { data } = await res.json(); console.log(data.removed); // [10, 11] ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/menus/' . $id); 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 // Deleting a parent takes the SUBMENU with it; move the children elsewhere first. $gone = Api::Website()->DeleteMenu(['id' => $id])['data']['removed']; ``` ### Pitfalls > **A delete takes the submenu with it** > > Deleting a menu item also deletes **everything beneath it**. Removing a top-level heading takes every link under it out of the menu. The removed list in the response tells you what actually went, so move the children under another item first when you want to keep them. > **An item cannot change group** > > The menu group is given **at creation alone**, and the update does not take it. Moving a header link into the footer means creating it again in the new group and removing the old one. The same holds for the reorder endpoint: it shifts things only within one tree. > **The reorder covers only what you send** > > The reorder endpoint writes **only the items in your list**, and whatever you leave out stays where it was. Sending part of the tree and skipping the rest can leave the menu half-moved. Read the number written from the response and weigh it against what you expected. > **The title is per language** > > A menu title is written separately in each language. Skip one and the item shows there with an **empty label**: it does not vanish, it stands nameless. Switch a new language on and none of the existing items carries a title in it, so you have to walk through them all. > **A page link and a hand-written link differ** > > An item pointing at a page **follows it by itself** when the page address changes. A hand-written link stays as it is and breaks once the page moves. When the target is a page on your own site, use the page field instead of typing an address; the page options endpoint gives you the valid keys. ### Related Articles - [Website Pages](https://dev.wisecp.com/en/website-pages) - [Website Categories](https://dev.wisecp.com/en/website-categories) - [The Contact Inbox](https://dev.wisecp.com/en/contact-inbox) ## The Contact Inbox https://dev.wisecp.com/en/contact-inbox The nine endpoints that read, move and answer the contact form inbox. ### Overview Messages from the contact form behave like an **inbox**: unread, read, replied, spam and trash. These nine endpoints read that inbox, move messages between folders and carry out two real actions. The folder is **not a stored field**; it is computed from the record's status and its read mark. Moving a message to read leaves the status alone and flips the mark instead. Two endpoints reach outside the installation: **replying** sends the visitor a real e-mail, and **converting** opens a real support ticket. Neither can be undone. ### Reference #### Listing the Messages get/api/v1/admin/website/messages `Website/GetMessages` admin Returns the contact form messages in a folder. Query 4 folderstringWhich folder: `unread`, `read`, `replied`, `spam`, `trash`. The unread ones by default. searchstringSearches the messages. pageintWhich page. limitintRecords per page. A hundred at most. Response fields data[] — 16 + meta — 5 idintThe message id. full_namestringThe sender name. emailstringThe sender e-mail. phonestringThe sender phone. messagestringThe message text. ipstringThe address it came from. langstringThe language the form was filled in. statusstringThe record's raw status. Normal, replied, spam or trash. folderstringThe computed folder: `unread`, `read`, `replied`, `spam`, `trash`. Derived from the status and the read mark. unreadintThe read mark. **The logic is inverted:** zero means unread and one means read. admin_messagestringThe text of the reply sent. replied_byintThe administrator who replied. replied_atstring | nullWhen the reply went out. converted_to_ticket_idintThe ticket it became. Zero means it has not been converted. read_byobjectWho read it and when. created_atstring | nullWhen it was submitted. totalintHow many the folder holds. It comes back under meta. pageintThe page you are on. limitintThe page size. folder stringThe folder listed. next_pageintThe next page. Zero means you are on the last one. Errors 2 invalid_folder422The folder is not recognised. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/messages?folder=unread' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/admin/website/messages'); url.searchParams.set('folder', 'unread'); const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages?' . http_build_query(['folder' => 'unread'])); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The read mark is INVERTED: zero means unread and one means read. $msgs = Api::Website()->GetMessages([], ['folder' => 'unread'])['data']; $isRead = $msgs[0]['unread'] === 1; ``` #### Reading One Message get/api/v1/admin/website/messages/{id} `Website/GetMessage` admin Returns a single message. Response fields data — 16 idintThe message id. full_namestringThe sender name. emailstringThe sender e-mail. phonestringThe sender phone. messagestringThe message text. ipstringThe address it came from. langstringThe language the form was filled in. statusstringThe record's raw status. Normal, replied, spam or trash. folderstringThe computed folder: `unread`, `read`, `replied`, `spam`, `trash`. Derived from the status and the read mark. unreadintThe read mark. **The logic is inverted:** zero means unread and one means read. admin_messagestringThe text of the reply sent. replied_byintThe administrator who replied. replied_atstring | nullWhen the reply went out. converted_to_ticket_idintThe ticket it became. Zero means it has not been converted. read_byobjectWho read it and when. created_atstring | nullWhen it was submitted. Errors 2 message_not_found404No such message. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/admin/website/messages/20' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/messages/${id}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Reading does NOT mark it read; the mark is set by a call of its own. $msg = Api::Website()->GetMessage(['id' => $id])['data']; ``` #### Moving Messages in Bulk post/api/v1/admin/website/messages/bulk-move `Website/BulkMoveMessages` admin can block the sender Moves several messages into another folder in one call. Body 4 idsarrayreqThe message ids to move. folderstringreqThe folder to move them to: `unread`, `read`, `replied`, `spam`, `trash`. block_emailsboolAdds the sender's e-mail and phone to the banned list. It works on the spam folder alone. report_spamboolBlocks the sender's address. It works on the spam folder alone. Response fields data — 2 movedintHow many were moved. folderstringThe folder they went to. Errors 3 ids_required422No message was given. invalid_folder422The folder is not recognised. 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/website/messages/bulk-move' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"ids":[20,21],"folder":"trash"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/messages/bulk-move', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids: [20, 21], folder: 'trash' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/bulk-move'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['ids' => [20, 21], 'folder' => 'trash']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Two options reach PAST THE MESSAGE: the sender is banned and their address blocked. Api::Website()->BulkMoveMessages([ 'ids' => $ids, 'folder' => 'spam', 'block_emails' => true, 'report_spam' => true, ]); ``` #### Emptying a Folder post/api/v1/admin/website/messages/empty-folder `Website/EmptyMessageFolder` admin a permanent delete Empties the spam or the trash folder outright. Body 1 folderstringreqThe folder to empty. Only the spam and trash folders can be emptied. Response fields data — 1 emptiedstringThe folder emptied. Errors 2 invalid_folder422That folder cannot be emptied. 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/website/messages/empty-folder' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"folder":"trash"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/admin/website/messages/empty-folder', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ folder: 'trash' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/empty-folder'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['folder' => 'trash']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // It does NOT say how many went and cannot be undone; list the folder and count first. $before = Api::Website()->GetMessages([], ['folder' => 'trash'])['meta']['total']; Api::Website()->EmptyMessageFolder(['folder' => 'trash']); ``` #### Moving One Message put/api/v1/admin/website/messages/{id}/folder `Website/SetMessageFolder` admin Moves one message into another folder. Body 3 folderstringreqThe folder to move it to: `unread`, `read`, `replied`, `spam`, `trash`. block_emailsboolAdds the sender to the banned list. report_spamboolBlocks the sender's address. Response fields data — 16 dataobjectThe message as it now stands. Same shape as the detail endpoint. Errors 3 message_not_found404No such message. invalid_folder422The folder is not recognised. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/admin/website/messages/20/folder' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"folder":"read"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/messages/${id}/folder`, { method: 'PUT', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ folder: 'read' }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/' . $id . '/folder'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['folder' => 'read']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The read and unread folders leave the STATUS alone and flip the read mark instead. Api::Website()->SetMessageFolder(['id' => $id, 'folder' => 'read']); ``` #### Marking a Message Read post/api/v1/admin/website/messages/{id}/read `Website/ReadMessage` admin Marks a message read and records who read it. Body — ——No body is needed. The message comes from the path; send an empty body. Response fields data — 16 dataobjectThe message as it now stands. The administrator joins the readers map. Errors 2 message_not_found404No such message. 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/website/messages/20/read' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/messages/${id}/read`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/' . $id . '/read'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The readers map keeps EACH administrator apart, while the mark is one for the panel. Api::Website()->ReadMessage(['id' => $id]); ``` #### Replying to a Message post/api/v1/admin/website/messages/{id}/reply `Website/ReplyMessage` admin a real e-mail goes out Sends the visitor an e-mail reply and moves the message to replied. Body 3 messagestringreqThe reply text to send. save_as_templateintKeeps the reply for later use. template_namestringThe name to keep it under. Needed when you ask for it to be kept. Response fields data — 16 dataobjectThe message as it now stands. Its status becomes replied. Errors 5 message_not_found404No such message. message_required422The reply text is empty. template_name_required422Keeping was asked for with no name given. reply_failed422The e-mail could not be sent. 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/website/messages/20/reply' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"message":"Ilginiz icin tesekkurler, en kisa surede donecegiz."}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/messages/${id}/reply`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message: "Thanks for reaching out - we'll get back to you shortly.", }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/' . $id . '/reply'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'message' => 'Thanks for reaching out.', ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This call MAILS THE VISITOR and cannot be undone; check the text before sending. Api::Website()->ReplyMessage([ 'id' => $id, 'message' => $text, ]); ``` #### Turning It into a Ticket post/api/v1/admin/website/messages/{id}/convert-to-ticket `Website/ConvertMessageToTicket` admin opens a real ticket Opens a support ticket from a message and ties the two together. Body 4 departmentintreqThe department the ticket goes to. priorityintThe ticket priority. staffintThe administrator to assign the ticket to. notesstringAn internal note for the ticket. Response fields data — 2 ticket_idintThe ticket opened. message_idintThe message it came from. Errors 5 message_not_found404No such message. department_required422No department was given. blocked_by_gate422A hook refused the conversion. ticket_failed500The ticket could not be opened. 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/website/messages/20/convert-to-ticket' \ -H "Authorization: Bearer $API_KEY" \ -H 'Content-Type: application/json' \ -d '{"department":1,"priority":2}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/messages/${id}/convert-to-ticket`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ department: 1, priority: 2 }), }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/' . $id . '/convert-to-ticket'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['department' => 1, 'priority' => 2]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The message CARRIES which ticket it became; read that before converting again. $msg = Api::Website()->GetMessage(['id' => $id])['data']; if ($msg['converted_to_ticket_id'] === 0) Api::Website()->ConvertMessageToTicket(['id' => $id, 'department' => 1]); ``` #### Deleting a Message delete/api/v1/admin/website/messages/{id} `Website/DeleteMessage` admin Removes one message for good. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the message removed. Errors 2 message_not_found404No such message. 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/website/messages/20' \ -H "Authorization: Bearer $API_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/admin/website/messages/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/admin/website/messages/' . $id); 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 // Move it to TRASH rather than delete: the record stays and can come back if needed. Api::Website()->SetMessageFolder(['id' => $id, 'folder' => 'trash']); ``` ### Pitfalls > **The read mark runs backwards** > > In the read field **zero means unread and one means read**. The name suggests the opposite, so the logic here often gets read backwards and the inbox count comes out wrong. Test against one rather than trusting the value as a boolean. > **The folder is computed rather than stored** > > What the record holds is the **status and the read mark**, and the folder is derived from those two. Moving a message to read leaves the status alone and flips the mark, while moving it to spam or trash changes the status. When filtering on your side, use the computed field rather than the raw status. > **Moving to spam can block the sender** > > Two options on the move calls reach **far past the message**: one adds the sender's e-mail and phone to the banned list, the other blocks their address. Both apply across the installation and keep that person from using the form again. Leave them off when doing a bulk clean-up. > **The reply really goes out** > > The reply endpoint saves no draft; it sends the visitor a **real e-mail**, and a sent message cannot be pulled back. The convert endpoint likewise opens a real ticket. When trying these two from a script, use a test record that reaches your own address. > **Emptying a folder is permanent and gives no count** > > The empty call works on the spam and trash folders alone, yet there it **removes everything for good** and does not say how many went. The response names only the folder emptied. When you need a record, list the folder before emptying it. ### Related Articles - [Customer Testimonials](https://dev.wisecp.com/en/customer-testimonials) - [Managing Tickets](https://dev.wisecp.com/en/managing-tickets) - [Website Pages](https://dev.wisecp.com/en/website-pages) # API / Client API ## Client API Overview https://dev.wisecp.com/en/client-api-overview The surface a customer uses on their own account, and the ways it deliberately differs from the admin one. ### Overview The client API lets a customer drive their own account from their own code: read services, pay an invoice, order a domain, open a ticket. The key belongs to the customer, not to you, and the customer issues it from their account. Everything it reaches is bounded to that one account. The scope is not a filter you remember to apply; it comes from the key and cannot be widened by anything in the request. It is a separate product from the admin surface, not a restricted view of it. Different address, different kind of key, and a shorter catalogue built from what a customer can actually do in their panel. ### Prerequisites - A customer account on the installation, and a client key issued from `/api-credentials` in that account. - The base address `/api/v1/client`. An admin key sent here is refused, and so is a client key sent to the admin surface. ### Structure Four things behave differently here, and knowing them up front saves reading two catalogues side by side. - **Every query is account-bound**: A record belonging to someone else answers `404`, exactly like a record that does not exist. The two are never distinguished, so a key cannot probe for its neighbours. - **Universal values travel as codes**: Countries are ISO two-letter codes and currencies are ISO codes, in both directions. Internal numeric ids are not part of this surface. - **Money is an object, dates are plain**: Amounts arrive as `{amount, currency}` rather than a formatted string, and dates as `YYYY-MM-DD` or null. Formatting is yours to do. - **Anything a form would pick has a lookup**: Where the panel offers a dropdown, the API offers a reference endpoint: countries, then that country's states, then that state's cities. #### What Stays in the Panel Some account work has no endpoint at all, and that is a boundary rather than a gap. Card storage needs the payment provider's own hosted flow. Changing a password or an email address needs a one-time code. Two-factor setup and identity documents need a person. Data-deletion requests are a legal decision. Their *state* is still readable, so an integration can tell whether an account is verified without being able to verify it. ### Example ```bash curl -H 'Authorization: Bearer wck_...' \ 'https://panel.example.com/api/v1/client/whoami' # {"data":{"id":3,"type":"client","owner_id":42,"permissions":["Services/*"]}} curl -H 'Authorization: Bearer wck_...' \ 'https://panel.example.com/api/v1/client/services?limit=10' ``` ### Pitfalls > **Do not build an operator tool on client keys** > > Serving many customers from one integration means one key per customer, each issued by that customer. If you need to act across accounts, that is the admin surface. Collecting customers' keys to fake it is a liability you do not want to hold. > **Payment is balance or a stored card, nothing else** > > Ordering and paying accept the account balance or a card the customer already saved. Gateway flows that need a redirect, an iframe or a 3-D Secure step belong to the panel. A declined card still answers `200` with a failed payment on it, so read the payment status, not only the HTTP code. ### Related Articles - [The WISECP API](https://dev.wisecp.com/en/the-wisecp-api) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [Request and Response Format](https://dev.wisecp.com/en/request-and-response-format) - [Client API First Calls](https://dev.wisecp.com/en/client-api-first-calls) ## Client API First Calls https://dev.wisecp.com/en/client-api-first-calls The five endpoints called while connecting to the client surface. ### Overview The client API is for a customer managing their own account from an integration. It is a surface apart from the admin API: **a different address and a different kind of key**, with every call bounded to one customer. This article answers the first three questions: is the surface up, what can my key do, and which values fill the address fields. The address chain runs one way: the country code first, then the state number, then the city number. Each step wants the one before it, and any of them can come back empty. ### Reference #### The Health Check get/api/v1/admin/client/ping `System/Ping` no key needed Returns that the client surface is up, along with the server time. Response fields data — 3 pongboolWhether the surface is up. versionstringThe API version. timestringThe server's time. It comes in the server's own time zone. Errors — ——This endpoint is open to everyone and returns no error. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/ping' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/ping'); const { data } = await res.json(); if (! data.pong) reportOutage(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/ping'); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This is SEPARATE from the admin surface's endpoint of the same name; one can be up while the other is not. $up = Kernel::internal('client:System/Ping')['data']['pong'] ?? false; ``` #### What the Key Is get/api/v1/admin/client/whoami `System/Whoami` no scope needed Returns the key's identity, its permissions and the client it belongs to. Response fields data — 6 idintThe key id. typestringThe key kind. On this surface it is always a client key. namestringThe name the key was given. permissionsstring[]The scopes the key carries. last_accessstringWhen it was last used. owner_idintThe client the key belongs to. Every call is bounded by this client. Errors 4 missing_token401The key was not sent or is not known. key_revoked401The key was revoked. audience_mismatch403An admin key was used on the client surface. ip_not_allowed403The request came from outside the addresses allowed. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/whoami' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/whoami', { headers: { Authorization: `Bearer ${clientKey}` }, }); if (res.status === 403) return showWrongSurface(); const { data } = await res.json(); console.log(data.owner_id, data.permissions); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/whoami'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // owner_id IS NOT PASSED on calls: the client comes from the key and writing it in the body changes nothing. $me = Kernel::internal('client:System/Whoami', ['owner_id' => $ownerId])['data']; ``` #### The Countries get/api/v1/admin/client/reference/countries `Reference/GetCountries` no scope needed Returns the country codes the profile and address endpoints take. Response fields data[] — 2 codestringThe two-letter country code. The profile and address endpoints want this rather than a number. namestringThe country name. It comes in the site's language. Errors 3 missing_token401The key was not sent or is not known. key_revoked401The key was revoked. audience_mismatch403An admin key was used on the client surface. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/reference/countries' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/reference/countries', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); renderCountryPicker(data); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/reference/countries'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The client surface speaks the COUNTRY CODE; the country NUMBER from the admin surface is not taken here. $rows = Kernel::internal('client:Reference/GetCountries', ['owner_id' => $uid])['data']; $codes = array_column($rows, 'code'); ``` #### A Country's States get/api/v1/admin/client/reference/countries/{code}/states `Reference/GetStates` no scope needed Returns a country's states with the numbers the address fields use. Response fields data[] — 2 idintThe state number. It is the state field on the address endpoints and the input to the city lookup. namestringThe state name. Errors 4 not_found404No such country code. missing_token401The key was not sent or is not known. key_revoked401The key was revoked. audience_mismatch403An admin key was used on the client surface. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/reference/countries/TR/states' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/reference/countries/${code}/states`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (! data.length) useFreeTextState(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/reference/countries/' . $code . '/states'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // AN EMPTY list is normal: with no states on that country the address field takes free text. $states = Kernel::internal('client:Reference/GetStates', ['owner_id' => $uid, 'code' => $code])['data']; $free = ! $states; ``` #### A State's Cities get/api/v1/admin/client/reference/states/{id}/cities `Reference/GetCities` no scope needed Returns a state's cities with the numbers the address fields use. Response fields data[] — 2 idintThe city number. It is the city field on the address endpoints. namestringThe city name. Errors 4 not_found404No such state. missing_token401The key was not sent or is not known. key_revoked401The key was revoked. audience_mismatch403An admin key was used on the client surface. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/reference/states/34/cities' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/reference/states/${stateId}/cities`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/reference/states/' . $stateId . '/cities'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A CITY IS REQUIRED on an address even when the list is empty: write free text rather than leaving it out. $cities = Kernel::internal('client:Reference/GetCities', ['owner_id' => $uid, 'id' => $stateId])['data']; ``` ### Pitfalls > **An admin key does not work on this surface** > > The client surface takes a client key alone, and calling with an admin key gives `audience_mismatch`. That code means the **wrong surface** rather than a missing permission, so fixing the address instead of the key wastes time. > **The customer comes from the key and never from the request** > > Every client endpoint is bounded by the key's owner. Writing a customer number into the body **changes nothing**, and asking for someone else's record answers not found. Use the admin surface when you need to reach more than one customer. > **The country speaks a code while the state and city speak numbers** > > On the client surface a country is given as a **two-letter code** and the country number from the admin surface is not taken. The state and city want numbers. Filling all three the same way leads to a quiet validation error. > **An empty list is not an error but a sign to use free text** > > The state or city list can come back empty when the platform holds no data for that country or state. The address field then takes **free text**. A city stays required even with an empty list, and an address does not save without one. > **The health check does not verify the key** > > The health endpoint wants no credentials, so a **successful answer** says nothing about your key working. Call both while wiring an integration up: the health endpoint proves the server and the key endpoint proves the credentials. ### Related Articles - [Account Details](https://dev.wisecp.com/en/the-account-of-the-key) - [The Address Book](https://dev.wisecp.com/en/the-address-book) ## The Account Behind the Key https://dev.wisecp.com/en/the-account-of-the-key The five endpoints giving the profile, choices and security state of the key's owner. ### Overview On the client API the account is always **resolved from the key**. There is no customer parameter: these five endpoints read and write the record of whoever owns the key. What may be changed on a profile is **the operator's decision**. The read endpoint returns that decision alongside the data: which fields are open and which cannot be emptied. Account security is deliberately left out. The password, the e-mail, two-step sign-in and ending a session **stay in the panel**. The API shows their state alone. ### Reference #### Reading the Profile get/api/v1/admin/client/me `Account/GetMe` the key's owner Returns the profile of the customer the key belongs to. Response fields data — 25 + meta — 2 idintThe customer id. kindstringThe account kind: a person or a company. full_namestringThe first and last name together. namestringThe first name. surnamestringThe last name. companyobject companyThe company details. namestringThe company name. tax_numberstringThe tax number. tax_officestringThe tax office. avatar_urlstringThe address of the profile picture. It comes empty when there is none. emailstringThe e-mail address. email_verifiedboolWhether the e-mail was verified. phonestringThe mobile number. It comes in international form. phone_country_codestringThe phone's country code. phone_verifiedboolWhether the number was verified. landline_phonestringThe landline number. identitystringThe identity number. birthdaystringThe date of birth. languagestringThe language chosen. countrystringThe country code. currencystringThe display currency. It can differ from the wallet's. group_idintThe customer group id. balanceobjectThe wallet balance. It carries an amount and a currency, and that currency is the **wallet's own**. timezonestringThe time zone chosen. Empty means the installation's is used. date_formatstringThe date format chosen. custom_fieldsarrayThe extra fields the operator defined. Each carries an id, name, type, whether it is required, whether it is editable, its choices and its value. created_atstringWhen the account was opened. last_login_atstringWhen they last signed in. editablearrayThe field names that can be changed now. It comes back under meta. requiredarrayThe fields that cannot be emptied once sent. Errors 4 missing_token401The key was not sent or is not known. insufficient_scope403The key lacks the required scope. audience_mismatch403An admin key was used on the client surface. not_found404The account behind the key is gone. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/me' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/me', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data, meta } = await res.json(); renderForm(data, meta.editable, meta.required); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/me'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Build the form from meta.editable: a field the operator closed gives 422 rather than passing quietly. $r = Kernel::internal('client:Account/GetMe', ['owner_id' => $uid]); $form = array_intersect_key($r['data'], array_flip($r['meta']['editable'])); ``` #### Updating the Profile patch/api/v1/admin/client/me `Account/UpdateMe` an operator gate Changes the profile fields you send. Body 14 namestringThe first name. It cannot be emptied. surnamestringThe last name. It cannot be emptied. kindstringThe account kind. Moving to a person clears the company details. companyobjectThe company details. It wants the account to be a company. phonestringThe mobile number. Changing it drops the verified mark. birthdaystringThe date of birth. identitystringThe identity number. landline_phonestringThe landline number. languagestringThe language code. It has to exist on the installation. countrystringThe country code. currencystringThe display currency code. timezonestringThe time zone. Sending it empty clears the account's own choice. date_formatstringThe date format. Sending it empty clears the account's own choice. custom_fieldsobjectThe values of the extra fields. A tick field takes a list. avatarstringThe profile picture. Encoded content alone is taken and an address is refused. Response fields data — 25 + meta — 1 dataobjectThe profile as it now stands. Same shape as the read endpoint. changedarrayThe fields truly written. It comes back under meta. Errors 12 field_not_editable422The field is closed by an operator setting. nothing_to_update422The body holds no field that is known. name_required422The first or last name cannot be emptied. kind_invalid422The account kind is neither of the two values. company_requires_corporate422Company details were sent to a personal account. company_name_required422A required part of the company is empty. phone_required422The phone was emptied while required. phone_taken422The number is used on another account. birthday_required422A required field was emptied. language_invalid422An unknown preference value. The country, currency, time zone and date format are refused the same way. custom_field_required422An unknown or required extra field. avatar_url_not_allowed422An address was sent as the picture. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PATCH 'https://panel.example.com/api/v1/client/me' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"timezone":"Europe/Istanbul","date_format":"d/m/Y"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/me', { method: 'PATCH', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ timezone: 'Europe/Istanbul' }), }); const { meta } = await res.json(); console.log(meta.changed); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/me'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['timezone' => 'Europe/Istanbul']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The E-MAIL and PASSWORD do not change here: both want a verification flow and stay in the panel. $r = Kernel::internal('client:Account/UpdateMe', ['owner_id' => $uid, 'timezone' => $tz]); $written = $r['meta']['changed']; ``` #### Reading the Notification Choices get/api/v1/admin/client/me/notifications `Account/GetNotifications` client Returns which notifications the account takes and by which channel. Response fields data — 1 categoriesarray categories[]One row per category. categorystringThe category name: general, invoices, support, product, domain or marketing. emailboolWhether the e-mail channel is on. smsboolWhether the message channel is on. lockedboolWhether the category can be closed. The general one is always on. Errors 3 missing_token401The key was not sent or is not known. insufficient_scope403The key lacks the required scope. audience_mismatch403An admin key was used on the client surface. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/me/notifications' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/me/notifications', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const editable = data.categories.filter((c) => ! c.locked); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/me/notifications'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Read BEFORE writing: the write is a FULL REPLACE and a category left out closes on both channels. $cur = Kernel::internal('client:Account/GetNotifications', ['owner_id' => $uid])['data']; ``` #### Writing the Notification Choices put/api/v1/admin/client/me/notifications `Account/UpdateNotifications` a full replace Rewrites the notification choices in full. Body 5 invoicesobjectThe invoice notifications. It carries the e-mail and message channels, and a channel left out counts as off. supportobjectThe support notifications. productobjectThe product and service notifications. domainobjectThe domain notifications. marketingobjectThe marketing notifications. Response fields data — 1 dataobjectThe choices as they now stand. Same shape as the read endpoint. Errors 3 missing_token401The key was not sent or is not known. insufficient_scope403The key lacks the required scope. audience_mismatch403An admin key was used on the client surface. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/me/notifications' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"invoices":{"email":true,"sms":false},"support":{"email":true,"sms":true}}' ``` ```javascript const cur = await fetch('https://panel.example.com/api/v1/client/me/notifications', { headers: { Authorization: `Bearer ${clientKey}` }, }).then((r) => r.json()); const body = {}; for (const c of cur.data.categories) if (! c.locked) body[c.category] = { email: c.email, sms: c.sms }; body.marketing = { email: false, sms: false }; await fetch('https://panel.example.com/api/v1/client/me/notifications', { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/me/notifications'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($prefs), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A category you leave out CLOSES: send the full set even to change one choice. $cur = Kernel::internal('client:Account/GetNotifications', ['owner_id' => $uid])['data']; $body = []; foreach ($cur['categories'] as $c) if (! $c['locked']) $body[$c['category']] = ['email' => $c['email'], 'sms' => $c['sms']]; $body['marketing'] = ['email' => false, 'sms' => false]; Kernel::internal('client:Account/UpdateNotifications', ['owner_id' => $uid] + $body); ``` #### Reading the Security Summary get/api/v1/admin/client/me/security `Account/GetSecurity` read only Returns the verification state, the two-step sign-in and the recent sessions. Response fields data — 5 email_verifiedboolWhether the e-mail was verified. phone_verifiedboolWhether the number was verified. two_factorobject two_factorWhere two-step sign-in stands. enabledboolWhether it is on. methodstringWhich method. An app, e-mail or a message. sessionsarray sessions[]The sign-ins of the last thirty days. 25 records at most, newest first. ipstringThe address it came from. citystringThe city caught at sign-in. country_codestringThe country code. user_agentstringThe browser string. It comes raw and parsing is left to you. created_atstringWhen they signed in. last_login_atstringWhen they last signed in. Errors 3 missing_token401The key was not sent or is not known. insufficient_scope403The key lacks the required scope. audience_mismatch403An admin key was used on the client surface. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/me/security' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/me/security', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (! data.two_factor.enabled) nudgeToEnable(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/me/security'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // It is READ ONLY: signing a session out, the password and setting two-step up are ABSENT here and stay in the panel. $sec = Kernel::internal('client:Account/GetSecurity', ['owner_id' => $uid])['data']; $stale = array_filter($sec['sessions'], fn ($s) => $s['country_code'] !== $home); ``` ### Pitfalls > **Writing the notifications is a full replace** > > The notification write reads the body as the **whole set** of choices. A category you leave out closes on both the e-mail and the message channel. Read the current list first and write over it to change one. The general category is locked and stays as it is even when sent. > **A closed field is not ignored quietly** > > Sending a field the operator closed for editing is refused with `422`. Building a form from a fixed field list makes the **whole save fail** the day one of them is closed. Always build the list from the editable set the read endpoint returns. > **The wallet currency is not the display choice** > > The currency field on the profile says **how amounts are shown**, while the wallet's own currency comes separately inside the balance object. The two can differ, and reading the balance in the display currency gives a wrong figure. Read the balance in its own currency. > **The profile picture goes as content alone** > > The picture field **takes no address**. The server never fetches one you give, because the outgoing request would give away its real address. Send the image as encoded content, and send it empty to remove the picture. > **Changing the phone drops its verification** > > The verified mark is **cleared** when the number changes. The flows resting on the phone stop until the new one is verified. The same holds for the e-mail, which cannot be changed here at all. Send the customer to verification after a number change. > **The security endpoint is not a management tool** > > The security summary lists the sessions and **cannot end them**, and no session token is ever returned. The password, two-step sign-in and identity documents stay in the panel for the same reason: each wants an unbroken human step. ### Related Articles - [The Address Book](https://dev.wisecp.com/en/the-address-book) - [Client API First Calls](https://dev.wisecp.com/en/client-api-first-calls) - [The Account Balance](https://dev.wisecp.com/en/the-account-balance) ## The Address Book https://dev.wisecp.com/en/the-address-book The six endpoints managing the account's billing profiles. ### Overview The address book holds the account's **billing profiles**. Each record carries a contact, an address and the tax rate that address falls under, and the invoices are raised from it. One profile is always the **default** and the account never sits without one: the first added becomes it, and removing the default promotes the next in line. The location fields are filled from the reference chain: a country code, a state number and a city number. Where the platform holds no data the same fields take free text. ### Reference #### Listing the Addresses get/api/v1/client/addresses `Addresses/GetAddresses` the key's owner Returns the account's billing profiles. Response fields data[] — 18 idintThe address id. labelstringA free label. full_namestringThe contact's full name. namestringThe first name. surnamestringThe last name. kindstringThe contact kind: a person or a company. emailstringThe contact's e-mail. phonestringThe contact's phone. identitystringThe identity number. companyobjectThe company details. It carries the name, tax number and tax office. countrystringThe country code. stateobjectThe state. It carries a number and a name, and the number is zero when it was typed free. cityobjectThe city. It carries a number and a name, and the number is zero when it was typed free. addressstringThe street address. zipcodestringThe postcode. tax_ratefloatThe tax rate this address falls under. The server works it out from the address. is_defaultboolWhether it is the default billing profile. notificationsarrayThe notification channels per contact. An e-mail and a message flag for each of the six categories. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/addresses' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/addresses', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const billing = data.find((a) => a.is_default); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/addresses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The default contact ALWAYS comes first; take the first row rather than searching for it. $rows = Kernel::internal('client:Addresses/GetAddresses', ['owner_id' => $uid])['data']; $default = $rows[0] ?? null; ``` #### Reading One Address get/api/v1/client/addresses/{id} `Addresses/GetAddress` the key's owner Returns one of the account's addresses. Response fields data — 18 dataobjectThe address record. Same shape as an item in the listing. Errors 2 not_found404No such address, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/addresses/12' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/addresses/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); if (res.status === 404) return notYours(); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/addresses/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Someone else's address also answers NOT FOUND: this endpoint never says whether it exists. $a = Kernel::internal('client:Addresses/GetAddress', ['owner_id' => $uid, 'id' => $id]); ``` #### Adding an Address post/api/v1/client/addresses `Addresses/CreateAddress` a billing profile Adds a new billing profile to the account. Body 16 namestringreqThe contact's first name. surnamestringreqThe last name. emailstringreqA valid e-mail. countrystringreqThe country code. Taken from the reference endpoint. statestringreqThe state. A number from the list, or free text when the list is empty. citystringreqThe city. A number from the list, or free text when the list is empty. addressstringreqThe street address. zipcodestringreqThe postcode. Twenty characters at most. kindstringThe contact kind. A person by default. companyobjectThe company details. The name is required on a company contact. labelstringA free label. phonestringThe contact's phone. identitystringThe identity number. notificationsobjectThe channel matrix. A full replace, and leaving it out opens every one. is_defaultboolMake this the default profile. overwrite_invoicesboolRewrite the open invoices to this address. Response fields data — 18 dataobjectThe address made. Same shape as the read endpoint. Errors 6 name_required422A required field is missing or invalid. The last name, e-mail, country, state, city, address and postcode are refused the same way. company_name_required422The company name is empty on a company contact. notifications_invalid422The notification matrix is broken or holds an unknown category. contact_rejected422A hook refused the save. address_add_failed500The address could not be added. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/addresses' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Jane","surname":"Cooper","email":"jane@example.com","country":"TR","state":"34","city":"1441","address":"Sample St 42","zipcode":"34710"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/addresses', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Jane', surname: 'Cooper', email: 'jane@example.com', country: 'TR', state: String(stateId), city: String(cityId), address: 'Sample St 42', zipcode: '34710', }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/addresses'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($contact), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The FIRST address becomes the DEFAULT by itself; the account never sits without a profile. $a = Kernel::internal('client:Addresses/CreateAddress', ['owner_id' => $uid] + $contact)['data']; $isFirst = $a['is_default']; ``` #### Updating an Address put/api/v1/client/addresses/{id} `Addresses/UpdateAddress` the whole body Rewrites the address in full. Body 16 namestringreqThe contact's first name. surnamestringreqThe last name. emailstringreqA valid e-mail. countrystringreqThe country code. Taken from the reference endpoint. statestringreqThe state. A number from the list, or free text when the list is empty. citystringreqThe city. A number from the list, or free text when the list is empty. addressstringreqThe street address. zipcodestringreqThe postcode. Twenty characters at most. kindstringThe contact kind. A person by default. companyobjectThe company details. The name is required on a company contact. labelstringA free label. phonestringThe contact's phone. identitystringThe identity number. notificationsobjectThe channel matrix. A full replace, and leaving it out opens every one. is_defaultboolMake this the default profile. overwrite_invoicesboolRewrite the open invoices to this address. Response fields data — 18 dataobjectThe address as it now stands. Same shape as the read endpoint. Errors 5 not_found404No such address, or it belongs to another customer. name_required422A required field is missing or invalid. company_name_required422The company name is empty on a company contact. contact_rejected422A hook refused the save. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/addresses/12' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Jane","surname":"Cooper","email":"jane@example.com","country":"TR","state":"34","city":"1441","address":"New St 7","zipcode":"34710"}' ``` ```javascript const cur = await fetch(`https://panel.example.com/api/v1/client/addresses/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }).then((r) => r.json()); const body = { ...cur.data, state: String(cur.data.state.id || cur.data.state.name), city: String(cur.data.city.id || cur.data.city.name), address: 'New St 7', }; await fetch(`https://panel.example.com/api/v1/client/addresses/${id}`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/addresses/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($contact), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The state and city are read as OBJECTS and sent as PLAIN VALUES: pull the number out before writing back. $a = Kernel::internal('client:Addresses/GetAddress', ['owner_id' => $uid, 'id' => $id])['data']; $a['state'] = (string) ($a['state']['id'] ?: $a['state']['name']); $a['city'] = (string) ($a['city']['id'] ?: $a['city']['name']); Kernel::internal('client:Addresses/UpdateAddress', ['owner_id' => $uid, 'id' => $id] + $a); ``` #### Moving the Default post/api/v1/client/addresses/{id}/default `Addresses/SetDefaultAddress` the account country follows Makes this address the default billing profile. Body — ——No body is needed, send an empty one. The address comes from the id in the path. Response fields data — 18 dataobjectThe address that is now the default. Errors 2 not_found404No such address, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/addresses/12/default' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/addresses/${id}/default`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/addresses/' . $id . '/default'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is no way to UNSET a default: making another address the default is the only road. Kernel::internal('client:Addresses/SetDefaultAddress', ['owner_id' => $uid, 'id' => $other]); ``` #### Removing an Address delete/api/v1/client/addresses/{id} `Addresses/DeleteAddress` the key's owner Removes an address and hands the default on where needed. Response fields data — 2 deletedboolWhether the delete ran. idintThe id of the address removed. Errors 2 not_found404No such address, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/addresses/12' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/addresses/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/addresses/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Removing the default is NOT refused: the next address is promoted to default by itself. Kernel::internal('client:Addresses/DeleteAddress', ['owner_id' => $uid, 'id' => $id]); $now = Kernel::internal('client:Addresses/GetAddresses', ['owner_id' => $uid])['data'][0] ?? null; ``` ### Pitfalls > **The state and city read as objects and write as plain values** > > The read endpoints return the state and city as an **object carrying a number and a name**, while the write endpoints expect a plain value. Sending a record straight back fails validation on those two fields. Pull the number out before writing, and send the name where the number is zero. > **The update wants the whole record** > > The update body is the **whole address**: the fields required on create are required here as well. Send the full record even to change the postcode alone. The one ease is the notification matrix, which keeps its saved value when left out. > **Moving the default changes the account country** > > Making an address the default pulls the account's country to **that address's country**. The country decides the tax rate and what some products show, so a simple-looking change can move prices. Think it through before defaulting a profile in another country. > **Rewriting the open invoices is a choice** > > While adding an address you can ask for the open invoices to move to it. Without that the invoices raised on the **old address** stay as they are and the customer sees two different addresses. Consider it on a change such as a new tax number. > **Someone else's address answers not found** > > Asking for another customer's address id answers `404` rather than a permission error. That is deliberate: the answer never reveals **whether that id exists**. Do not read a not-found as "it was removed". > **Every contact carries its own notification matrix** > > The notification choices are kept at the account level and **per contact**: invoices can reach one person and support another. With the matrix left out a create opens every channel while an update keeps what was saved. ### Related Articles - [The Account Behind the Key](https://dev.wisecp.com/en/the-account-of-the-key) - [Client API First Calls](https://dev.wisecp.com/en/client-api-first-calls) - [Paying an Invoice](https://dev.wisecp.com/en/paying-an-invoice) ## Balance https://dev.wisecp.com/en/the-account-balance The three endpoints giving the prepaid balance, its movements and the collection settings. ### Overview The wallet is the account's prepaid balance. It can pay an invoice, and renewals can be taken from it **by themselves** when asked. Three endpoints answer three questions: how much is there, where the money went and how collection should work. The wallet has a currency of its own and it is **separate** from the display choice on the profile. Always read an amount together with the currency beside it. ### Reference #### Reading the Wallet get/api/v1/client/balance `Balance/GetBalance` always fresh Returns the wallet, the automatic payment setting and the warning setup in one call. Response fields data — 4 balanceobjectWhat the wallet holds. It is fresh on every read and never from a cache. low_balanceboolWhether the balance sits under the warning threshold. auto_payobject auto_payWhere automatic payment stands. from_balanceboolWhether renewals are taken from the wallet. availableboolWhether the installation offers it. It rests on the operator's balance module. backup_cardobjectThe card charged when the wallet falls short. It carries an id, a brand and the last four digits. alertobject alertThe low balance warning. enabledboolWhether the warning is on. thresholdobjectThe warning threshold. It comes in the wallet's currency. recipientsobject[]Who gets the warning. The row numbered zero is the account owner and cannot be closed; the rest are billing addresses. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/balance' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/balance', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.low_balance) promptTopUp(data.balance); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/balance'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The wallet currency can differ from the DISPLAY one on the profile; read the amount in its own. $w = Kernel::internal('client:Balance/GetBalance', ['owner_id' => $uid])['data']; $amount = $w['balance']['amount']; $cur = $w['balance']['currency']; ``` #### Reading the Wallet Movements get/api/v1/client/balance/transactions `Balance/GetBalanceTransactions` the key's owner Returns what came into the wallet and what left it, newest first. Query 4 pageintWhich page. limitintRows per page. 100 at the most. typestringThe direction filter: money in or money out. searchstringSearches the movement note. Response fields data[] — 6 + meta — 4 transaction_idintThe movement id. typestringIts direction: in or out. amountobjectThe amount and its currency. descriptionstringThe movement note. It usually carries an invoice number. invoice_idintThe invoice closed. It fills when the movement closed one. created_atstringWhen the movement happened. totalintHow many movements there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Zero on the last one. Errors 2 type_invalid422The direction filter is neither of the two values. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/balance/transactions?type=down&limit=50' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const url = new URL('https://panel.example.com/api/v1/client/balance/transactions'); url.searchParams.set('type', 'down'); const res = await fetch(url, { headers: { Authorization: `Bearer ${clientKey}` } }); const { data, meta } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/balance/transactions?type=down'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A movement is a HISTORICAL record: a refund adds a row the other way rather than removing the old one. $rows = Kernel::internal('client:Balance/GetBalanceTransactions', ['owner_id' => $uid, 'type' => 'down'])['data']; $paid = array_column($rows, 'invoice_id'); ``` #### Changing the Wallet Settings patch/api/v1/client/balance/settings `Balance/UpdateBalanceSettings` it reaches the invoices Sets automatic payment and the low balance warning. Body 4 auto_pay_from_balanceboolWhether renewals are taken from the wallet. Changing it makes the open renewal invoices follow the new choice at once. backup_card_idintThe card charged when the wallet falls short. Sending zero clears the card chain entirely. alertobjectThe warning block. It carries an on flag and a threshold, and turning it on wants a positive threshold. alert_recipientsint[]The billing address ids that get the warning. The list replaces, and the account owner never appears in it and always gets it. Response fields data — 4 dataobjectThe wallet as it now stands. Same shape as the read endpoint. Errors 6 not_found404The backup card is none of this account's saved cards. nothing_to_update422The body holds no field that can be updated. auto_pay_invalid422The automatic payment field is of the wrong kind. card_expired422The backup card has expired. alert_invalid422The warning block is broken or the threshold is missing. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PATCH 'https://panel.example.com/api/v1/client/balance/settings' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"auto_pay_from_balance":true,"alert":{"enabled":true,"threshold":50}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/balance/settings', { method: 'PATCH', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ alert: { enabled: true, threshold: 50 }, alert_recipients: [84], }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/balance/settings'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['auto_pay_from_balance' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The recipient list REPLACES: read the ones already on and merge before adding an address. $cur = Kernel::internal('client:Balance/GetBalance', ['owner_id' => $uid])['data']; $on = array_column(array_filter($cur['alert']['recipients'], fn ($r) => $r['enabled'] && ! $r['owner']), 'id'); $on[] = $newAddressId; Kernel::internal('client:Balance/UpdateBalanceSettings', ['owner_id' => $uid, 'alert_recipients' => $on]); ``` ### Pitfalls > **The wallet currency is not the display choice** > > The wallet lives in **its own currency** while the one on the profile says how amounts are shown. The two can differ, and reading the balance in the display currency gives a wrong figure. The warning threshold is in the wallet's currency as well. > **The recipient list replaces** > > Writing the warning recipients **overwrites** the list: an address you leave out stops getting the warning. Sending only the address you meant to add closes the others. The account owner never enters this list and always gets the warning. > **Changing automatic payment reaches the open invoices** > > Turning the wallet collection of renewals on or off also takes **the renewal invoices open at that moment** down the new road. This is not a setting for the future: the collections waiting are hit at once. Make sure the balance covers them. > **The backup card steps in when the wallet falls short** > > The backup card works after the wallet rather than **in place of** it: the balance is tried first and the card is charged when it falls short. Sending zero clears the chain, and a failed collection can suspend a service. Saving is refused on a card that has expired. > **The movements are a historical record** > > A wallet movement is never removed or corrected: a refund adds **a new row the other way** rather than taking the old one out. Read the balance from the wallet state rather than adding the movements up, since that value is fresh on every call. ### Related Articles - [Paying an Invoice](https://dev.wisecp.com/en/paying-an-invoice) - [The Account Behind the Key](https://dev.wisecp.com/en/the-account-of-the-key) - [The Address Book](https://dev.wisecp.com/en/the-address-book) ## Orders https://dev.wisecp.com/en/ordering-as-the-client The four endpoints that show past orders, quote a price and place the order. ### Overview A customer can order on their own behalf, and that order completes **without a browser**. Payment is kept narrow for that reason: the account balance or a card the account has saved. The flow runs in three steps: read the schema from the product detail, see the price with the preview, then place the order. The preview runs the **same chain** as the order endpoint and writes nothing. Taking a domain does not belong here. A new domain is taken from the domain endpoints first and then named in the order as one you **already own**. ### Reference #### Listing the Orders get/api/v1/client/orders `Orders/GetOrders` the key's owner Returns the account's orders, newest first. Query 3 pageintWhich page. limitintRows per page. 100 at the most. statusstringThe state filter: waiting, in process, active or cancelled. Response fields data[] — 8 + meta — 4 idintThe order id. numberstringThe order number the customer sees. statusstringWhere the order stands. totalobjectThe order total. It is in the wallet currency. payment_methodstringThe payment method recorded. invoice_idintThe order invoice. It comes empty when there is none. items_countintHow many lines the order holds. created_atstringThe day it was placed. total_countintHow many orders there are. It comes back under meta as the total. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 2 status_invalid422The state is not one the orders use. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/orders?status=active' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/orders?status=active', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data, meta } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/orders?status=active'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The listing carries NO LINES: the items and the services born come on the detail endpoint. $rows = Kernel::internal('client:Orders/GetOrders', ['owner_id' => $uid])['data']; ``` #### Reading an Order in Full get/api/v1/client/orders/{id} `Orders/GetOrder` the key's owner Returns an order's lines, the services born and its invoice. Response fields data — 12 idintThe order id. numberstringThe order number. statusstringWhere the order stands. totalobjectThe order total. payment_methodstringThe payment method recorded. invoice_idintThe order invoice id. items_countintHow many lines. created_atstringThe day it was placed. itemsobject[]The order lines. Each carries a label, a quantity and a line total. servicesobject[]The services born from the order. Each carries an id, a name, a type and a state, and the id goes to the service endpoint. invoiceobjectThe order invoice summary. It carries the id, number, state, total and the dates. notesstringThe note written while ordering. Errors 2 not_found404No such order, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/orders/918' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/orders/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const ids = data.services.map((s) => s.id); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/orders/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The services can already exist while the order still reads WAITING; follow the service state. $o = Kernel::internal('client:Orders/GetOrder', ['owner_id' => $uid, 'id' => $id])['data']; $live = array_filter($o['services'], fn ($s) => $s['status'] === 'active'); ``` #### Pricing an Order post/api/v1/client/orders/preview `Orders/PreviewOrder` nothing is written Prices an order body without writing a single row. Body 4 itemsobject[]req items[]The order lines. One to twenty, in the same shape as the order endpoint. product_idintreqThe id of the product ordered. cyclestringreqThe billing cycle. It has to be one the product prices. quantityintHow many. It counts only where the product allows more than one. addonsobjectThe add-ons picked. It maps an add-on id to a choice id. addons_qtyobjectThe count of add-ons priced by quantity. requirementsobjectThe answers to the product's questions. Every field marked required has to be sent. requirement_filesobjectThe content for fields wanting a file. It goes as encoded content and an address is refused. metricsint[]The ids of the meters to switch on. domainobjectThe line's domain axis. It carries the choice, the name, the subdomain root and the licence fields. couponsstring[]The coupon codes to try. billing_profile_idintThe billing profile id. It sets the tax context, and the account default stands in when left out. paymentobjectThe payment block. Sending it adds the method's fee to the total, and the balance is not checked here. Response fields data — 6 itemsobject[]The lines priced. Each carries a kind, product, name, cycle, quantity, domain, unit price, set-up fee and line total. subtotalobjectThe total before discount and tax. discountobjectThe discount in all. taxobjectThe tax on the discounted base. feeobjectThe payment method fee. It is worked out only when the payment block is sent. totalobjectThe grand total. It is what the order endpoint will charge. Errors 8 items_required422No line was sent, or there are more than twenty. product_not_orderable422The product is absent, closed, hidden or its group is shut to orders. cycle_invalid422The cycle is not one the product prices. requirement_missing422A required order field was not sent. domain_acquisition_not_here422Registering or transferring a domain does not happen here. coupon_invalid422The coupon is invalid or does not apply to these lines. not_found404The billing profile or card belongs to another account. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/orders/preview' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"items":[{"product_id":55,"cycle":"monthly","quantity":1}],"coupons":["WELCOME10"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/orders/preview', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ items, coupons }), }); const { data } = await res.json(); showSummary(data.subtotal, data.discount, data.tax, data.total); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/orders/preview'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['items' => $items]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The preview DOES NOT run the ordering gates: the terms, verification and balance go unchecked here. $q = Kernel::internal('client:Orders/PreviewOrder', ['owner_id' => $uid, 'items' => $items])['data']; $due = $q['total']['amount']; ``` #### Placing the Order post/api/v1/client/orders `Orders/CreateOrder` the balance or a saved card Places the order and collects payment in the same call. Body 7 termsboolreqAccepting the terms. It has to be sent true. itemsobject[]req items[]The order lines. One to twenty. product_idintreqThe id of the product ordered. cyclestringreqThe billing cycle. It has to be one the product prices. quantityintHow many. It counts only where the product allows more than one. addonsobjectThe add-ons picked. It maps an add-on id to a choice id. addons_qtyobjectThe count of add-ons priced by quantity. requirementsobjectThe answers to the product's questions. Every field marked required has to be sent. requirement_filesobjectThe content for fields wanting a file. It goes as encoded content and an address is refused. metricsint[]The ids of the meters to switch on. domainobjectThe line's domain axis. It carries the choice, the name, the subdomain root and the licence fields. couponsstring[]The coupon codes. The operator's self-applying coupons join in as well. billing_profile_idintThe billing profile id. notesstringThe order note. Anything past a thousand characters is cut. paymentobject paymentThe payment source. It is required once the total is above zero. methodstringThe road: the account balance or a saved card. card_idintThe card to charge. The account's default card is charged when left out. Response fields 201 — data — 6 order_idintThe new order id. numberstringThe order number. statusstringThe state right after collection. It reads waiting until provisioning moves it on. totalobjectThe order total settled. paymentobject paymentHow the collection went. methodstringThe road used: the balance, a card or free. statusstringWhether it was paid, failed or stands open. cardobjectThe id and last four digits of the card charged. transaction_idstringThe provider's transaction reference. errorstringWhy a failed payment failed. invoiceobjectThe order invoice summary. Errors 9 terms_required422The terms were not accepted. verification_required422The account is waiting on verification. checkout_blocked422A hook refused the order. insufficient_balance422The wallet does not cover the total. no_stored_card422No card was named and the account holds no default. card_not_chargeable422The card's provider cannot charge without a browser. payment_method_restricted422The method is closed to this customer or these products. not_found404The billing profile or card belongs to another account. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/orders' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"terms":true,"items":[{"product_id":55,"cycle":"monthly"}],"payment":{"method":"balance"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/orders', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ terms: true, items, payment: { method: 'balance' }, }), }); const { data } = await res.json(); if (data.payment.status === 'failed') showPayLater(data.invoice); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/orders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'terms' => true, 'items' => $items, 'payment' => ['method' => 'balance'], ]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A 201 DOES NOT MEAN PAID: a declined card still leaves the order and its unpaid invoice, so read the payment state. $o = Kernel::internal('client:Orders/CreateOrder', ['owner_id' => $uid, 'terms' => true, 'items' => $items, 'payment' => ['method' => 'card']])['data']; if ($o['payment']['status'] !== 'paid') $notify($o['invoice']['invoice_id']); ``` ### Pitfalls > **A successful answer does not mean paid** > > The order endpoint answers with success even on a failed collection, **because the order was built**: a declined card leaves the order and its unpaid invoice standing. Read whether it was paid from the payment state in the answer. Only the provider's plain approval counts as paid, and an answer wanting a further step fails here. > **The preview does not run the ordering gates** > > The preview runs the whole pricing chain and skips the **gates that belong to the moment of ordering**: accepting the terms, account verification, the policy hook and whether the balance covers it. A body passing the preview cleanly can still be refused by the order endpoint. > **Registering a domain is refused here** > > A line's domain axis takes a domain you **already own** or a free subdomain alone. Asking to register or transfer is refused with a plain error. The right order is to take it from the domain endpoints first and then name it in the order as owned. > **A file field takes no address** > > An order field wanting a file wants **encoded content**, and giving an address is refused. That is a deliberate security decision: the server never downloads an address a caller hands it. Send the file with the request. > **A waiting order does not mean the service is absent** > > An order reads **waiting** right after collection and stays there until provisioning moves it on. The services can already exist by then. Follow the progress from the **service states** in the detail rather than from the order's own. > **Every amount is in the wallet currency** > > The product prices, the preview total and the order total all come in one currency: the account's **wallet currency**. No conversion is needed to compare them with the balance. The display choice on the profile does not move these figures. ### Related Articles - [What Is on Sale](https://dev.wisecp.com/en/what-is-on-sale) - [Paying an Invoice](https://dev.wisecp.com/en/paying-an-invoice) - [Taking a Domain](https://dev.wisecp.com/en/taking-a-domain) ## Invoices https://dev.wisecp.com/en/paying-an-invoice The four endpoints that see, read, pay and discount invoices. ### Overview An invoice is the account's **record of what is owed**. An order, a renewal and a wallet top-up all raise one, and payment goes through it. Every invoice carries **two states**: the raw one on the record and the one shown to the customer. The second takes the due date into account, so an unpaid invoice takes a separate value once that date passes. Payment is kept narrow here as well: the account balance or a saved card. The roads wanting a browser step, a part payment and a transfer notice **stay in the panel**. ### Reference #### Listing the Invoices get/api/v1/client/invoices `Invoices/GetInvoices` urgency order Returns the account's invoices in order of urgency. Query 6 pageintWhich page. limitintRows per page. 100 at the most. statusstringThe state filter. It takes the customer-facing state vocabulary. searchstringSearches the invoice number and the first line's description. sortstringThe sort field: raised, due or amount. Left out, the urgency order stands. dirstringThe sort direction. It works with the sort field alone. Response fields data[] — 9 + meta — 4 invoice_idintThe invoice id. numberstringThe invoice number shown. statusstringThe raw state. statestringThe state shown to the customer. It comes from the raw state and the due date, and an open invoice past its date takes a value of its own. totalobjectThe document total. It is in its own currency. created_atstringThe day it was raised. paid_atstringThe day it was paid. due_datestringThe day it falls due. first_itemstringThe first line's description. It says quickly what the invoice is for. total_countintHow many invoices there are. It comes back under meta as the total. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 2 status_invalid422The state is not one that is known. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/invoices?status=overdue' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/invoices?status=overdue', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data, meta } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/invoices?status=overdue'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Two state fields exist: the filter takes the CUSTOMER one, and the raw state does not separate overdue. $rows = Kernel::internal('client:Invoices/GetInvoices', ['owner_id' => $uid, 'status' => 'overdue'])['data']; ``` #### Reading an Invoice get/api/v1/client/invoices/{id} `Invoices/GetInvoice` the moment is kept Returns an invoice's lines, customer record, summary and payments. Response fields data — 17 invoice_idintThe invoice id. numberstringThe invoice number shown. statusstringThe raw state. statestringThe state shown to the customer. It comes from the raw state and the due date, and an open invoice past its date takes a value of its own. totalobjectThe document total. It is in its own currency. created_atstringThe day it was raised. paid_atstringThe day it was paid. due_datestringThe day it falls due. amount_dueobjectWhat is still open. It reaches zero once closed. payableboolWhether the payment endpoint takes it. subscription_lockedboolWhether a subscription is collecting it. Paying by hand is refused when it is. notesstringThe operator notes on it. billed_toobjectThe customer record kept on the invoice. It carries the details as they stood when it was raised, even after the profile moves: the kind, name, contact, e-mail, tax number, identity number and address. custom_fieldsobject[]The extra fields the operator chose to show on the invoice. itemsobject[] items[]The invoice lines. item_idintThe line id. parent_item_idintThe parent line. It fills on an add-on line. descriptionstringThe line description. eventstringThe flow that wrote it. It comes empty on a line entered by hand. service_idintThe service billed. cyclestringThe billing cycle. period_startstringThe start of the period covered. It can be empty on an older document. period_endstringThe end of the period covered. domainstringThe domain the line concerns. quantityintHow many. unit_priceobjectThe unit amount. totalobjectThe line total. summaryobject summaryThe money summary. subtotalobjectThe line subtotal. discountsobjectThe discounts. It carries the dealer discount and any coupon applied. taxobjectThe tax line. It carries a rate and an amount. payment_feeobjectThe fee of the method picked. installmentobjectThe instalment plan. It carries the count and the surcharge. totalobjectThe document total. paidobjectWhat has been paid so far. amount_dueobjectWhat is still open. transactionsobject[]The money movements that closed. Each carries a kind, a method, the provider reference, an amount and a date, and failed attempts are not kept. bank_transferobjectA transfer notice waiting for approval. It carries the bank name, the sender and a reference. Errors 2 not_found404No such invoice, it belongs to another customer, or it is a draft. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/invoices/1193' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/invoices/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.payable && ! data.subscription_locked) enablePayButton(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/invoices/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The customer record on an invoice is a snapshot of THE MOMENT it was raised; do not reconcile it with the profile today. $inv = Kernel::internal('client:Invoices/GetInvoice', ['owner_id' => $uid, 'id' => $id])['data']; $thenAddress = $inv['billed_to']['address']; ``` #### Paying an Invoice post/api/v1/client/invoices/{id}/pay `Invoices/PayInvoice` the balance or a saved card Closes an open invoice with the balance or a saved card. Body 1 paymentobjectreq paymentThe payment source. methodstringreqThe road: the account balance or a saved card. card_idintThe card to charge. The account's default card is charged when left out. Response fields data — 2 invoiceobjectThe invoice read afresh. It carries the number, state, total, what is open and the dates. paymentobject paymentHow the charge went. methodstringThe road used. statusstringWhether it paid or failed. A failure happens on a declined card alone. cardobjectThe id and last four digits of the card charged. transaction_idstringThe provider's transaction reference. errorstringWhy it was declined. Errors 10 not_found404No such invoice, it belongs to another customer, or it is a draft. invoice_not_payable422The invoice is not open or nothing is left to pay. subscription_collects422A subscription is collecting this invoice. payment_method_invalid422The payment road is neither of the two values. balance_not_allowed422A top-up invoice cannot be paid from the wallet. insufficient_balance422The wallet does not cover the amount with the fee. What is needed and what is there come in the answer's detail. no_stored_card422No card was named and there is no default. card_expired422The saved card has expired. card_not_chargeable422The card's provider wants a browser step. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/invoices/1193/pay' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"payment":{"method":"balance"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/invoices/${id}/pay`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ payment: { method: 'card' } }), }); const { data } = await res.json(); if (data.payment.status === 'failed') showError(data.payment.error); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/invoices/' . $id . '/pay'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['payment' => ['method' => 'balance']]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A DECLINED CARD is not an error: the answer succeeds, the invoice stays open with its fee, so read the state. $r = Kernel::internal('client:Invoices/PayInvoice', ['owner_id' => $uid, 'id' => $id, 'payment' => ['method' => 'card']])['data']; if ($r['payment']['status'] === 'failed') $retryLater($id); ``` #### Applying a Coupon to an Invoice post/api/v1/client/invoices/{id}/coupon `Invoices/ApplyInvoiceCoupon` one coupon per document Applies a coupon to an open invoice and prices the document again. Body 1 codestringreqThe coupon code. Response fields data — 3 invoiceobjectThe document priced again. couponobjectThe coupon applied. discountobjectThe discount granted on this document. Errors 8 not_found404No such invoice, it belongs to another customer, or it is a draft. invoice_not_payable422The invoice is not open. coupon_code_required422No code was sent. coupon_invalid422No such coupon. coupon_not_invoice422The coupon cannot be used on invoices. coupon_used422A coupon was already applied to this document. coupon_scope422The coupon does not apply to the lines on this invoice. coupon_rejected422The coupon engine refused it. The reason comes in the message. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/invoices/1193/coupon' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"code":"WELCOME10"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/invoices/${id}/coupon`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), }); const { data } = await res.json(); showNewTotal(data.invoice.total, data.discount); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/invoices/' . $id . '/coupon'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['code' => $code]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Apply the coupon BEFORE paying: a closed document cannot be changed and there is no way back. Kernel::internal('client:Invoices/ApplyInvoiceCoupon', ['owner_id' => $uid, 'id' => $id, 'code' => $code]); Kernel::internal('client:Invoices/PayInvoice', ['owner_id' => $uid, 'id' => $id, 'payment' => ['method' => 'balance']]); ``` ### Pitfalls > **A declined card returns no error** > > The payment endpoint answers **successfully** on a declined card as well: the invoice stays open and the fee of the method picked stays on the document. Read the outcome from the payment state in the answer. Take that fee into account when paying the same invoice another way. > **A subscription collecting it refuses a manual payment** > > Where a payment provider subscription collects an invoice the payment endpoint **refuses**. That is a gate against a double charge rather than a fault. Read the subscription field in the detail and close the pay button; paying by hand wants the subscription cancelled first. > **The customer details on an invoice belong to the moment** > > An invoice **keeps the customer record on itself**: the address, tax number and title stand as they were when it was raised. The document does not move when the profile does, because it is a legal record. Do not report a mismatch by comparing it with the profile today. > **One coupon per document, and before paying** > > One invoice takes **one coupon**, and only while the document is open. Applying one after payment is refused and there is no way back. Try the coupon before the payment call. > **A top-up invoice is not paid from the wallet** > > The invoice raised to put money into the wallet **cannot be paid from the wallet**, since that would be circular. It is paid with a saved card or from the panel. Handle that error apart when writing a general payment flow. > **The two state fields do not say the same thing** > > The raw state is the value on the record while the customer-facing one **takes the due date in**. Only the second moves when an unpaid invoice passes its date. The filter takes the customer state, so the overdue ones cannot be found by the raw one. ### Related Articles - [The Account Balance](https://dev.wisecp.com/en/the-account-balance) - [Ordering as the Client](https://dev.wisecp.com/en/ordering-as-the-client) - [The Services You Own](https://dev.wisecp.com/en/the-services-you-own) ## Tickets https://dev.wisecp.com/en/asking-for-support The eleven endpoints that open, answer, close and rate a support ticket. ### Overview A support ticket is a **conversation**: the customer opens it, the staff reply, and it closes. Eleven endpoints cover every step of it. Two dictionaries want reading before a ticket is opened: which fields each **department** asks for, and which **access groups** take encrypted details. A password does not belong in the message body. A separate field exists for the access groups, and a password-type value there is stored **encrypted**. ### Reference #### Listing the Departments get/api/v1/client/tickets/departments `Tickets/GetTicketDepartments` the opening schema Returns the departments a ticket can open in, each with its own field schema. Response fields data[] — 4 idintThe department id. namestringThe department name. descriptionstringWhat it is for. custom_fieldsobject[] custom_fields[]The fields asked at opening. idintThe field id. It is the key of the map in the body. namestringThe field label. typestringThe input type. requiredboolWhether it may be left empty. optionsobject[]The choices on a picker field. Each carries a value and a label, and the value is what goes. Errors 2 ticket_system_disabled422The operator closed the support system. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/tickets/departments' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/tickets/departments', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); renderDepartmentForm(data[0].custom_fields); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/departments'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The field schema differs PER DEPARTMENT: build the form from the chosen one's schema. $deps = Kernel::internal('client:Tickets/GetTicketDepartments', ['owner_id' => $uid])['data']; $schema = array_column($deps, 'custom_fields', 'id')[$did] ?? []; ``` #### Listing the Access Groups get/api/v1/client/tickets/access-groups `Tickets/GetTicketAccessGroups` encrypted fields Returns the access detail groups a message can carry, with their schemas. Response fields data[] — 3 idintThe group id. This is what goes into the group field of an access row. namestringThe group name. custom_fieldsobject[] custom_fields[]The group's access fields. idintThe field id. It is the key of the map in the body. namestringThe field label. typestringThe input type. requiredboolWhether it may be left empty. optionsobject[]The choices on a picker field. Each carries a value and a label, and the value is what goes. Errors 2 ticket_system_disabled422The operator closed the support system. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/tickets/access-groups' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/tickets/access-groups', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const server = data.find((g) => g.name.includes('Server')); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/access-groups'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A password-type field is stored ENCRYPTED; use this rather than writing secrets into the message body. $groups = Kernel::internal('client:Tickets/GetTicketAccessGroups', ['owner_id' => $uid])['data']; $creds = [['group_id' => $groups[0]['id'], 'fields' => $values]]; ``` #### Listing the Tickets get/api/v1/client/tickets `Tickets/GetTickets` state order Returns the account's support tickets in order of state. Query 5 pageintWhich page. limitintRows per page. 100 at the most. statusstringThe state filter. It takes the customer-facing vocabulary. departmentintThe department filter. searchstringSearches the subject, the reference and the department name. Response fields data[] — 11 + meta — 4 ticket_idintThe ticket id. refstringThe reference the customer quotes. subjectstringThe subject. statusstringThe raw state. statestringThe state shown to the customer. priorityintThe priority. One to four: low, medium, high, urgent. departmentobjectThe department. It carries an id and a name. ratingintThe rating given to the ticket. created_atstringWhen it was opened. unreadboolWhether an unread staff reply waits. last_replyobjectThe last visible message. It carries when and who wrote it. totalintHow many tickets there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 3 ticket_system_disabled422The operator closed the support system. status_invalid422The state is not one that is known. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/tickets?status=answered' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/tickets?status=answered', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const waiting = data.filter((t) => t.unread); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets?status=answered'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The unread mark shows in the LISTING alone: reading the detail clears it, so count from the list first. $rows = Kernel::internal('client:Tickets/GetTickets', ['owner_id' => $uid])['data']; $badge = count(array_filter($rows, fn ($t) => $t['unread'])); ``` #### Opening a Ticket post/api/v1/client/tickets `Tickets/CreateTicket` it takes attachments Opens a new support ticket. Body 9 department_idintreqThe department id. subjectstringreqThe subject. messagestringreqThe opening message. Five characters at the least, in plain text with markup stripped. priorityintThe priority. Medium stands in when left out. service_idintTies the ticket to a service. custom_fieldsobjectThe answers to the department fields. It maps a field id to a value, and a tick field takes a list. credentialsobject[]The access details. Each row carries a group id and a field map, and it is written into the message encrypted. attachmentsarrayThe attachments. They go as encoded content and an address is refused. encrypt_messageboolWhether the message body is stored encrypted. Response fields 201 — data — 13 dataobjectThe ticket opened, in full. Same shape as the read endpoint. Errors 10 not_found404The service to tie it to is not this account's. ticket_system_disabled422The operator closed the support system. subject_required422The subject is empty. department_invalid422The department is unknown or closed. message_too_short422The message is under five characters. custom_field_required422A required department field is empty. Which one comes in the answer's detail. access_group_invalid422An access row carries an unknown group. credential_field_required422A required access field is empty. attachment_invalid422An attachment came as an address or failed validation. ticket_rejected422A hook refused the ticket. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/tickets' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"department_id":4,"subject":"Cannot reach my server","message":"SSH times out since this morning.","priority":3}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/tickets', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ department_id: 4, subject, message, credentials: [{ group_id: 1, fields: { 30: ip, 32: password } }], }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($ticket), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The attachments are checked BEFORE the ticket is saved: a broken file leaves no half ticket. Kernel::internal('client:Tickets/CreateTicket', ['owner_id' => $uid] + $ticket); ``` #### Reading a Ticket and Its Messages get/api/v1/client/tickets/{id} `Tickets/GetTicket` it marks as read Returns a ticket's heading and the whole run of messages. Response fields data — 13 ticket_idintThe ticket id. refstringThe reference the customer quotes. subjectstringThe subject. statusstringThe raw state. statestringThe state shown to the customer. priorityintThe priority. One to four: low, medium, high, urgent. departmentobjectThe department. It carries an id and a name. ratingintThe rating given to the ticket. created_atstringWhen it was opened. lockedboolWhether the staff locked it. Replying and reopening stop while it is. can_replyboolWhether a reply can be written. related_serviceobjectThe service tied to it. It carries an id, a name, a type and a state. last_reply_atstringWhen the last message came. messagesobject[] messages[]The visible messages, oldest first. The staff's internal notes never appear. message_idintThe message id. This is the number given when rating a staff reply. authorobjectWho wrote it. It carries a kind and a name. is_htmlboolWhether the body carries markup. It can be true on a staff message. bodystringThe message itself. created_atstringWhen it was written. ratingintThe rating given to this reply. It fills on a staff message alone. attachmentsobject[]The attachments. Each carries an id, a name and a size, and the content comes from the download endpoint. credentialsobject[]The access details unlocked. It carries the group and its fields, and a password-type value is marked secret. Errors 3 not_found404No such ticket, or it belongs to another customer. ticket_system_disabled422The operator closed the support system. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/tickets/429' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); renderThread(data.messages, data.can_reply); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This read MARKS AS READ: a job counting badges drops the badge the moment it calls. $t = Kernel::internal('client:Tickets/GetTicket', ['owner_id' => $uid, 'id' => $id])['data']; $html = array_filter($t['messages'], fn ($m) => $m['is_html']); ``` #### Writing a Reply post/api/v1/client/tickets/{id}/reply `Tickets/ReplyTicket` it takes attachments Adds a reply to an open ticket. Body 4 messagestringreqThe reply text. Five characters at the least. credentialsobject[]The access details. attachmentsarrayThe attachments. encrypt_messageboolWhether the body is stored encrypted. Response fields data — 2 ticketobjectThe ticket's new state. A reply moves it back to waiting. messageobjectThe reply saved. It comes in the shape used in the message run. Errors 7 not_found404No such ticket, or it belongs to another customer. ticket_system_disabled422The operator closed the support system. ticket_not_open422The ticket is closed or locked. message_too_short422The message is under five characters. access_group_invalid422The access rows did not pass the schema. attachment_invalid422An attachment was refused. reply_rejected422A hook refused the reply. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/tickets/429/reply' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"message":"The firewall rule is back, thank you."}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}/reply`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ message }), }); const { data } = await res.json(); appendMessage(data.message); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id . '/reply'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['message' => $text]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A reply moves the ticket back to WAITING: a closed one takes none, so reopen it first. $t = Kernel::internal('client:Tickets/GetTicket', ['owner_id' => $uid, 'id' => $id])['data']; if (! $t['can_reply']) Kernel::internal('client:Tickets/ReopenTicket', ['owner_id' => $uid, 'id' => $id]); ``` #### Closing a Ticket post/api/v1/client/tickets/{id}/close `Tickets/CloseTicket` a rating may come with it Closes a ticket as solved. Body 1 ratingintA rating given while closing. It counts only while the ticket carries none. Response fields data — 3 ticket_idintThe ticket id. statusstringThe new raw state. statestringThe new state shown. Errors 3 not_found404No such ticket, or it belongs to another customer. ticket_system_disabled422The operator closed the support system. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/tickets/429/close' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"rating":5}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}/close`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rating: 5 }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id . '/close'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['rating' => 5]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Rating while closing is a ONE-TIME chance: a ticket that already carries one skips the value quietly. Kernel::internal('client:Tickets/CloseTicket', ['owner_id' => $uid, 'id' => $id, 'rating' => 5]); ``` #### Reopening a Ticket post/api/v1/client/tickets/{id}/reopen `Tickets/ReopenTicket` not while locked Reopens a closed ticket. Body — ——No body is needed. The id in the path names the ticket; send an empty body. Response fields data — 3 ticket_idintThe ticket id. statusstringThe new raw state. statestringThe new state shown. Errors 4 not_found404No such ticket, or it belongs to another customer. ticket_system_disabled422The operator closed the support system. ticket_locked422The staff locked the ticket. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/tickets/429/reopen' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}/reopen`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id . '/reopen'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A LOCKED ticket cannot be reopened: opening a new one is the only road there. $t = Kernel::internal('client:Tickets/GetTicket', ['owner_id' => $uid, 'id' => $id])['data']; if ($t['locked']) $openNewTicket(); ``` #### Rating a Ticket post/api/v1/client/tickets/{id}/rating `Tickets/RateTicket` once only Gives the ticket a rating from one to five. Body 1 ratingintreqThe rating given. One to five. Response fields data — 2 ticket_idintThe ticket id. ratingintThe rating written. Errors 5 not_found404No such ticket, or it belongs to another customer. ticket_system_disabled422The operator closed the support system. rating_invalid422The rating is not between one and five. already_rated422The ticket already carries a rating. It cannot be changed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/tickets/429/rating' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"rating":5}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}/rating`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rating }), }); if (res.status === 422) hideRatingWidget(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id . '/rating'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['rating' => $n]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A rating is FINAL: once given it cannot be changed, so ask for confirmation in your interface. $t = Kernel::internal('client:Tickets/GetTicket', ['owner_id' => $uid, 'id' => $id])['data']; if ($t['rating'] === null) Kernel::internal('client:Tickets/RateTicket', ['owner_id' => $uid, 'id' => $id, 'rating' => $n]); ``` #### Rating a Staff Reply post/api/v1/client/tickets/{id}/replies/{rid}/rating `Tickets/RateTicketReply` once only Rates one staff reply on its own. Body 1 ratingintreqThe rating given. One to five. Response fields data — 2 message_idintThe reply rated. ratingintThe rating written. Errors 5 not_found404No such ticket, or the id given is not a visible staff reply. ticket_system_disabled422The operator closed the support system. rating_invalid422The rating is not between one and five. already_rated422The reply already carries a rating. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/tickets/429/replies/878/rating' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"rating":4}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}/replies/${rid}/rating`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ rating }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id . '/replies/' . $rid . '/rating'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['rating' => $n]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // STAFF replies alone can be rated: the id of your own message answers not found. $t = Kernel::internal('client:Tickets/GetTicket', ['owner_id' => $uid, 'id' => $id])['data']; $staff = array_filter($t['messages'], fn ($m) => $m['author']['kind'] === 'staff'); ``` #### Downloading an Attachment get/api/v1/client/tickets/{id}/attachments/{aid} `Tickets/GetTicketAttachment` encoded content Returns a message attachment together with its content. Response fields data — 6 attachment_idintThe attachment id. message_idintThe message it hangs from. It can come empty on an older opening record. namestringThe original file name. sizeintThe file size. mimestringThe type worked out from the extension. It falls back to a general value on an unknown one. contentstringThe file itself. It comes encoded. Errors 3 not_found404No such ticket or attachment, it is not yours, or it hangs from a hidden note. ticket_system_disabled422The operator closed the support system. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/tickets/429/attachments/55' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/tickets/${id}/attachments/${aid}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const blob = await (await fetch(`data:${data.mime};base64,${data.content}`)).blob(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/tickets/' . $id . '/attachments/' . $aid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The content comes ENCODED inside the JSON: account for the memory on a large file. $a = Kernel::internal('client:Tickets/GetTicketAttachment', ['owner_id' => $uid, 'id' => $id, 'aid' => $aid])['data']; file_put_contents($a['name'], base64_decode($a['content'])); ``` ### Pitfalls > **Reading the detail clears the unread mark** > > Reading a ticket's detail **clears the unread mark** on the account. A job building a notification badge drops it before the customer ever sees it. Count from the listing and call the detail only when the customer truly opens it. > **A locked ticket cannot be reopened** > > Where the staff locked a ticket neither a reply nor a reopen works. That is **different** from being closed: a closed ticket reopens and a locked one does not. Read the lock field in the detail and offer opening a new ticket instead. > **A rating is final and cannot be taken back** > > Both a ticket rating and a staff reply rating are given **once**, and a second attempt is refused. Sending a rating while closing spends the same right, and a ticket that already carries one skips the value quietly. Ask for confirmation in your interface. > **Secrets go in the access field and not the message body** > > A separate structure exists for a password or a key: the access group rows. A password-type value there is stored **encrypted** and unlocked for the owner and the staff alone. Writing the same detail into the message text leaves it in plain sight. > **An attachment cannot be sent as an address** > > Attachments go as **encoded content** and giving an address is refused. They are checked before the ticket is saved, so a broken file leaves no half ticket. Downloading works the same way: the content comes encoded inside the JSON. > **Internal notes never appear** > > The notes the staff write among themselves **never appear** in the message run, and the attachments on them cannot be downloaded. A gap in the message ids on the customer side is the design rather than a fault. ### Related Articles - [The Services You Own](https://dev.wisecp.com/en/the-services-you-own) - [Client API First Calls](https://dev.wisecp.com/en/client-api-first-calls) ## SMS https://dev.wisecp.com/en/sending-messages The five endpoints that price a bulk send, make it and show its history. ### Overview A customer can send messages in bulk, paying from their own wallet. The flow runs in three steps: pick a **sender name**, get a price, send. The price follows the destination country and the message's **part count**. A longer text, or one leaving the basic alphabet, raises the part count and the amount grows with it. Some countries want the sender name **registered beforehand**. Numbers going to a country without that registration do not drop quietly: they appear in the skipped list with the reason. ### Reference #### Listing the Sender Names get/api/v1/client/sms/senders `Sms/GetSmsSenders` the key's owner Returns the sender names a send can use, along with their country registrations. Response fields data[] — 4 idintThe sender id. namestringThe text the recipient sees. This is what the send endpoint takes. is_defaultboolWhether it is the account's default. countriesobject[]Where it stands in the countries wanting pre-registration. Each carries a country code and a state, and the countries wanting none never appear. Errors 3 sms_disabled422The operator closed the international message service. sms_api_disabled422The operator closed the message interface. The panel keeps working. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/sms/senders' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/sms/senders', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const preferred = data.find((s) => s.is_default) ?? data[0]; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/sms/senders'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An empty country list is NO BAR: most countries want no pre-registration and the name works there straight away. $senders = Kernel::internal('client:Sms/GetSmsSenders', ['owner_id' => $uid])['data']; $name = $senders[0]['name'] ?? null; ``` #### Getting a Quote post/api/v1/client/sms/quote `Sms/QuoteSms` nothing is sent Says what a send will cost and what will be left out. Body 3 senderstringreqThe sender name. It has to be one of the account's live names. messagestringreqThe message text. Text past the part ceiling is cut and the cut is reported. numbersarrayreqThe recipient numbers. In international form, and one string is split on lines, commas and semicolons. Response fields data — 6 messageobjectThe text analysis. The encoding, the length, how many parts, whether it was cut and the text that will truly go. recipientsintHow many numbers will be charged and sent. total_partsintThe parts in all. This is the billing unit. totalobjectWhat the send endpoint will take. countriesobject[]The breakdown per destination. A code, a name, a count, the parts, the unit price and the total. skippedobjectWhat was left out. A count, the entries that could not be read, the countries with no price and the countries where the sender is not registered. Errors 6 sms_disabled422The operator closed the international message service. sms_api_disabled422The operator closed the message interface. The panel keeps working. sender_invalid422The sender is missing, not yours or not live. message_required422The message is empty. numbers_required422The recipient list is empty. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/sms/quote' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"sender":"TESTBRAND","message":"Your code is 482913","numbers":["+15551112233"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/sms/quote', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sender, message, numbers }), }); const { data } = await res.json(); if (data.skipped.total) reviewSkipped(data.skipped); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/sms/quote'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(compact('sender', 'message', 'numbers')), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A dropped destination is NO ERROR: the quote always succeeds and the loss is read from the skipped block. $q = Kernel::internal('client:Sms/QuoteSms', ['owner_id' => $uid, 'sender' => $sender, 'message' => $text, 'numbers' => $nums])['data']; $willCost = $q['total']['amount']; $willDrop = $q['skipped']['total']; ``` #### Sending in Bulk post/api/v1/client/sms/send `Sms/SendSms` it takes from the wallet Sends the message and takes the amount from the wallet. Body 3 senderstringreqThe sender name. It has to be one of the account's live names. messagestringreqThe message text. Text past the part ceiling is cut and the cut is reported. numbersarrayreqThe recipient numbers. In international form, and one string is split on lines, commas and semicolons. Response fields 201 — data — 9 message_idintThe send id. The history endpoint is asked with it. senderstringThe sender used. acceptedintHow many recipients were charged and handed to the provider. partsintThe parts per message. total_partsintThe parts in all. totalobjectWhat was taken from the wallet. balanceobjectThe wallet after the charge. report_idstringThe provider's batch reference. skippedobjectWhat was left out. A count, the entries that could not be read, the countries with no price and the countries where the sender is not registered. Errors 10 sms_disabled422The operator closed the international message service. sms_api_disabled422The operator closed the message interface. The panel keeps working. sender_invalid422The sender is missing, not yours or not live. no_recipients422Every number was left out. The reason comes in the answer's detail. price_unavailable422The amount priced to zero. send_rejected422A hook refused the send. sms_module_unavailable422No sending provider is configured. insufficient_balance422The wallet does not cover it. Nothing is sent and nothing is taken. send_failed500The provider refused the send. The whole charge is returned. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/sms/send' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"sender":"TESTBRAND","message":"Your code is 482913","numbers":["+15551112233"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/sms/send', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ sender, message, numbers }), }); const { data } = await res.json(); console.log(data.accepted, data.total, data.balance); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/sms/send'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(compact('sender', 'message', 'numbers')), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The SERVER prices it again: the figure in the quote is no promise and the send knows its own price. $r = Kernel::internal('client:Sms/SendSms', ['owner_id' => $uid, 'sender' => $sender, 'message' => $text, 'numbers' => $nums])['data']; $charged = $r['total']['amount']; ``` #### The Sending History get/api/v1/client/sms/messages `Sms/GetSmsMessages` the key's owner Returns the sends made, newest first. Query 2 pageintWhich page. limitintRows per page. 100 at the most. Response fields data[] — 9 + meta — 4 message_idintThe send id. senderstringThe sender used. textstringThe text sent. recipientsintHow many recipients were charged. partsintThe parts per message. total_partsintThe parts in all. totalobjectWhat was taken. countriesobject[]The breakdown per destination. created_atstringWhen it was sent. total_countintHow many sends there are. It comes back under meta as the total. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 3 sms_disabled422The operator closed the international message service. sms_api_disabled422The operator closed the message interface. The panel keeps working. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/sms/messages' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/sms/messages', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data, meta } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/sms/messages'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The history holds NO RECIPIENT NUMBERS: they come on the single-send endpoint alone. $rows = Kernel::internal('client:Sms/GetSmsMessages', ['owner_id' => $uid])['data']; $one = Kernel::internal('client:Sms/GetSmsMessage', ['owner_id' => $uid, 'id' => $rows[0]['message_id']])['data']; ``` #### Reading One Send get/api/v1/client/sms/messages/{id} `Sms/GetSmsMessage` the key's owner Returns one send together with its recipient numbers. Response fields data — 11 dataobjectThe fields of a history row. numbersstring[]The numbers charged and sent. report_idstringThe provider's batch reference. Errors 4 not_found404No such send, or it belongs to another customer. sms_disabled422The operator closed the international message service. sms_api_disabled422The operator closed the message interface. The panel keeps working. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/sms/messages/4520' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/sms/messages/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); console.log(data.numbers.length, data.report_id); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/sms/messages/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A per-recipient DELIVERY REPORT is ABSENT here: the live provider report stays in the panel view. $m = Kernel::internal('client:Sms/GetSmsMessage', ['owner_id' => $uid, 'id' => $id])['data']; $sentTo = $m['numbers']; ``` ### Pitfalls > **Dropped recipients return no error** > > The quote and send endpoints do not drop invalid numbers, unpriced countries and countries where the sender is unregistered **in silence**, and they do not stop the send either: the rest goes and the dropped ones are listed in the answer. Without reading that block you never notice part of the message never left. > **The quote is not a promise** > > The send endpoint **prices it again itself** and never trusts a figure the caller sends. A price or a sender registration moving between the quote and the send can change what is taken. Read the settled figure from the send answer. > **The charge is atomic and the refund is whole** > > The wallet is charged with **one conditional update**: two sends at once cannot push the balance below zero. Where the provider refuses, the whole charge comes back and no message leaves. A failed send costs nothing. > **Leaving the basic alphabet raises the part count** > > A message in the basic alphabet fits more characters per part, while a single letter outside it drops the text into **the other encoding** and the capacity per part falls by more than half. The amount rests on parts, so the cost jumps. Read the encoding field in the quote. > **The history carries no numbers** > > The sending history gives summary rows and the **recipient numbers** come only when you read one send. A per-recipient delivery report is absent from the API entirely and stays in the panel, since it is pulled live from the provider. ### Related Articles - [The Account Balance](https://dev.wisecp.com/en/the-account-balance) - [Client API First Calls](https://dev.wisecp.com/en/client-api-first-calls) ## Affiliate https://dev.wisecp.com/en/your-affiliate-earnings The five endpoints managing a partner's programme, earnings and payout requests. ### Overview The affiliate programme is where a customer takes a share of the business their own link brings in. These five endpoints manage **one partner's own** programme. Earnings move in two stages: a commission **clears** first and then reaches the available balance. A payout is asked from the available amount alone. Payouts are settled by hand. A request is opened, the operator approves it, and only one request stands open at a time. ### Reference #### Reading Where You Stand get/api/v1/client/affiliate `Affiliate/GetAffiliate` it moves what cleared Returns the programme, the earnings and the payout state in one call. Response fields data — 7 enrolledboolWhether the account joined. disabledboolWhether the operator closed this partnership. programobjectThe programme rules. The commission rate, the tracking window, the clearing period and the payout floor. referralobjectThe referral to share. It carries a code and a full link. balanceobject balanceThe earnings block. availableobjectWhat can be asked for now. clearingobjectWhat is waiting to clear. earnedobjectWhat has been earned in all. paid_outobjectWhat has been paid out. statsobjectThe referral counts. Those who signed up and those who earned commission. payoutobject payoutWhere payout stands. readyboolWhether what is available reaches the floor. pending_requestobjectThe one request waiting. It carries an amount, a method, a state and when it was asked. gatewaysstring[]The payout roads the operator offers. saved_destinationsobjectYour saved destination per road. Errors 2 affiliate_disabled422The operator closed the affiliate programme. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/affiliate' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/affiliate', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (! data.enrolled) showJoinButton(data.program); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/affiliate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This read WRITES: it moves the cleared commissions into the available balance and is not read-only. $aff = Kernel::internal('client:Affiliate/GetAffiliate', ['owner_id' => $uid])['data']; $canAsk = $aff['payout']['ready'] ?? false; ``` #### Joining the Programme post/api/v1/client/affiliate/enroll `Affiliate/EnrollAffiliate` the account currency Signs the account up to the affiliate programme. Body — ——No body is needed. Response fields 201 — data — 7 dataobjectThe new partnership dashboard. Same shape as the read endpoint. Errors 2 affiliate_disabled422The operator closed the affiliate programme. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/affiliate/enroll' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/affiliate/enroll', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); shareLink(data.referral.link); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/affiliate/enroll'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The partnership opens in THE ACCOUNT's currency and cannot be changed later; check the profile first. $me = Kernel::internal('client:Account/GetMe', ['owner_id' => $uid])['data']; if ($me['currency'] === $wanted) Kernel::internal('client:Affiliate/EnrollAffiliate', ['owner_id' => $uid]); ``` #### Listing the Commissions get/api/v1/client/affiliate/commissions `Affiliate/GetAffiliateCommissions` the key's owner Returns the commissions earned, newest first. Query 2 pageintWhich page. limitintRows per page. 100 at the most. Response fields data[] — 10 + meta — 4 commission_idintThe record id. statusstringThe raw state. statestringThe state shown: available, clearing or refused. referralstringThe display name of the customer referred. servicestringThe service the commission came from. service_typestringThe service's product type. order_amountobjectThe amount of the order referred. commissionobjectYour share of it. created_atstringWhen the record was written. clearing_datestringThe day it becomes available. totalintHow many records there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 3 affiliate_disabled422The operator closed the affiliate programme. not_enrolled422The account has not joined the programme. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/affiliate/commissions' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/affiliate/commissions', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const soon = data.filter((c) => c.state === 'clearing'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/affiliate/commissions'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A clearing commission CANNOT be paid yet: it reaches the available balance on its clearing day. $rows = Kernel::internal('client:Affiliate/GetAffiliateCommissions', ['owner_id' => $uid])['data']; $soon = array_filter($rows, fn ($c) => $c['state'] === 'clearing'); ``` #### Asking to Be Paid post/api/v1/client/affiliate/withdraw `Affiliate/WithdrawAffiliate` one request at a time Asks for the available earnings to be paid out. Body 4 amountfloatreqThe amount asked for. It has to sit between the floor and what is available. gatewaystringreqThe payout road. It has to be one the operator offers. gateway_infostringThe destination detail. It can be left out where one is saved. save_defaultboolKeep this destination for later requests. Response fields 201 — data — 4 withdrawal_idintThe request id. amountobjectThe amount asked for. gatewaystringThe road picked. statusstringWhere the request stands. The operator settles it by hand. Errors 10 affiliate_disabled422The operator closed the affiliate programme. not_enrolled422The account has not joined the programme. affiliate_disabled_account422The operator closed this partnership. withdrawal_pending422A request is already waiting. below_minimum422What is available does not reach the floor. amount_invalid422The amount sits outside the range allowed. Both bounds come in the answer's detail. gateway_invalid422The road is none of the operator's. gateway_info_required422No destination was given and none is saved. withdrawal_rejected422A hook refused the request. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/affiliate/withdraw' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"amount":40,"gateway":"PayPal","gateway_info":"partner@example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/affiliate/withdraw', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ amount, gateway, save_default: true }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/affiliate/withdraw'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['amount' => 40, 'gateway' => 'PayPal']), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Take the amount FROM THE DASHBOARD: what is available can move as commissions clear during the read. $aff = Kernel::internal('client:Affiliate/GetAffiliate', ['owner_id' => $uid])['data']; $max = $aff['balance']['available']['amount']; Kernel::internal('client:Affiliate/WithdrawAffiliate', ['owner_id' => $uid, 'amount' => $max, 'gateway' => $gw]); ``` #### Saving a Payout Destination put/api/v1/client/affiliate/payment-method `Affiliate/SaveAffiliatePayoutMethod` the key's owner Saves or clears the destination detail of a payout road. Body 2 gatewaystringreqThe payout road. infostringThe destination detail. Sending it empty clears the record. Response fields data — 1 saved_destinationsobjectThe destination map as it now stands. It carries the detail saved per road. Errors 4 affiliate_disabled422The operator closed the affiliate programme. not_enrolled422The account has not joined the programme. gateway_invalid422The road is none of the operator's. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/affiliate/payment-method' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"gateway":"PayPal","info":"partner@example.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/affiliate/payment-method', { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ gateway, info }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/affiliate/payment-method'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['gateway' => $gw, 'info' => $info]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A destination is kept PER ROAD: clearing one leaves the others alone and the map is managed one by one. Kernel::internal('client:Affiliate/SaveAffiliatePayoutMethod', ['owner_id' => $uid, 'gateway' => $gw, 'info' => '']); ``` ### Pitfalls > **Reading the dashboard actually writes** > > Reading where you stand **moves the commissions** whose clearing day arrived into the available balance. The endpoint is not read-only, and two calls in a row can show different balances. Call it right before a payout request to see the highest amount you may ask for. > **A clearing commission is not money yet** > > A commission is not paid the moment it is earned: it waits out the **clearing period** the operator set. The clearing figure on the dashboard is part of what was earned and cannot be asked for. Adding the two figures and asking for the sum is refused. > **One payout request at a time** > > A second request **cannot be opened** while one waits. A new one is refused until the operator settles it by hand. Read the pending field on the dashboard and close the request road in your interface, or the customer meets an error they cannot place. > **The partnership opens in the account currency** > > The partnership record opens in the account's currency at the time of joining and the commissions build up in it. Changing the currency on the profile later **does not move the earnings**. Make sure the currency is right before joining. > **The destination is kept per road** > > Every payout road keeps **a destination of its own**, and clearing one leaves the others alone. Leaving the destination out of a request falls back to that road's saved detail, and the request is refused where none is saved. ### Related Articles - [The Account Balance](https://dev.wisecp.com/en/the-account-balance) - [Affiliate Partners](https://dev.wisecp.com/en/affiliate-partners) # API / Client API / Domains ## The Domains You Own https://dev.wisecp.com/en/the-domains-you-own The five endpoints that read, set, renew and extend a domain you own. ### Overview A domain is a service too, and it carries **endpoints of its own**: the service endpoints answer not found for it. These five read a domain you own, set it, renew it and open its add-ons. The address takes either **the domain name itself** or the service number, and both open the same record. The capability list in the detail says which jobs can be done. It differs from provider to provider, so the interface should be built **from that list**. ### Reference #### Listing the Domains get/api/v1/client/domains `Domains/GetDomains` the key's owner Returns the account's domains. Query 5 pageintWhich page. limitintRows per page. 100 at the most. statusstringThe state filter. It takes badge groups. tldstringThe domains on this extension alone. searchstringSearches the domain name. Response fields data[] — 11 + meta — 4 idintThe domain's service id. namestringThe full domain name. tldstringThe extension. The dots stay in a multi-part one. statusstringThe raw service state. auto_renewboolAutomatic renewal for this domain. lockedboolThe last known transfer lock value. The live one is read from the lock endpoint. whois_privacyboolThe last known registration privacy value. priceobjectThe price per period. yearsintThe term in years. registered_atstringThe day it was registered. due_datestringThe day it expires and renews. totalintHow many domains there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains?tld=com' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains?status=active', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const expiring = data.filter((d) => d.due_date && d.due_date < horizon); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains?status=active'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The lock and privacy values are a MIRROR: the last value saved rather than the current one. $rows = Kernel::internal('client:Domains/GetDomains', ['owner_id' => $uid])['data']; // for the live one: client:Domains/GetTransferLock ``` #### Reading a Domain get/api/v1/client/domains/{domain} `Domains/GetDomain` a capability probe Returns a domain, what its provider can do and the renewal terms. Response fields data — 17 idintThe domain's service id. namestringThe full domain name. tldstringThe extension. The dots stay in a multi-part one. statusstringThe raw service state. auto_renewboolAutomatic renewal for this domain. lockedboolThe last known transfer lock value. The live one is read from the lock endpoint. whois_privacyboolThe last known registration privacy value. priceobjectThe price per period. yearsintThe term in years. registered_atstringThe day it was registered. due_datestringThe day it expires and renews. order_idintThe order that brought it into being. auto_renew_lockedboolWhether account-wide automatic payment is on. The per-domain switch cannot move while it is. billing_profile_idintThe billing profile assigned. nameserversstring[]The name servers on record. Empty slots drop out. capabilitiesobjectWhat the provider supports. Eleven fields: name servers, the transfer lock, the transfer code, child name servers, reading and writing records, signing, e-mail and address forwarding, registrant details and privacy. addonsobjectWhere the domain add-ons stand. Each carries a state, whether it is free, a price and any invoice waiting. renewalobjectThe renewal terms. It carries whether it is open, any subscription block, the registry ceiling and the priced term rows. verificationstringWhere registrant verification stands. Uploading a document happens in the panel alone. Errors 2 not_found404No such domain, it is not yours, or access to it is restricted. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.capabilities.dns_records) showDnsTab(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Build the interface FROM THE CAPABILITIES: not every provider does everything, and a closed surface answers 422. $d = Kernel::internal('client:Domains/GetDomain', ['owner_id' => $uid, 'domain' => $domain])['data']; $tabs = array_keys(array_filter($d['capabilities'])); ``` #### Changing the Domain Preferences patch/api/v1/client/domains/{domain} `Domains/UpdateDomain` two fields Changes automatic renewal and the billing profile. Body 2 auto_renewboolAutomatic renewal for this domain. On a live domain alone, and with a true boolean. billing_profile_idintThe billing profile id. Zero returns to the account default. Response fields data — 17 dataobjectThe domain as it now stands. Same shape as the read endpoint. Errors 7 not_found404No such domain, it is not yours, or access to it is restricted. nothing_to_update422The body holds no field that can be updated. auto_renew_invalid422The value is not a boolean. not_actionable422Automatic renewal was sent while the domain is not live. autorenew_locked422Account-wide automatic payment is on. no_auto_pay_source422There is no source to charge. billing_profile_rejected422A hook refused the profile assignment. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PATCH 'https://panel.example.com/api/v1/client/domains/example.com' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"auto_renew":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}`, { method: 'PATCH', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ auto_renew: true }), }); if (res.status === 422) explainLock(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['auto_renew' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // With automatic renewal off a domain expires QUIETLY: setting a reminder is left to you. Kernel::internal('client:Domains/UpdateDomain', ['owner_id' => $uid, 'domain' => $domain, 'auto_renew' => true]); ``` #### Raising a Renewal Invoice post/api/v1/client/domains/{domain}/renew `Domains/RenewDomain` it raises an invoice Raises a renewal invoice for the number of years asked. Body 1 yearsintreqThe term in years. It has to be one of the priced term rows in the detail. Response fields data — 8 invoice_idintThe invoice id. numberstringThe number shown. statusstringThe raw invoice state. statestringThe state shown. totalobjectThe total with tax. created_atstringThe day it was raised. paid_atstringThe day it was paid. due_datestringThe day it falls due. Errors 4 not_found404No such domain, it is not yours, or access to it is restricted. not_renewable422The domain cannot be renewed, or a subscription collects it. years_invalid422The term is none of those offered. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/renew' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"years":2}' ``` ```javascript const d = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}`, { headers: { Authorization: `Bearer ${clientKey}` }, }).then((r) => r.json()); const terms = d.data.renewal.terms; await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/renew`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ years: terms[0].years }), }); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/renew'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['years' => 2]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The terms offered are bounded by THE REGISTRY CEILING: a ten-year name may take no further two. $d = Kernel::internal('client:Domains/GetDomain', ['owner_id' => $uid, 'domain' => $domain])['data']; $years = array_column($d['renewal']['terms'], 'years'); ``` #### Buying a Domain Add-on post/api/v1/client/domains/{domain}/addons/{key} `Domains/BuyDomainAddon` a prorated first period Buys the name management, privacy or forwarding add-on. Body — ——No body is needed. The domain and the add-on key come from the path, and the term is fixed at one year aligned to the domain; send an empty body. Response fields data — 9 invoice_idintThe invoice to pay. numberstringThe number shown. statusstringThe raw invoice state. statestringThe state shown. totalobjectThe prorated total of the first period. created_atstringThe day it was raised. paid_atstring | nullThe day it was paid. It stays null until the invoice is paid. due_datestringThe last day to pay. addonstringThe add-on ordered. Errors 10 not_found404The add-on key is none of the three values, or the domain was not found. not_actionable422The domain is not live. addon_not_supported422The provider offers no such surface. addon_not_offered422The operator does not sell this add-on on this extension. addon_free422The add-on is free on this extension. There is nothing to buy. addon_active422The add-on is already live. addon_pending422An unpaid add-on invoice is already open. addon_rejected422A site policy vetoed the purchase. The message carries the reason. addon_term_invalid422No term is left to prorate, or the amount falls to zero. addon_failed500The invoice or the record could not be made. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/addons/whois-privacy' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/addons/${key}`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); goPay(data.invoice_id); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/addons/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // An add-on does not open until THE INVOICE IS PAID: the purchase call raises the document alone. $inv = Kernel::internal('client:Domains/BuyDomainAddon', ['owner_id' => $uid, 'domain' => $domain, 'key' => 'whois-privacy'])['data']; Kernel::internal('client:Invoices/PayInvoice', ['owner_id' => $uid, 'id' => $inv['invoice_id'], 'payment' => ['method' => 'balance']]); ``` ### Pitfalls > **The capabilities follow the provider** > > The capability list in the detail carries **eleven fields** and no provider offers them all. Calling a closed surface comes back with a plain error. Draw the tabs and the buttons from that list, since a fixed interface breaks when the provider changes. > **The lock and privacy values are a mirror** > > The transfer lock and privacy values in the listing and the detail are **the last ones saved** rather than the provider's current state. Read the real value live from the lock endpoint. On a domain where the mirror was never written the safe assumption is locked. > **An add-on does not open until the invoice is paid** > > Buying an add-on **raises an invoice** and nothing more; the surface stays closed until it is paid. A second purchase is refused while one waits, and the answer says which invoice blocks it. The first period is prorated to the time left. > **The renewal terms are bounded by the registry ceiling** > > The renewal rows in the detail show the terms the operator prices **and** that fit under the registry's total ceiling. A domain expiring far out can leave one year in the list, or none. Pick the term from the list rather than guessing. > **With automatic renewal off the reminder is yours** > > A domain with automatic renewal off expires **quietly** on its due date and enters the recovery window. The API gives no separate warning. Read the expiry from the listing and set a reminder of your own. ### Related Articles - [Name Servers and Records](https://dev.wisecp.com/en/name-servers-and-records) - [Domain Contacts and Privacy](https://dev.wisecp.com/en/domain-contacts-and-privacy) - [Taking a Domain](https://dev.wisecp.com/en/taking-a-domain) ## Taking a Domain https://dev.wisecp.com/en/taking-a-domain The nine endpoints that register, transfer and control the movement of a domain. ### Overview A domain is taken in one of two ways: **registering** a new one or **moving** one from another provider. These nine endpoints cover both roads and the transfer controls around them. Two questions come before an order: what the extension costs and for which terms it sells, and whether the name is free right now. The answer to the second is a **snapshot**. The transfer controls work both ways. Handing a domain to someone else means opening the lock and asking for the code; bringing one here means having it opened at the other end and writing the code down here. ### Reference #### Reading the Extension Prices get/api/v1/client/domains/tlds `Domains/GetTlds` the wallet currency Returns the extensions on sale with their terms and prices. Query 1 tldstringOne extension. It narrows the list and adds the price table per term. Response fields data[] — 7 tldstringThe extension. min_yearsintThe shortest term. max_yearsintThe longest term. registerobjectThe one-year registration price. The price before a discount comes too where a promotion runs. transferobjectThe one-year transfer price. It comes empty where the extension has no transfer price. renewalobjectThe one-year renewal price. register_yearsobjectThe registration price per term. It comes only when one extension is asked for, and a term with no entry is not sold. Errors 3 not_found404The extension asked for is not sold. domains_not_offered422The operator does not sell domains. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/tlds?tld=com' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/tlds?tld=com', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const years = Object.keys(data[0].register_years ?? {}); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/tlds?tld=com'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The price per term comes ONLY when one extension is asked for: the general listing holds no such table. $one = Kernel::internal('client:Domains/GetTlds', ['owner_id' => $uid, 'tld' => 'com'])['data'][0]; $priced = array_keys($one['register_years'] ?? []); ``` #### Asking Whether a Name Is Free post/api/v1/client/domains/check `Domains/CheckDomain` it asks the registrar Asks live whether a domain can be taken. Body 1 domainstringreqThe full domain to ask about. Response fields data — 5 domainstringThe name as it was read. statusstringThe outcome: free, taken or unknown. premiumboolWhether the registry prices this name apart. premium_priceobjectThe customer price of a specially priced name. It comes with the margin applied and in the wallet currency. registerobjectThe extension's one-year price. It comes on a free name without a special price alone. Errors 6 domains_not_offered422The operator does not sell domains. domain_required422No domain was sent. domain_invalid422It is not a valid domain name. tld_not_offered422The extension is not sold. domain_check_throttled422The shared query pool is full. Try again shortly. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/check' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"domain":"example-shop.com"}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/check', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ domain }), }); const { data } = await res.json(); if (data.premium) showPremiumPrice(data.premium_price); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/check'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['domain' => $name]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Do not read UNKNOWN as free: the registrar could not be reached and the order can still fail. $r = Kernel::internal('client:Domains/CheckDomain', ['owner_id' => $uid, 'domain' => $name])['data']; $safe = $r['status'] === 'available'; ``` #### Registering a Domain post/api/v1/client/domains/register `Domains/RegisterDomain` the balance or a saved card Orders a new domain and collects payment. Body 12 termsboolreqAccepting the terms. It has to be sent true. domainstringreqThe full domain name. yearsintThe term in years. It has to sit inside the extension's window, and a specially priced name is registered for one year whatever is sent. accept_premiumboolAccepting the special price. It is required on such a name. addonsstring[]The add-ons to take with it. Name management, registration privacy and forwarding, where the extension offers them. nameserversstring[]The name servers. Two to four, and the account defaults stand in when left out. whois_profile_idintA saved registrant profile. It fills all four roles. contactsobjectThe contacts per role. Each role carries either a saved profile id or an inline contact, and it overrides the profile for that role. couponsstring[]The coupon codes. billing_profile_idintThe billing profile id. notesstringThe order note. paymentobjectThe payment source. The account balance or a saved card. Response fields 201 — data — 7 order_idintThe order id. numberstringThe order number. actionstringThe job done: a registration or a transfer. domainobjectThe domain service made. It carries the service id, the name, a state and the term. totalobjectThe order total. paymentobjectHow the collection went. It carries the road, the state, the card charged and any reason it failed. invoiceobjectThe order invoice summary. Errors 10 not_found404The registrant profile, the billing profile or the card belongs to another account. domains_not_offered422The operator does not sell domains. terms_required422The terms were not accepted. verification_required422The account is waiting on verification. tld_not_offered422The extension is not sold. years_invalid422The term sits outside the extension's window. addon_not_offered422The add-on is not offered on this extension or the key is unknown. nameservers_invalid422The name server count or form is invalid. contact_field_required422A required field is missing on an inline contact. Every field but the company is required. insufficient_balance422The wallet does not cover the total. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/register' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"terms":true,"domain":"example-shop.com","years":1,"payment":{"method":"balance"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/register', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ terms: true, domain, years: 1, whois_profile_id: profileId, payment: { method: 'balance' }, }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/register'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($order), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A SPECIALLY PRICED name registers for ONE YEAR whatever is sent, and it wants the acceptance flag. $c = Kernel::internal('client:Domains/CheckDomain', ['owner_id' => $uid, 'domain' => $name])['data']; Kernel::internal('client:Domains/RegisterDomain', ['owner_id' => $uid, 'terms' => true, 'domain' => $name, 'accept_premium' => $c['premium'], 'payment' => ['method' => 'balance']]); ``` #### Transferring a Domain post/api/v1/client/domains/transfer `Domains/TransferDomain` a code may be needed Moves a domain held at another provider over here. Body 11 termsboolreqAccepting the terms. It has to be sent true. domainstringreqThe full domain name. auth_codestringThe transfer code from the current provider. It is required where the extension says so and is written to the service either way. addonsstring[]The add-ons to take with it. Name management, registration privacy and forwarding, where the extension offers them. nameserversstring[]The name servers. Two to four, and the account defaults stand in when left out. whois_profile_idintA saved registrant profile. It fills all four roles. contactsobjectThe contacts per role. Each role carries either a saved profile id or an inline contact, and it overrides the profile for that role. couponsstring[]The coupon codes. billing_profile_idintThe billing profile id. notesstringThe order note. paymentobjectThe payment source. The account balance or a saved card. Response fields 201 — data — 7 order_idintThe order id. numberstringThe order number. actionstringThe job done: a registration or a transfer. domainobjectThe domain service made. It carries the service id, the name, a state and the term. totalobjectThe order total. paymentobjectHow the collection went. It carries the road, the state, the card charged and any reason it failed. invoiceobjectThe order invoice summary. Errors 8 domains_not_offered422The operator does not sell domains. terms_required422The terms were not accepted. transfer_not_offered422The extension carries no transfer price. auth_code_required422The extension wants a transfer code and none was sent. domain_not_registered422The live check found the name free. A name that is not registered cannot be transferred. domain_transfer_locked422The live check shows a transfer lock. Open it at the current provider. checkout_blocked422A hook refused the order. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/transfer' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"terms":true,"domain":"example.com","auth_code":"EPP-CODE","payment":{"method":"balance"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/transfer', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ terms: true, domain, auth_code: code, payment }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/transfer'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($order), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A transfer is ALWAYS one year and does not finish at payment: follow it on the transfer status endpoint. $o = Kernel::internal('client:Domains/TransferDomain', ['owner_id' => $uid] + $order)['data']; $sid = $o['domain']['service_id']; ``` #### Reading the Transfer Lock get/api/v1/client/domains/{domain}/transfer-lock `Domains/GetTransferLock` it reads live Reads from the provider whether the domain is closed to transfer. Response fields data — 2 lockedboolWhether the lock is on. liveboolWhether the value came from the provider. False means the last known record is shown. Errors 2 not_found404No such domain, it is not yours, or access to it is restricted. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/transfer-lock' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/transfer-lock`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (! data.live) warnStaleValue(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/transfer-lock'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Where the live flag is false the value can be old: weigh the lock decision against it. $l = Kernel::internal('client:Domains/GetTransferLock', ['owner_id' => $uid, 'domain' => $domain])['data']; $trusted = $l['live']; ``` #### Changing the Transfer Lock put/api/v1/client/domains/{domain}/transfer-lock `Domains/UpdateTransferLock` it writes to the provider Closes the domain to transfer or opens it. Body 1 lockedboolreqThe new lock state. It has to be a true boolean, and text or a number is refused. Response fields data — 1 lockedboolThe new state the provider confirmed. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not in a live state. locked_invalid422The value is not a boolean. lock_not_supported422The provider module has no lock. lock_rejected422A hook refused the change. lock_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/transfer-lock' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"locked":false}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/transfer-lock`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ locked: false }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/transfer-lock'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['locked' => false]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // OPENING the lock leaves the domain open to being taken: close it again as soon as the move is done. Kernel::internal('client:Domains/UpdateTransferLock', ['owner_id' => $uid, 'domain' => $domain, 'locked' => false]); ``` #### Asking for the Transfer Code post/api/v1/client/domains/{domain}/auth-code `Domains/SendAuthCode` no code in the answer Sends the transfer code to the domain owner's e-mail. Body — ——No body is needed, send an empty one. The domain comes from the path and the address is the account's registered one; there is no field to send the code somewhere else. Response fields data — 1 sentboolWhether the send ran. The code itself never appears in the answer. Errors 5 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not in a live state. auth_code_not_supported422The provider module gives no code. auth_code_rejected422A hook refused the request. auth_code_failed422The provider could not give the code. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/auth-code' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/auth-code`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); if (res.ok) tellUserToCheckEmail(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/auth-code'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The code NEVER comes back in the answer: it goes to the owner's e-mail, which is a deliberate safeguard. Kernel::internal('client:Domains/SendAuthCode', ['owner_id' => $uid, 'domain' => $domain]); ``` #### Saving an Incoming Transfer Code put/api/v1/client/domains/{domain}/auth-code `Domains/SaveAuthCode` the key's owner Updates the code for a transfer under way. Body 1 codestringreqThe new transfer code. Markup is stripped and the spaces are trimmed. Response fields data — 1 savedboolWhether the save ran. Errors 4 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not in a live state. code_required422The code is empty or ends up empty once cleaned. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/auth-code' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"code":"NEW-EPP-CODE"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/auth-code`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/auth-code'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['code' => $code]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint SAVES and does not restart the transfer: a changed code still wants a fresh attempt at the provider. Kernel::internal('client:Domains/SaveAuthCode', ['owner_id' => $uid, 'domain' => $domain, 'code' => $code]); ``` #### Asking Where a Transfer Is get/api/v1/client/domains/{domain}/transfer-status `Domains/GetTransferStatus` it asks live Reads from the provider where a transfer under way stands. Response fields data — 4 service_idintThe domain's service id. domainstringThe domain name. statusstringThe service's raw state. transferobject transferWhere the transfer stands. statestringWhether it runs or finished. liveboolWhether the provider answered live. messagestringThe provider's note on progress. It carries the error where the live read failed. expires_atstringThe expiry date reported once it finishes. last_checkedstringWhen it was last asked. This call moves the value. Errors 3 not_found404No such domain, it is not yours, or access to it is restricted. not_a_transfer422The domain was not taken through a transfer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/transfer-status' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/transfer-status`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); showProgress(data.transfer.state, data.transfer.message); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/transfer-status'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Every call goes LIVE to the provider: do not poll it often in a watcher, hourly is plenty. $t = Kernel::internal('client:Domains/GetTransferStatus', ['owner_id' => $uid, 'domain' => $domain])['data']; $done = $t['transfer']['state'] === 'completed'; ``` ### Pitfalls > **An unknown answer does not mean free** > > The availability check gives three answers and **unknown** is one of them: the registrar could not be reached. Treating it as free and ordering leads to a failure at registration. Trust a plain free answer alone. > **A specially priced name registers for one year** > > On a name the registry prices apart the term field is **ignored** and the registration runs one year. Such a name also wants the flag accepting the special price, and the order is refused without it. Read the price from the availability answer. > **Opening the lock leaves the domain exposed** > > The transfer lock stops your domain being moved without permission. Opening it **takes that protection away** for as long as it stays open. Close it again as soon as the move is done, since nobody does it for you. > **The transfer code never comes back in the answer** > > The endpoint asking for a code says **sent** and nothing more; the code goes to the domain owner's e-mail. That is a deliberate safeguard: a stolen API key cannot move a domain in one call. Do not try to show the code in an interface. > **The lock value is not always live** > > The lock read carries a **live flag**. Where the provider offers no reader or the read fails, the last known value comes back and it may not match reality. Check that flag in any flow that decides on the lock. > **A transfer does not end at payment** > > Paying for a transfer order is where the work **begins**: the domain waits on approval at the other end and that can take days. The term is one year either way. Follow it on the transfer status endpoint, and ask rarely since every call reaches the provider. ### Related Articles - [The Domains You Own](https://dev.wisecp.com/en/the-domains-you-own) - [Domain Contacts and Privacy](https://dev.wisecp.com/en/domain-contacts-and-privacy) - [Name Servers and Records](https://dev.wisecp.com/en/name-servers-and-records) ## Name Servers and Records https://dev.wisecp.com/en/name-servers-and-records The fourteen endpoints deciding where a domain points. ### Overview Where a domain points is decided in two layers. The **name servers** say which server answers the questions, and the **records** on that server say what the answer is. That split reaches the endpoints: changing name servers is open on every domain, while managing the records here wants the **name management add-on**. Three more endpoints go further. Running your own name servers means defining them under the domain, and the signature records prove the answers were not changed on the way. ### Reference #### Reading the Name Servers get/api/v1/client/domains/{domain}/nameservers `Domains/GetNameservers` the key's owner Returns a domain's name servers and the account's default set. Response fields data — 3 nameserversstring[]The domain's current set. Four entries at most, and empty slots drop out. defaultstring[]The account's default set. is_customboolWhether the set differs from the default. The comparison ignores case. Errors 2 not_found404No such domain, it is not yours, or access to it is restricted. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/nameservers`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.is_custom) showResetToDefault(data.default); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This endpoint wants NO ADD-ON: changing name servers is open on every domain, unlike the records. $n = Kernel::internal('client:Domains/GetNameservers', ['owner_id' => $uid, 'domain' => $domain])['data']; $set = $n['nameservers']; ``` #### Changing the Name Servers put/api/v1/client/domains/{domain}/nameservers `Domains/UpdateNameservers` it moves the site Changes a domain's name servers at the provider. Body 1 nameserversstring[]reqThe new set. Two to four valid host names, and empty entries drop before the count. Response fields data — 1 nameserversstring[]The set the provider accepted. It comes back in lower case. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. nameservers_invalid422Fewer than two or more than four entries, or one is not a valid host name. nameservers_not_supported422The provider module cannot write them. nameservers_rejected422A hook refused the change. nameservers_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"nameservers":["ns1.example.net","ns2.example.net"]}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/nameservers`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ nameservers: list }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['nameservers' => $list]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing the name servers MOVES THE SITE: the records on the old ones stop counting. // Confirm the records are ready on the new servers first. Kernel::internal('client:Domains/UpdateNameservers', ['owner_id' => $uid, 'domain' => $domain, 'nameservers' => $list]); ``` #### Reading the Default Name Servers get/api/v1/client/domains/default-nameservers `Domains/GetDefaultNameservers` account-wide Returns the default set the account applies to new domains. Response fields data — 1 nameserversstring[]The default set saved. An empty list comes where none was saved. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/default-nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/default-nameservers', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (! data.nameservers.length) promptToSetDefaults(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/default-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The default set applies to NEW registrations and never reaches back to the domains you hold. $d = Kernel::internal('client:Domains/GetDefaultNameservers', ['owner_id' => $uid])['data']; ``` #### Saving the Default Name Servers put/api/v1/client/domains/default-nameservers `Domains/UpdateDefaultNameservers` account-wide Saves the default set for new domains. Body 1 nameserversstring[]reqThe default set. Two to four valid host names, trimmed and lowered to lower case. Response fields data — 1 nameserversstring[]The set saved. Errors 2 nameservers_invalid422The entry count or form is invalid. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/default-nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"nameservers":["ns1.example.net","ns2.example.net"]}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/default-nameservers', { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ nameservers: list }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/default-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['nameservers' => $list]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This setting touches NO domain: it is the starting value for the registrations that follow. Kernel::internal('client:Domains/UpdateDefaultNameservers', ['owner_id' => $uid, 'nameservers' => $list]); ``` #### Listing Your Own Name Servers get/api/v1/client/domains/{domain}/child-nameservers `Domains/GetChildNameservers` it reads from the provider Returns the name servers defined under the domain. Response fields data[] — 2 nsstringThe server's full name. It usually sits under the domain itself. ipstringThe address on record. It comes empty where the provider reports none. Errors 4 not_found404No such domain, it is not yours, or access to it is restricted. child_ns_not_supported422The provider module lacks the support. child_ns_failed500The live read failed with no record to fall back to. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/child-nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/child-nameservers`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // These records are for running YOUR OWN name servers; an ordinary domain wants none. $g = Kernel::internal('client:Domains/GetChildNameservers', ['owner_id' => $uid, 'domain' => $domain])['data']; ``` #### Defining Your Own Name Server post/api/v1/client/domains/{domain}/child-nameservers `Domains/CreateChildNameserver` it writes to the registry Defines a name server under the domain. Body 2 hoststringreqThe server's full name. It is lowered to lower case. ipstringreqThe server's address. Its form is checked. Response fields data[] — 2 dataarrayThe list read afresh. Same shape as the listing endpoint. Errors 8 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. child_ns_fields422The name or the address was not sent. child_ns_host_invalid422The name is not a valid host name. child_ns_ip_invalid422The address is not valid. child_ns_not_supported422The provider module lacks the support. child_ns_rejected422A hook refused the change. child_ns_failed422The provider refused the record. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/child-nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"host":"ns1.example.com","ip":"203.0.113.10"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/child-nameservers`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ host, ip }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(compact('host', 'ip')), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Defining one is not enough: write the name server set too to POINT the domain at it. Kernel::internal('client:Domains/CreateChildNameserver', ['owner_id' => $uid, 'domain' => $domain, 'host' => $host, 'ip' => $ip]); Kernel::internal('client:Domains/UpdateNameservers', ['owner_id' => $uid, 'domain' => $domain, 'nameservers' => [$host, $host2]]); ``` #### Removing Your Own Name Server delete/api/v1/client/domains/{domain}/child-nameservers `Domains/DeleteChildNameserver` it writes to the registry Removes a name server defined under the domain. Body 2 hoststringreqThe name of the server to remove. ipstringThe address. Send it where the provider keys on the name and the address together. Response fields data[] — 2 dataarrayThe list read afresh. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. child_ns_fields422The server name was not sent. child_ns_not_supported422The provider module lacks the support. child_ns_rejected422A hook refused the removal. child_ns_failed422The provider refused the removal. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/domains/example.com/child-nameservers' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"host":"ns1.example.com"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/child-nameservers`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ host }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/child-nameservers'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['host' => $host]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Removing a server that is STILL IN USE can leave other domains unreachable. Kernel::internal('client:Domains/DeleteChildNameserver', ['owner_id' => $uid, 'domain' => $domain, 'host' => $host]); ``` #### Listing the DNS Records get/api/v1/client/domains/{domain}/dns-records `Domains/GetDnsRecords` an add-on is needed Reads a domain's DNS records from the provider. Response fields data[] — 6 identitystringThe record id on the provider's side. Some providers know a record by its type, name and value, and it comes empty there. typestringThe record type. namestringThe label the record answers for. valuestringWhat the record holds. ttlintThe lifetime in seconds. It comes zero where the provider reports none. prioritystringThe priority on mail and service records. It comes empty on other types. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The name management add-on was not bought. An unpaid invoice or an extension that does not offer it fall at the same gate. addon_pending422The add-on invoice is unpaid. Its id comes in the answer's detail. dns_not_supported422The provider module cannot read records. dns_rejected422A hook refused the read. dns_failed500The provider read failed. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/dns-records' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dns-records`, { headers: { Authorization: `Bearer ${clientKey}` }, }); if (res.status === 422) return offerAddon(await res.json()); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The record id can come EMPTY: that provider knows a record by its type, name and value. $rows = Kernel::internal('client:Domains/GetDnsRecords', ['owner_id' => $uid, 'domain' => $domain])['data']; $keyed = (bool) ($rows[0]['identity'] ?? ''); ``` #### Adding a DNS Record post/api/v1/client/domains/{domain}/dns-records `Domains/CreateDnsRecord` an add-on is needed Adds a new DNS record to the domain. Body 5 typestringreqThe record type. namestringreqThe label it answers for. The form accepted follows the provider. valuestringreqWhat the record holds. ttlintThe lifetime. The provider's default stands in when left out. priorityintThe priority. It is ignored outside mail and service records. Response fields data[] — 6 dataarrayThe list read afresh after the add. Same shape as the listing endpoint. Errors 7 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The name management add-on was not bought. An unpaid invoice or an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. dns_fields422The type, the name or the value is missing. dns_not_supported422The provider module cannot add records. dns_rejected422A hook refused the add. dns_failed422The provider refused the record. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/dns-records' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"type":"A","name":"www","value":"203.0.113.10","ttl":3600}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dns-records`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ type: 'A', name: 'www', value: ip }), }); const { data } = await res.json(); renderRecords(data); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($record), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The answer returns THE FRESH LIST: refresh the screen from it rather than reading again. $rows = Kernel::internal('client:Domains/CreateDnsRecord', ['owner_id' => $uid, 'domain' => $domain] + $record)['data']; ``` #### Editing a DNS Record put/api/v1/client/domains/{domain}/dns-records `Domains/UpdateDnsRecord` it keys on the id Changes a DNS record that exists. Body 6 identitystringThe record id from the listing. Always send it where the listing gave one. typestringreqThe record type. namestringreqThe record label. valuestringreqThe new content. ttlintThe lifetime. priorityintThe priority. Response fields data[] — 6 dataarrayThe list read afresh after the edit. Errors 7 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The name management add-on was not bought. An unpaid invoice or an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. dns_fields422The type, the name or the value is missing. dns_edit_not_supported422The provider module cannot edit. Remove and add again on such a provider. dns_rejected422A hook refused the edit. dns_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/dns-records' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"identity":"42","type":"A","name":"www","value":"203.0.113.20"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dns-records`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ identity: rec.identity, ...next }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($record), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Editing is ABSENT on some providers: remove and add again where the capability list closes it. $d = Kernel::internal('client:Domains/GetDomain', ['owner_id' => $uid, 'domain' => $domain])['data']; if (! $d['capabilities']['dns_edit']) { /* delete + create */ } ``` #### Removing a DNS Record delete/api/v1/client/domains/{domain}/dns-records `Domains/DeleteDnsRecord` the match narrows Removes a DNS record. Body 4 typestringreqThe type of the record to remove. namestringThe record label. It narrows the match. valuestringThe record content. It narrows the match. identitystringThe record id from the listing. It is the surest selector. Response fields data[] — 6 dataarrayThe list read afresh after the removal. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The name management add-on was not bought. An unpaid invoice or an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. dns_fields422The record type was not sent. dns_rejected422A hook refused the removal. dns_failed422The provider refused the removal. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/domains/example.com/dns-records' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"identity":"42","type":"A","name":"www"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dns-records`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ identity: rec.identity, type: rec.type, name: rec.name }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dns-records'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($selector), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Sending the TYPE ALONE can remove MORE THAN ONE record of it: add the id, or the name and value. Kernel::internal('client:Domains/DeleteDnsRecord', ['owner_id' => $uid, 'domain' => $domain, 'identity' => $rec['identity'], 'type' => $rec['type'], 'name' => $rec['name'], 'value' => $rec['value']]); ``` #### Listing the Signature Records get/api/v1/client/domains/{domain}/dnssec `Domains/GetDnssecRecords` it reads from the provider Returns a domain's signature verification records. Response fields data[] — 5 identitystringThe row id on the provider's side. digeststringThe signature digest. key_tagintThe tag of the key the digest points at. digest_typeintThe digest algorithm number. The set allowed comes from the provider module. algorithmintThe key algorithm number. Errors 4 not_found404No such domain, it is not yours, or access to it is restricted. dnssec_not_supported422The provider module lacks signature support. dnssec_failed500The provider read failed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/dnssec' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dnssec`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const signed = data.length > 0; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dnssec'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // These want NO ADD-ON and do want provider support; confirm it from the capability list. $ds = Kernel::internal('client:Domains/GetDnssecRecords', ['owner_id' => $uid, 'domain' => $domain])['data']; ``` #### Adding a Signature Record post/api/v1/client/domains/{domain}/dnssec `Domains/CreateDnssecRecord` the algorithm is checked Adds a signature verification record to the domain. Body 4 key_tagintreqThe key tag. algorithmintreqThe key algorithm number. It has to sit in the set the module reports. digest_typeintreqThe digest algorithm number. It has to sit in the set the module reports. digeststringreqThe signature digest. Response fields data[] — 5 dataarrayThe set read afresh. Errors 8 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. dnssec_fields422One of the required fields is missing. dnssec_not_supported422The provider module cannot add. dnssec_digest_type_invalid422The digest algorithm is outside the set allowed. The numbers allowed come in the answer's detail. dnssec_algorithm_invalid422The key algorithm is outside the set allowed. dnssec_rejected422A hook refused the add. dnssec_failed422The provider refused the record. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/dnssec' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"key_tag":12345,"algorithm":13,"digest_type":2,"digest":"A1B2C3"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dnssec`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(ds), }); if (res.status === 422) showAllowed(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dnssec'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($ds), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The set of algorithms allowed follows THE PROVIDER: read the refusal and show the allowed list. try { Kernel::internal('client:Domains/CreateDnssecRecord', ['owner_id' => $uid, 'domain' => $domain] + $ds); } catch (\Throwable $e) { $allowed = $e->details['allowed'] ?? []; } ``` #### Removing a Signature Record delete/api/v1/client/domains/{domain}/dnssec `Domains/DeleteDnssecRecord` it can break the site Removes a signature verification record. Body 5 digeststringreqThe digest of the record to remove. key_tagintreqThe record's key tag. digest_typeintIt narrows the match. algorithmintIt narrows the match. identitystringThe row id from the listing. It is the surest selector. Response fields data[] — 5 dataarrayThe set read afresh after the removal. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. dnssec_fields422The digest or the key tag is missing. dnssec_not_supported422The provider module cannot remove. dnssec_rejected422A hook refused the removal. dnssec_failed422The provider refused the removal. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/domains/example.com/dnssec' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"key_tag":12345,"digest":"A1B2C3"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/dnssec`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ key_tag: ds.key_tag, digest: ds.digest }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/dnssec'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($selector), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Unsign the zone BEFORE removing the last record: the wrong order leaves the domain unresolvable. $ds = Kernel::internal('client:Domains/GetDnssecRecords', ['owner_id' => $uid, 'domain' => $domain])['data']; $isLast = count($ds) === 1; ``` ### Pitfalls > **Records want the add-on and name servers do not** > > The DNS record endpoints sit behind the **name management add-on**. Unbought, or with its invoice unpaid, they all refuse. The answer says which case it is. The name server endpoints pass no such gate and work on every domain. > **Changing name servers leaves the records behind** > > Changing the name server set sends the domain's questions to **a different server**. The records you entered here stay on the old one and nobody asks them any more. Do not change the set before confirming the records are ready on the new servers. > **The record id does not come from every provider** > > Some providers key a record by an id and others know it by its type, name and value. In the second case the id comes **empty**. Always send the id on an edit or a removal where one exists. Give the full triple where none does, or the wrong record can be picked. > **Sending the type alone can remove several** > > Only the record type is required on a removal, while the name and the value **narrow the match**. Sending the type alone can take several records of that type at once. Add the name and the value even where you hold no id. > **Removing a signature in the wrong order breaks the domain** > > While signature verification is on, the record at the registry and the signature in the zone **have to agree**. Removing the last record while the zone is still signed, or unsigning the zone while the record stands, leaves the domain unresolvable. Unsign the zone first and remove the record after. > **Your own name server wants two steps** > > Defining a name server under the domain only **brings it into being**. The domain uses it once you write the name server set as well. Skipping that second step leaves the definition at the registry with nothing changed. ### Related Articles - [The Domains You Own](https://dev.wisecp.com/en/the-domains-you-own) - [Setting Up Forwarding](https://dev.wisecp.com/en/setting-up-forwarding) - [Domain Contacts and Privacy](https://dev.wisecp.com/en/domain-contacts-and-privacy) ## Domain Contacts and Privacy https://dev.wisecp.com/en/domain-contacts-and-privacy The nine endpoints governing who a domain is registered to and who sees it. ### Overview Every domain carries contact details in **four roles**: the registrant, the administrative, the technical and the billing one. They sit at the registry and on most extensions a public lookup shows them. **Contact profiles** are kept at the account level to save writing the same details onto every domain. A profile applies to all four roles at once, and the default one is used by itself on new registrations. The privacy setting stops those details showing in a public lookup. It is free on some extensions and wants an **add-on** on others. ### Reference #### Reading the Domain Contacts get/api/v1/client/domains/{domain}/whois `Domains/GetWhoisContacts` it reads from the provider Returns the contact details in a domain's four roles. Response fields data — 4 registrantobject registrantThe registrant. This is the domain's legal holder. first_namestringThe first name. last_namestringThe last name. namestringThe full name shown. It is built from the two where none is stored. companystringThe organisation. emailstringThe e-mail. phonestringThe phone. addressstringThe address. citystringThe city. statestringThe state. zipstringThe postcode. countrystringThe country code. administrativeobjectThe administrative contact. It carries the same fields. technicalobjectThe technical contact. billingobjectThe billing contact. Errors 4 not_found404No such domain, it is not yours, or access to it is restricted. whois_not_supported422The provider module could not be set up. whois_rejected422A hook refused the read. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/whois' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/whois`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); renderContact(data.registrant); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/whois'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // All four roles ALWAYS come back; where the provider keeps one contact, all four carry it. $w = Kernel::internal('client:Domains/GetWhoisContacts', ['owner_id' => $uid, 'domain' => $domain])['data']; $same = $w['registrant']['email'] === $w['technical']['email']; ``` #### Writing the Domain Contacts put/api/v1/client/domains/{domain}/whois `Domains/UpdateWhoisContacts` it writes to the registry Writes one contact into a chosen role or into all four. Body 2 rolestringWhich roles to write into. All four are written when it is left out. contactobjectreq contactThe contact to write. first_namestringreqThe person's first name. last_namestringreqThe person's last name. companystringThe organisation. It is the one optional field. emailstringreqA valid e-mail. phonestringreqThe phone. addressstringreqThe address line. citystringreqThe city. statestringThe state or province. Required on a domain contact and not on a saved profile. zipstringreqThe postcode. countrystringreqThe country code. It is raised to upper case. Response fields data — 4 dataobjectThe four roles as they now stand. Same shape as the read endpoint. Errors 9 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. role_invalid422The role is none of the five values. contact_name_required422The first or last name is empty. contact_email_invalid422The e-mail is empty or invalid. contact_field_required422Another required field is empty. Which one comes in the answer's detail, in the provider's own key form. whois_not_supported422The provider module cannot write contacts. whois_rejected422A hook refused the change. whois_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/whois' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"role":"technical","contact":{"first_name":"Jane","last_name":"Cooper","email":"jane@example.com","phone":"+15551112233","address":"Sample St 42","city":"Austin","state":"TX","zip":"73301","country":"US"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/whois`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ role: 'technical', contact }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/whois'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['role' => 'technical', 'contact' => $contact]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Changing THE REGISTRANT counts as a change of holder on some extensions and locks the domain. // Name the role plainly to change one; the default writes into ALL FOUR. Kernel::internal('client:Domains/UpdateWhoisContacts', ['owner_id' => $uid, 'domain' => $domain, 'role' => 'technical', 'contact' => $contact]); ``` #### Applying a Saved Profile post/api/v1/client/domains/{domain}/whois/apply `Domains/ApplyWhoisProfile` it fills all four roles Writes a saved contact profile into all four roles of the domain. Body 1 profile_idintreqThe id of the profile to apply. Response fields data — 4 dataobjectThe four roles as they now stand. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. not_found404The profile was not found or belongs to another account. not_actionable422The domain is not live. whois_not_supported422The provider module cannot write contacts. whois_rejected422A hook refused the change. whois_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/whois/apply' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"profile_id":7}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/whois/apply`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ profile_id: profileId }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/whois/apply'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['profile_id' => $pid]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This writes over ALL FOUR roles: use the write endpoint with a role to change the technical one alone. Kernel::internal('client:Domains/ApplyWhoisProfile', ['owner_id' => $uid, 'domain' => $domain, 'profile_id' => $pid]); ``` #### Changing the Registration Privacy put/api/v1/client/domains/{domain}/whois-privacy `Domains/UpdateWhoisPrivacy` it can be paid for Opens or closes the hiding of contact details in a public lookup. Body 1 enabledboolreqWhether privacy is on. It has to be a true boolean. Response fields data — 1 whois_privacyboolThe new state the provider confirmed. Errors 7 not_found404No such domain, it is not yours, or access to it is restricted. not_actionable422The domain is not live. enabled_invalid422The value is not a boolean. privacy_not_supported422The provider module lacks privacy support. addon_required422Privacy is paid for on this extension and the add-on was not bought. privacy_rejected422A hook refused the change. privacy_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/whois-privacy' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/whois-privacy`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true }), }); if (res.status === 422) offerAddon(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/whois-privacy'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Privacy is FREE on some extensions and an ADD-ON on others: read the refusal and offer the purchase. $d = Kernel::internal('client:Domains/GetDomain', ['owner_id' => $uid, 'domain' => $domain])['data']; $state = $d['addons']['whois_privacy']['state']; ``` #### Listing the Saved Profiles get/api/v1/client/domains/whois-profiles `Domains/GetWhoisProfiles` account-wide Returns the account's saved contact profiles. Response fields data[] — 4 idintThe profile id. namestringThe profile label. is_defaultboolWhether it is the account default. One profile at most carries it. contactobjectThe contact saved. The same fields as a domain contact, without the built full name, and the state can be empty. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/whois-profiles' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/whois-profiles', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const fallback = data.find((p) => p.is_default); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/whois-profiles'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The default profile applies by itself on NEW registrations and never touches the domains you hold. $rows = Kernel::internal('client:Domains/GetWhoisProfiles', ['owner_id' => $uid])['data']; $def = array_values(array_filter($rows, fn ($p) => $p['is_default']))[0] ?? null; ``` #### Creating a Profile post/api/v1/client/domains/whois-profiles `Domains/CreateWhoisProfile` account-wide Saves a contact profile you can reuse. Body 2 namestringreqThe profile label. contactobjectreq contactThe contact to save. The state is not required here. first_namestringreqThe person's first name. last_namestringreqThe person's last name. companystringThe organisation. It is the one optional field. emailstringreqA valid e-mail. phonestringreqThe phone. addressstringreqThe address line. citystringreqThe city. statestringThe state or province. Required on a domain contact and not on a saved profile. zipstringreqThe postcode. countrystringreqThe country code. It is raised to upper case. Response fields data[] — 4 dataobject[]The profile list as it now stands. Same shape as the listing endpoint. Errors 6 name_required422The label is empty. contact_name_required422The first or last name is empty. contact_email_invalid422The e-mail is empty or invalid. contact_field_required422Another required field is empty. The state is not required here. profile_rejected422A hook refused the save. profile_failed500The profile record could not be made. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/whois-profiles' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Company","contact":{"first_name":"Jane","last_name":"Cooper","email":"jane@example.com","phone":"+15551112233","address":"Sample St 42","city":"Austin","zip":"73301","country":"US"}}' ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/domains/whois-profiles', { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name, contact }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/whois-profiles'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(compact('name', 'contact')), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The STATE is not required on a profile and CAN BE on a domain; filling it in is the safe road. Kernel::internal('client:Domains/CreateWhoisProfile', ['owner_id' => $uid, 'name' => $name, 'contact' => $contact]); ``` #### Updating a Profile put/api/v1/client/domains/whois-profiles/{pid} `Domains/UpdateWhoisProfile` the whole contact Rewrites a saved profile. Body 2 namestringreqThe new label. contactobjectreq contactThe whole contact. The saved one is replaced entirely. first_namestringreqThe person's first name. last_namestringreqThe person's last name. companystringThe organisation. It is the one optional field. emailstringreqA valid e-mail. phonestringreqThe phone. addressstringreqThe address line. citystringreqThe city. statestringThe state or province. Required on a domain contact and not on a saved profile. zipstringreqThe postcode. countrystringreqThe country code. It is raised to upper case. Response fields data[] — 4 dataobject[]The profile list as it now stands. Same shape as the listing endpoint. Errors 6 not_found404No such profile, or it belongs to another account. name_required422The label is empty. contact_name_required422The first or last name is empty. contact_email_invalid422The e-mail is empty or invalid. contact_field_required422Another required field is empty. profile_rejected422A hook refused the save. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/whois-profiles/7' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"Company","contact":{"first_name":"Jane","last_name":"Cooper","email":"new@example.com","phone":"+15551112233","address":"New St 7","city":"Austin","zip":"73301","country":"US"}}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/whois-profiles/${pid}`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name, contact }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/whois-profiles/' . $pid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(compact('name', 'contact')), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Updating a profile DOES NOT reach the domains USING it: apply it again to each of them. Kernel::internal('client:Domains/UpdateWhoisProfile', ['owner_id' => $uid, 'pid' => $pid, 'name' => $name, 'contact' => $contact]); foreach ($domains as $d) Kernel::internal('client:Domains/ApplyWhoisProfile', ['owner_id' => $uid, 'domain' => $d, 'profile_id' => $pid]); ``` #### Removing a Profile delete/api/v1/client/domains/whois-profiles/{pid} `Domains/DeleteWhoisProfile` account-wide Removes a saved contact profile. Response fields data[] — 4 dataobject[]The profile list as it now stands. Deleting the default promotes the one left at the top. Errors 2 not_found404No such profile, or it belongs to another account. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/domains/whois-profiles/7' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/whois-profiles/${pid}`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/whois-profiles/' . $pid); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Removing a profile DOES NOT change the contacts on the domains using it: the registry keeps them. Kernel::internal('client:Domains/DeleteWhoisProfile', ['owner_id' => $uid, 'pid' => $pid]); ``` #### Making a Profile the Default post/api/v1/client/domains/whois-profiles/{pid}/default `Domains/SetDefaultWhoisProfile` account-wide Sets the profile applied by itself on new registrations. Body — ——No body is needed; send an empty one. The profile comes from the path, and there are no query parameters either. Response fields data[] — 4 dataobject[]The profile list as it now stands, the default first. Same shape as the listing endpoint. Errors 2 not_found404No such profile, or it belongs to another account. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/whois-profiles/7/default' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/whois-profiles/${pid}/default`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/whois-profiles/' . $pid . '/default'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The default is used on new registrations and transfers ALONE; it never reaches the domains you hold. Kernel::internal('client:Domains/SetDefaultWhoisProfile', ['owner_id' => $uid, 'pid' => $pid]); ``` ### Pitfalls > **With no role named all four are written** > > The role field on the contact write is optional, and leaving it out writes the contact into **all four roles**. Forgetting it while meaning to change the technical contact changes the registrant as well. The profile apply endpoint always writes all four. > **Changing the registrant can count as a transfer of holder** > > The registrant is the domain's **legal holder**. On some extensions changing those details counts as a transfer of holder: the registry sends a confirmation e-mail and closes the domain to transfer for a while. That can follow even when you are only fixing a typo. > **Updating a profile does not update the domains** > > A saved profile is a **template**: applying it copies the details onto the domain there and then. Editing it later does not reach those domains, and removing it takes nothing off the registry. Apply it again to each domain to spread a change. > **The state field behaves differently in two places** > > The state field is **not required** on a saved profile and is on a domain contact. Applying a profile saved without it can fail where the provider wants it. Save your profiles with the state filled in. > **Privacy is not free on every extension** > > Privacy is free on some extensions and opens straight away, while others want an **add-on** bought first and refuse the call without it. Read which case you are in from the add-on block in the domain detail. > **The default profile works on new registrations alone** > > Making a profile the default applies it by itself on the registrations and transfers **that follow**. It touches none of the domains you hold. Apply it to each of them separately to bring the existing ones into line. ### Related Articles - [The Domains You Own](https://dev.wisecp.com/en/the-domains-you-own) - [Taking a Domain](https://dev.wisecp.com/en/taking-a-domain) - [Name Servers and Records](https://dev.wisecp.com/en/name-servers-and-records) ## Setting Up Forwarding https://dev.wisecp.com/en/setting-up-forwarding The six endpoints carrying what arrives at a domain somewhere else. ### Overview Forwarding is **carrying what arrives** at a domain somewhere else. It comes in two kinds and both sit behind the same add-on: mail forwarding carries the post, and address forwarding carries the visitor. The two differ in number: mail forwarding is **a list** with a rule per local part, while an address forward is **one** per domain. Forwarding is not hosting. No mail is stored and no page is served; what arrives is handed on to another address and nothing more. ### Reference #### Listing the Mail Forwards get/api/v1/client/domains/{domain}/email-forwards `Domains/GetEmailForwards` an add-on is needed Returns the mail forwards defined on the domain. Response fields data[] — 4 identitystringThe record id on the provider's side. A steady value is worked out from the source and target where the provider gives none. prefixstringThe local part on the domain. It sits left of the sign. sourcestringThe full source address. It is always the local part joined to the domain. targetstringThe box the mail goes to. Errors 5 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The forwarding add-on was not bought. An unpaid invoice and an extension that does not offer it fall at the same gate. addon_pending422The add-on invoice is unpaid. Its id comes in the answer's detail. email_forwarding_not_supported422The provider module lacks mail forwarding. email_forwarding_failed500The live read failed. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/email-forwards' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/email-forwards`, { headers: { Authorization: `Bearer ${clientKey}` }, }); if (res.status === 422) return offerAddon(await res.json()); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A forward is NOT A MAILBOX: the mail arriving passes to another address and is kept nowhere here. $rows = Kernel::internal('client:Domains/GetEmailForwards', ['owner_id' => $uid, 'domain' => $domain])['data']; ``` #### Adding a Mail Forward post/api/v1/client/domains/{domain}/email-forwards `Domains/CreateEmailForward` an add-on is needed Adds a rule carrying mail for the domain to another box. Body 2 prefixstringreqThe local part on the domain. The forward answers for that part joined to the domain. targetstringreqThe target box. It has to be a valid e-mail address. Response fields data[] — 4 dataarrayThe list read afresh after the add. Same shape as the listing endpoint. Errors 8 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The forwarding add-on was not bought. An unpaid invoice and an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. email_forward_fields422The local part or the target is missing. email_forward_target_invalid422The target is not a valid e-mail address. email_forwarding_not_supported422The provider module lacks the support. email_forward_rejected422A hook refused the change. email_forwarding_failed422The provider refused the rule. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/domains/example.com/email-forwards' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"prefix":"sales","target":"team@example.net"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/email-forwards`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ prefix, target }), }); const { data } = await res.json(); renderForwards(data); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(compact('prefix', 'target')), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Send THE LOCAL PART alone: writing a full address adds the domain twice and breaks the rule. Kernel::internal('client:Domains/CreateEmailForward', ['owner_id' => $uid, 'domain' => $domain, 'prefix' => 'sales', 'target' => $box]); ``` #### Removing a Mail Forward delete/api/v1/client/domains/{domain}/email-forwards `Domains/DeleteEmailForward` the match narrows Removes a mail forwarding rule. Body 3 prefixstringreqThe local part of the rule to remove. targetstringThe target. Send it where the provider keys on the source and the target together. identitystringThe record id from the listing. Send it where you hold one. Response fields data[] — 4 dataarrayThe list read afresh after the removal. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The forwarding add-on was not bought. An unpaid invoice and an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. email_forward_fields422The local part was not sent. email_forwarding_not_supported422The provider module lacks the support. email_forwarding_failed422The provider refused the removal. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/domains/example.com/email-forwards' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"prefix":"sales","target":"team@example.net"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/email-forwards`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ prefix: f.prefix, target: f.target, identity: f.identity }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/email-forwards'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($selector), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // One local part can reach SEVERAL targets: send the target too, or they can all go. Kernel::internal('client:Domains/DeleteEmailForward', ['owner_id' => $uid, 'domain' => $domain, 'prefix' => $f['prefix'], 'target' => $f['target'], 'identity' => $f['identity']]); ``` #### Reading the Address Forward get/api/v1/client/domains/{domain}/forwarding `Domains/GetUrlForwarding` an add-on is needed Returns whether the domain sends visitors on to another address. Response fields data — 5 activeboolWhether a forward is set up. protocolstringThe target's scheme. methodintThe kind of forward. Permanent or temporary. domainstringThe target without its scheme. It comes empty where none is set up. urlstringThe full target address. Errors 5 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The forwarding add-on was not bought. An unpaid invoice and an extension that does not offer it fall at the same gate. addon_pending422The add-on invoice is unpaid. url_forwarding_not_supported422The provider module lacks address forwarding. url_forwarding_failed500The live read failed. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/domains/example.com/forwarding' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/forwarding`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.active) showTarget(data.url, data.method); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/forwarding'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is ONE forward per domain: setting a new one writes over the old, and no list exists. $f = Kernel::internal('client:Domains/GetUrlForwarding', ['owner_id' => $uid, 'domain' => $domain])['data']; ``` #### Setting Up an Address Forward put/api/v1/client/domains/{domain}/forwarding `Domains/UpdateUrlForwarding` an add-on is needed Sends a visitor arriving at the domain on to another address. Body 2 urlstringreqThe full target address. Its scheme becomes the forward's, and the insecure one stands in when none is written. methodintThe kind of forward. Permanent is the default and another value falls to it. Response fields data — 5 dataobjectThe forward as it now stands. Same shape as the read endpoint. Errors 7 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The forwarding add-on was not bought. An unpaid invoice and an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. url_required422The target was not sent, or nothing is left once the scheme is taken off. url_forwarding_not_supported422The provider module lacks the support. url_forwarding_rejected422A hook refused the change. url_forwarding_failed422The provider refused the change. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PUT 'https://panel.example.com/api/v1/client/domains/example.com/forwarding' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"url":"https://shop.example.net/welcome","method":301}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/forwarding`, { method: 'PUT', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url, method: 302 }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/forwarding'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PUT', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['url' => $target, 'method' => 301]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Browsers CACHE a permanent forward: pick the temporary kind while testing and move to permanent after. Kernel::internal('client:Domains/UpdateUrlForwarding', ['owner_id' => $uid, 'domain' => $domain, 'url' => $target, 'method' => 302]); ``` #### Removing the Address Forward delete/api/v1/client/domains/{domain}/forwarding `Domains/DeleteUrlForwarding` an add-on is needed Removes the domain's address forward. Response fields data — 5 dataobjectThe state read after the removal. Same shape as the read endpoint. Errors 6 not_found404No such domain, it is not yours, or access to it is restricted. addon_required422The forwarding add-on was not bought. An unpaid invoice and an extension that does not offer it fall at the same gate. not_actionable422The domain is not live. url_forwarding_not_supported422The provider module lacks the support. url_forwarding_rejected422A hook refused the removal. url_forwarding_failed422The provider refused the removal. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/domains/example.com/forwarding' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/domains/${domain}/forwarding`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/domains/' . $domain . '/forwarding'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Removing the forward leaves the domain pointing NOWHERE: a visitor lands on nothing. // Think first about what takes its place; a record or a new target is wanted. Kernel::internal('client:Domains/DeleteUrlForwarding', ['owner_id' => $uid, 'domain' => $domain]); ``` ### Pitfalls > **Both kinds rest on one add-on** > > All **six endpoints** of mail and address forwarding sit behind the forwarding add-on. Unbought, or with its invoice unpaid, they all refuse and the answer says which case it is. One purchase opens them both. > **One address forward per domain** > > An address forward has no list: setting one **writes over** what was there. There is no adding a second target, and a second call replaces the first. Mail forwarding is a list and carries as many rules as you like. > **The local part goes, not the full address** > > The source field on a mail forward takes **the local part** alone and the server adds the domain. Writing a full address adds the domain twice and the rule answers no mail. The source field in the answer shows the right joining. > **Removing without a target can take several rules** > > One local part can forward to several targets. Sending it alone on a removal can take **every rule** bound to that part. Add the target, and the record id where you hold one. > **A permanent forward is kept by the browser** > > Picking the permanent kind tells browsers and search engines the address **moved for good**, and they keep that for a long while. A permanent forward to a wrong target sends visitors there for a time even after the fix. Use the temporary kind while testing. > **A forward is not a mailbox** > > A mail forward **passes the post to another box** and keeps no copy. Where the target box is full or refuses the mail, the message is lost with no trace here. A customer wanting a real box should take a mail service. ### Related Articles - [Name Servers and Records](https://dev.wisecp.com/en/name-servers-and-records) - [The Domains You Own](https://dev.wisecp.com/en/the-domains-you-own) # API / Client API / Products ## What Is on Sale https://dev.wisecp.com/en/what-is-on-sale The three endpoints giving the products on sale, their categories and the order schema. ### Overview These three endpoints show what a customer **can buy**. What the shop window holds is what appears here: a product that is closed, hidden or in a shut group never shows at all. The prices come in the account's **wallet currency**. The figure listed can be compared with the balance straight away, and ordering brings no surprise. The detail endpoint gives more than a catalogue record: it is the **schema** of the order body. The cycles, add-ons, questions and the domain axis are read from it. ### Reference #### Listing the Products get/api/v1/client/products `Products/GetProducts` the wallet currency Returns the products that can be ordered, in shop-window order. Query 5 pageintWhich page. limitintRows per page. 100 at the most. typestringOne product type. An unknown type is refused and the valid list comes in the answer's detail. categoryintThe category number. It matches the main category and the extra assignments alike. searchstringSearches the product title. Response fields data[] — 8 + meta — 5 idintThe product id. typestringThe product type. titlestringThe product name. taglinestringA short line about it. categoryobjectThe main category. It carries a number and a name, and comes empty on a product with none. in_stockboolWhether it can be ordered. It closes only with stock tracking on and the counter at zero. stockintThe stock left. Empty means it is not tracked. pricingobject[]One price row per billing cycle. totalintHow many products there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. currencystringThe currency every price is in. It is the account's wallet currency. Errors 2 type_invalid422The product type is unknown or that group is closed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/products?type=hosting' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/products?type=hosting', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data, meta } = await res.json(); renderPrices(data, meta.currency); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/products?type=hosting'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The prices come in the WALLET currency: compare them with the balance straight and convert nothing. $r = Kernel::internal('client:Products/GetProducts', ['owner_id' => $uid, 'type' => 'hosting']); $cur = $r['meta']['currency']; ``` #### Listing the Categories get/api/v1/client/products/categories `Products/GetProductCategories` no paging Returns the product categories on show as one flat list. Query 1 typestringThe categories of this type alone. An unknown value gives an empty list rather than an error. Response fields data[] — 4 + meta — 1 idintThe category number. This is what goes into the product listing's category filter. parentintThe parent category. Empty means it sits at the top. typestringThe category's product type. namestringThe category name. totalintHow many categories there are. It comes back under meta. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/products/categories' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/products/categories', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const roots = data.filter((c) => c.parent === null); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/products/categories'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The list is FLAT: build the tree from the parent field yourself, since the endpoint gives none. $rows = Kernel::internal('client:Products/GetProductCategories', ['owner_id' => $uid])['data']; $tree = []; foreach ($rows as $c) $tree[$c['parent'] ?? 0][] = $c; ``` #### The Product and Its Order Schema get/api/v1/client/products/{id} `Products/GetProduct` the order input Returns a product with everything needed to build an order body. Response fields data — 14 idintThe product id. typestringThe product type. titlestringThe product name. taglinestringA short line about it. descriptionstringThe full description. It comes with the markup the shop shows. categoryobjectThe main category. in_stockboolWhether it can be ordered. stockintThe stock left. quantityobjectThe quantity rule. It carries a permission and a mode: one unit, each unit its own service, or a quantity scaling inside one service. order_limit_per_userintHow many services one account may hold. Empty means there is no limit. pricingobject[]One price row per billing cycle. addonsobject[]The add-ons an order can pick. Each carries an id, a name, a description, an input type, whether it is required, its choices and its own questions. requirementsobject[]The fields an order has to fill. Each carries an id, a name, an input type and whether it is required; a choice type adds its options and a file type adds the extensions allowed and any size limit. metricsobject[]The meters charged by use. Each carries an id, a type, a label, a unit, a charging scheme, what is included, a ceiling and the tiered prices. domainobjectThe domain axis. By its mode it carries a chooser list, subdomain roots or the licence fields. Errors 2 not_found404The product is absent, closed, hidden or its group is shut. The four are deliberately not told apart. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/products/36' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/products/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const must = data.requirements.filter((r) => r.required); const cycles = data.pricing.map((p) => p.cycle); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/products/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A NEW domain cannot be ordered here: registering and transferring happen on the Domains endpoints. $pr = Kernel::internal('client:Products/GetProduct', ['owner_id' => $uid, 'id' => $id])['data']; $viaDomains = ($pr['domain']['register_transfer'] ?? '') === 'via_domains_endpoints'; ``` ### Pitfalls > **A new domain is not ordered from here** > > The domain axis in the product detail covers using a domain you **already own** or picking a free subdomain. Registering a new domain or transferring one belongs to the domain endpoints. Mixing them up sends you looking for an order field that does not exist. > **The price listed is the promotional one** > > Where a cycle carries a promotion the price field holds the **promotional amount** and the promotion flag comes back true. Applying a discount on top of the listing takes it down twice. This is also what the order engine charges, so the listing and the basket never disagree. > **A product not found can mean four things** > > The detail endpoint meets an unknown, closed, hidden and group-shut product with the **same answer**. That is deliberate: the answer never leaks whether the product exists. When one is missing from the listing the API will not say why, and the operator has to be asked. > **The quantity mode means three different orders** > > The quantity rule sits in one of three modes: one unit per order, **a separate service** for each unit, or a quantity scaling inside one service. Ordering ten in the second mode brings ten services into being, and in the third one service at ten times the price. Read the mode before building the interface. > **The category list is flat and not a tree** > > The categories come back flat and the hierarchy is built from the parent field. There is no paging either: the whole set comes every time. A chosen category goes into the product listing as a **filter** and does not take its children in by itself. ### Related Articles - [Ordering as the Client](https://dev.wisecp.com/en/ordering-as-the-client) - [Taking a Domain](https://dev.wisecp.com/en/taking-a-domain) - [The Account Balance](https://dev.wisecp.com/en/the-account-balance) ## The Services You Own https://dev.wisecp.com/en/the-services-you-own The eleven endpoints that read, set, renew and end a service you own. ### Overview A service is the thing a customer **owns**: a hosting package, a server, a software licence. These eleven endpoints read that record, change its settings, renew it and ask for it to end. Renewal runs in two steps: these endpoints **raise an invoice** and payment happens on the invoice endpoints. Automatic renewal, meanwhile, is governed by two switches, one at the account level and one on the service. Some products are charged by use as well. A meter can be switched, and an unpaid usage invoice **locks it shut**. ### Reference #### Listing the Services get/api/v1/client/services `Services/GetServices` the key's owner Returns the services the account owns. Query 5 pageintWhich page. limitintRows per page. 100 at the most. statusstringThe state filter. It takes badge groups: pending covers both waiting and in process, and cancelled takes completed in too. typestringThe product type filter. searchstringSearches the service name and the domain tied to it. Response fields data[] — 10 + meta — 4 idintThe service id. typestringThe product type. namestringThe service name. It is a copy of the product name at order time. statusstringThe raw state. hoststringThe domain or host name tied to it. product_idintThe id of the product ordered. auto_renewboolWhether automatic renewal is on for this service. priceobjectThe amount per period. cyclestringThe billing cycle. created_atstringThe day it was set up. due_datestringThe day the next payment falls. totalintHow many services there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 1 insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services?status=active' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch('https://panel.example.com/api/v1/client/services?status=active', { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const soon = data.filter((s) => s.due_date && s.due_date < horizon); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services?status=active'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The filter takes a BADGE GROUP rather than a raw state: pending covers two raw states at once. $rows = Kernel::internal('client:Services/GetServices', ['owner_id' => $uid, 'status' => 'pending'])['data']; ``` #### Reading a Service get/api/v1/client/services/{id} `Services/GetService` the key's owner Returns a service's settings, access details and where renewal stands. Response fields data — 16 idintThe service id. typestringThe product type. namestringThe service name. statusstringThe raw state. productobjectThe product plan. It carries an id and a name. hoststringThe domain or host name tied to it. order_idintThe order that brought it into being. managedboolWhether a panel module runs it. True means the tools and sign-in endpoints work. auto_renewboolAutomatic renewal for this service. auto_renew_lockedboolWhether account-wide automatic payment is on. The per-service switch cannot move while it is. billing_profile_idintThe billing profile assigned. Zero means the account's default address. priceobjectThe amount per period. cyclestringThe billing cycle. accessobjectThe access details as they stood at order time. Its keys follow the service type and an empty value never appears. renewalobject renewalWhere renewal stands. availableboolWhether a renewal invoice can be raised. blocked_by_subscriptionboolWhether a subscription collects the renewals itself. already_invoicedboolWhether the period is already invoiced. open_invoice_idintThe open invoice it would attach to. cyclesarrayThe renewal cycles that can be picked. cancellationobjectAn open cancellation request. It carries a state, an urgency, a reason, a note and when it was asked. has_metricsboolWhether a meter charged by use is defined. Errors 2 not_found404No such service, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.renewal.available) showRenewButton(data.renewal.cycles); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A DOMAIN is NOT read here: domains have endpoints of their own and answer not found on this one. $s = Kernel::internal('client:Services/GetService', ['owner_id' => $uid, 'id' => $id])['data']; $panel = $s['managed']; ``` #### Changing the Service Preferences patch/api/v1/client/services/{id} `Services/UpdateService` two fields Changes automatic renewal and the billing profile. Body 2 auto_renewboolAutomatic renewal for this service. It cannot move while account-wide automatic payment is on. billing_profile_idintThe billing profile id. Zero returns to the account default. Response fields data — 16 dataobjectThe service as it now stands. Same shape as the read endpoint. Errors 6 not_found404No such service, or it belongs to another customer. nothing_to_update422The body holds no field that is known. not_actionable422Automatic renewal was sent while the service is not live. autorenew_locked422Account-wide automatic payment is on. no_auto_pay_source422Turning it on was tried with no source to charge. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PATCH 'https://panel.example.com/api/v1/client/services/622' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"auto_renew":true,"billing_profile_id":3}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ auto_renew: true }), }); if (res.status === 422) explainLock(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['auto_renew' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The per-service switch is LOCKED while account-wide automatic payment is on; look at the wallet setting first. $s = Kernel::internal('client:Services/GetService', ['owner_id' => $uid, 'id' => $id])['data']; if (! $s['auto_renew_locked']) Kernel::internal('client:Services/UpdateService', ['owner_id' => $uid, 'id' => $id, 'auto_renew' => true]); ``` #### Raising a Renewal Invoice post/api/v1/client/services/{id}/renew `Services/RenewService` it raises an invoice Raises a renewal invoice for the service. Body 1 cyclestringThe renewal cycle. It is picked from the cycle list in the service detail, and the current one stands in when left out. Response fields data — 8 invoice_idintThe renewal invoice id. numberstringThe invoice number shown. statusstringThe raw invoice state. statestringThe state shown to the customer. totalobjectThe invoice total. created_atstringThe day it was raised. due_datestringThe day it falls due. existingboolWhether an invoice that was already open came back. True means no new document was raised. Errors 7 not_found404No such service, or it belongs to another customer. not_renewable422The service is neither live nor expired. renew_blocked_subscription422A subscription collects the renewals itself. cycle_invalid422The cycle is not one the product renews at. already_invoiced422The period is invoiced and no open invoice waits. renew_disabled422The operator closed renewal on this service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/services/622/renew' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"cycle":"annually"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/renew`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ cycle }), }); const { data } = await res.json(); if (data.existing) note('An invoice was already open.'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/renew'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['cycle' => $cycle]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // It RAISES an invoice and PAYS nothing: hand the id it returns to the invoice payment endpoint. $inv = Kernel::internal('client:Services/RenewService', ['owner_id' => $uid, 'id' => $id])['data']; Kernel::internal('client:Invoices/PayInvoice', ['owner_id' => $uid, 'id' => $inv['invoice_id'], 'payment' => ['method' => 'balance']]); ``` #### Asking to Cancel post/api/v1/client/services/{id}/cancellation `Services/CreateCancellation` one request at a time Opens a request to cancel the service. Body 4 urgencystringreqWhen it should end: at once or at the end of the period. reasonstringreqThe reason. One of five: no longer needed, too costly, moving away, missing features or other. reason_detailstringA free explanation. It is required where the reason is other. notestringA note to the operator. Response fields data dataobjectThe cancellation opened. Same shape as the cancellation field in the service detail. Errors 5 not_found404No such service, or it belongs to another customer. not_actionable422The service is not live. cancellation_exists422A request is already open. reason_detail_required422The explanation is empty on the other reason, or a value is invalid. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/services/622/cancellation' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"urgency":"period-ending","reason":"not-needed"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/cancellation`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ urgency: 'period-ending', reason, note }), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/cancellation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['urgency' => 'period-ending', 'reason' => $why]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Choosing AT ONCE ends the service there and then: the period-end choice lets the time already paid run out. Kernel::internal('client:Services/CreateCancellation', ['owner_id' => $uid, 'id' => $id, 'urgency' => 'period-ending', 'reason' => 'not-needed']); ``` #### Withdrawing a Cancellation delete/api/v1/client/services/{id}/cancellation `Services/RevokeCancellation` the key's owner Withdraws an open cancellation request. Response fields data — 2 revokedboolWhether the withdrawal ran. service_idintThe service id. Errors 2 not_found404No request stands open on this service. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X DELETE 'https://panel.example.com/api/v1/client/services/622/cancellation' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/cancellation`, { method: 'DELETE', headers: { Authorization: `Bearer ${clientKey}` }, }); const body = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/cancellation'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'DELETE', CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // Once a request is APPROVED withdrawing may not save the service; read its state from the detail. $s = Kernel::internal('client:Services/GetService', ['owner_id' => $uid, 'id' => $id])['data']; $pending = ($s['cancellation']['status'] ?? '') === 'pending'; ``` #### Reading the Service Add-ons get/api/v1/client/services/{id}/addons `Services/GetServiceAddons` the key's owner Returns the add-ons tied to the service. Response fields data[] — 11 idintThe add-on record id. addon_idintThe add-on definition id. option_idintThe choice picked. namestringThe add-on name. optionstringThe choice name. quantityintHow many were taken. statusstringWhere the add-on stands. priceobjectThe line total per period. cyclestringThe billing cycle. due_datestringThe day the next payment falls. unpaid_invoice_idintThe unpaid purchase invoice. It fills while the add-on waits. cancellation_requestedboolWhether it is set to end with the period. Errors 2 not_found404No such service, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/addons' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/addons`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const waiting = data.filter((a) => a.unpaid_invoice_id > 0); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/addons'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A waiting add-on stays asleep until its INVOICE is paid; take the id from here and pay it. $rows = Kernel::internal('client:Services/GetServiceAddons', ['owner_id' => $uid, 'id' => $id])['data']; $due = array_filter(array_column($rows, 'unpaid_invoice_id')); ``` #### Reading the Service Invoices get/api/v1/client/services/{id}/invoices `Services/GetServiceInvoices` the key's owner Returns the documents that bill this service. Query 1 limitintThe rows at most. 100 at the most. Response fields data[] — 7 invoice_idintThe invoice id. numberstringThe number shown. statusstringThe raw state. statestringThe state shown. totalobjectThe document total. created_atstringThe day it was raised. due_datestringThe day it falls due. Errors 2 not_found404No such service, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/invoices' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/invoices`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const open = data.filter((i) => i.state !== 'paid'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/invoices'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // There is NO PAGING here, only a ceiling: use the invoice listing for a long history. $rows = Kernel::internal('client:Services/GetServiceInvoices', ['owner_id' => $uid, 'id' => $id, 'limit' => 100])['data']; ``` #### Reading the Meters get/api/v1/client/services/{id}/metrics `Services/GetServiceMetrics` charged by use Returns the meters charged by use and what they are likely to cost. Response fields data — 2 metricsobject[] metrics[]The meters. keystringThe meter key. This is what the switch endpoint takes. labelstringThe name shown. unitstringIts unit. enabledboolWhether it is measuring now. includedfloatWhat is included free. usagefloatThe use this period. overagefloatThe billable use above what is included. estimated_chargeobjectThe charge expected for the overage. unit_priceobjectThe unit price of the first live tier. lockedboolWhether an unpaid usage invoice blocks turning it on. locked_by_invoice_idintThe invoice blocking it. auto_disabledboolWhether it closed by itself over an unpaid invoice. has_unpaidboolWhether any usage invoice on the service is unpaid. It is a lock across the whole service. Errors 2 not_found404No such service, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/metrics' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/metrics`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const bill = data.metrics.reduce((s, m) => s + (m.estimated_charge?.amount ?? 0), 0); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/metrics'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A meter switched off is still billed for what it USED UP TO THEN: closing it erases no history. $m = Kernel::internal('client:Services/GetServiceMetrics', ['owner_id' => $uid, 'id' => $id])['data']; $blocked = $m['has_unpaid']; ``` #### Switching a Meter patch/api/v1/client/services/{id}/metrics/{key} `Services/UpdateServiceMetric` an unpaid invoice locks it Turns one meter on or off. Body 1 enabledboolreqThe state wanted. Response fields data dataobjectThe meter as it now stands. Errors 7 not_found404No such meter key or service. enabled_invalid422The state field is missing or of the wrong kind. not_actionable422The service is not live. metric_not_priced422No live price tier exists. metric_unpaid_lock422An unpaid usage invoice blocks turning it on. metric_module_rejected422The panel module refused the change. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X PATCH 'https://panel.example.com/api/v1/client/services/622/metrics/storage' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"enabled":true}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/metrics/${key}`, { method: 'PATCH', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ enabled: true }), }); if (res.status === 422) explainLock(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/metrics/' . $key); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => 'PATCH', CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['enabled' => true]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The one way past the lock is PAYING THE INVOICE that blocks it; its id is written on the meter. $m = Kernel::internal('client:Services/GetServiceMetrics', ['owner_id' => $uid, 'id' => $id])['data']; $blocker = array_column($m['metrics'], 'locked_by_invoice_id', 'key')[$key] ?? 0; ``` #### The Usage Billing History get/api/v1/client/services/{id}/metrics/billing `Services/GetServiceMetricBilling` the key's owner Returns the use billed period by period. Query 3 pageintWhich page. limitintRows per page. 100 at the most. metricstringNarrows it to one meter. Response fields data[] — 9 + meta — 4 metricstringThe meter key. labelstringThe name shown. unitstringIts unit. period_startstringThe start of the period billed. period_endstringThe end of it. usagefloatThe use over the period. overagefloatThe share billed. amountobjectThe amount. statestringWhere the row stands: pending, unpaid, paid or cancelled. invoice_idintThe usage invoice. It is zero while nothing was billed. totalintHow many rows there are. It comes back under meta. pageintThe page you are on. limitintThe page size. next_pageintThe next page. Errors 2 not_found404No such service, or it belongs to another customer. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/metrics/billing?metric=storage' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const url = new URL(`https://panel.example.com/api/v1/client/services/${id}/metrics/billing`); url.searchParams.set('metric', key); const res = await fetch(url, { headers: { Authorization: `Bearer ${clientKey}` } }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/metrics/billing'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // A PENDING row is a period not yet billed: its invoice id is zero and the amount can still move. $rows = Kernel::internal('client:Services/GetServiceMetricBilling', ['owner_id' => $uid, 'id' => $id])['data']; $open = array_filter($rows, fn ($r) => $r['state'] === 'pending'); ``` ### Pitfalls > **Renewal raises an invoice and pays nothing** > > The renewal endpoint **produces an invoice** and stops there; the service runs longer only once that invoice is paid. A field in the answer says whether no new document was raised and an already open one came back. Hand the id to the invoice payment endpoint. > **Two automatic renewal switches exist** > > While account-wide automatic payment is on the per-service switch is **locked** and a change is refused. The lock field in the detail says so beforehand. The way past it is the wallet setting rather than the service one. > **Cancelling at once burns the time paid for** > > The urgency on a cancellation takes one of two values. **At once** ends the service there and then and the time already paid for does not come back, while **at the end of the period** lets it run out. Show the customer that difference. > **Switching a meter off erases no past use** > > Switching a meter off **does not save** the use up to that moment from being billed: it still enters the document at period end. An unpaid usage invoice also locks the meter shut, and paying that invoice is the only key. > **Domains do not belong to these endpoints** > > A domain record looks like a service and answers **not found** on these endpoints, because it has endpoints of its own. Code looking for a domain in a service listing comes back empty and can read that as a fault. See the domain articles instead. > **The service invoice list is not paged** > > The endpoint giving a service's invoices takes a **ceiling** alone and no paging. Use the general invoice listing for a long history and search there. The add-on listing carries the same limit. ### Related Articles - [Reaching Into a Service](https://dev.wisecp.com/en/reaching-into-a-service) - [Paying an Invoice](https://dev.wisecp.com/en/paying-an-invoice) - [The Domains You Own](https://dev.wisecp.com/en/the-domains-you-own) ## Reaching Into a Service https://dev.wisecp.com/en/reaching-into-a-service The seven endpoints reaching the panel behind a service. ### Overview Behind a service there is usually a **panel**: a hosting control panel, a virtualisation interface or a software installation. These seven endpoints reach it. They are all **live calls**: WISECP returns the panel's state at that moment rather than its own records. A slow panel makes a slow answer, and a panel that is down makes the endpoint fail. What is opened to the customer is **kept narrow** next to the admin side. Creating, suspending and terminating are never reachable here. ### Reference #### Reading the Panel Overview get/api/v1/client/services/{id}/dashboard `Services/GetServiceDashboard` it asks the module live Returns the overview and the abilities of the panel behind the service. Response fields data — 8 modulestringThe panel module name. panel_namestringThe panel name shown. sso_availableboolWhether single sign-on works. It closes while access is restricted. can_change_passwordboolWhether the panel password can be changed here. has_toolsboolWhether the module offers a tool surface. methodsarrayThe module methods the customer may call. The method endpoint takes these alone. quick_actionsarrayThe shortcuts on the overview. Each carries a key, a label and whether it runs an action. overviewobject overviewThe state the panel reports. gaugesarrayThe limited resources. Each carries what is used, the total and the unit, and a total at zero or below means no limit. resourcesarrayThe counters and values the module reports. accountarrayThe live panel account details. The sign-in details drop while access is restricted. Errors 4 not_found404No such service, it is not yours, it has no module, or it is not live. not_actionable422The service is not in a live state. module_unavailable422The module could not be set up. The panel cannot be reached now. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/dashboard' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/dashboard`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); if (data.sso_available) showOpenPanelButton(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/dashboard'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This call goes LIVE TO THE PANEL: a slow panel makes a slow answer, so cache it on screen. $d = Kernel::internal('client:Services/GetServiceDashboard', ['owner_id' => $uid, 'id' => $id])['data']; $can = $d['methods']; ``` #### Listing the Tools get/api/v1/client/services/{id}/tools `Services/GetServiceTools` it asks the module live Returns the panel tools the service offers. Response fields data[] — 4 keystringThe tool key. This is what the data and action endpoints take. groupstringThe panel's grouping. labelstringThe name shown. capabilitiesarrayThe actions the tool supports. Creating, editing, removing and the like. Errors 3 not_found404No such service, it is not yours, it has no module, or it is not live. tools_not_supported422The module has no tool surface. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/tools' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/tools`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); const byGroup = Object.groupBy(data, (t) => t.group); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/tools'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The customer list is NARROWER than the admin one: the tools opened to the customer area come alone. $tools = Kernel::internal('client:Services/GetServiceTools', ['owner_id' => $uid, 'id' => $id])['data']; $keys = array_column($tools, 'key'); ``` #### Reading a Tool's Data get/api/v1/client/services/{id}/tools/{tool} `Services/GetServiceToolData` it asks the module live Pulls what a tool lists from the panel. Query 1 actionstringThe read action. The tool's default listing stands in when left out. Response fields data dataobjectWhat the tool returns. Its shape follows the tool and the module, and there is no fixed contract. Errors 5 not_found404The tool is unknown or the service was not found. tools_not_supported422The module has no tool surface. tool_rejected422A hook refused the call. tool_data_failed500The call to the panel failed. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl 'https://panel.example.com/api/v1/client/services/622/tools/databases' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/tools/${tool}`, { headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/tools/' . $tool); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The shape belongs to THE MODULE and is no API contract: the fields move when the server panel does. $rows = Kernel::internal('client:Services/GetServiceToolData', ['owner_id' => $uid, 'id' => $id, 'tool' => $tool])['data']; ``` #### Running a Tool Action post/api/v1/client/services/{id}/tools/{tool}/{action} `Services/RunServiceToolAction` it changes the server Runs a create, an edit or a removal on a tool. Body — *objectA free body belonging to the tool. Its fields are what the module expects. Response fields data dataobjectWhat the module gave back. Errors 5 not_found404No such service, it is not yours, it has no module, or it is not live. tools_not_supported422The module has no tool surface. tool_rejected422A hook refused the call. tool_action_failed500The call to the panel threw an error. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/services/622/tools/databases/create' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"name":"shop"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/tools/${tool}/${action}`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); const { data } = await res.json(); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/tools/' . $tool . '/' . $action); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($payload), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // This call makes a real change ON THE SERVER and cannot be undone: put a removal behind a confirmation. $out = Kernel::internal('client:Services/RunServiceToolAction', ['owner_id' => $uid, 'id' => $id, 'tool' => $tool, 'action' => 'create'] + $payload); ``` #### Running a Module Method post/api/v1/client/services/{id}/module-method `Services/UseServiceModuleMethod` declared methods only Runs a method the module opened to the customer. Body 1 methodstringreqThe method to run. It comes from the method list in the panel overview. Response fields data — 4 methodstringThe method that ran. redirect_urlstringThe address the module produced. It fills on a download or a redirect flow. resultobjectWhat the method returned. outputstringThe output caught where the method printed rather than returned. Errors 6 not_found404No such service, it is not yours, it has no module, or it is not live. method_required422No method name was sent. invalid_method422The method was never declared open to the customer. module_unavailable422The module could not be set up. tool_rejected422A hook refused the call. module_method_failed500The module method threw an error. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/services/622/module-method' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"method":"backup_download"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/module-method`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ method }), }); const { data } = await res.json(); if (data.redirect_url) window.location = data.redirect_url; ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/module-method'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['method' => $method]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // DECLARED methods alone can be called: creating, suspending and terminating are never reachable here. $d = Kernel::internal('client:Services/GetServiceDashboard', ['owner_id' => $uid, 'id' => $id])['data']; if (in_array($method, $d['methods'], true)) Kernel::internal('client:Services/UseServiceModuleMethod', ['owner_id' => $uid, 'id' => $id, 'method' => $method]); ``` #### Signing Into the Panel post/api/v1/client/services/{id}/sso `Services/GetServiceSso` a short-lived address Makes a one-time address for signing into the panel without a password. Body — ——No body is needed, so send an empty one. The panel account comes from the service record. Response fields data — 1 urlstringThe address opening the panel. It is short-lived and not meant to be kept. Errors 5 not_found404The service was not found, has no module, is not live, or access is restricted. sso_not_supported422The module offers no single sign-on. tool_rejected422A hook refused the call. sso_failed500The module returned no address. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/services/622/sso' \ -H "Authorization: Bearer $CLIENT_KEY" ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/sso`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}` }, }); const { data } = await res.json(); window.open(data.url, '_blank'); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/sso'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $clientKey], ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The address is SHORT-LIVED and single use: never cache it and make a new one on each open. $sso = Kernel::internal('client:Services/GetServiceSso', ['owner_id' => $uid, 'id' => $id])['data']; header('Location: ' . $sso['url']); ``` #### Changing the Panel Password post/api/v1/client/services/{id}/password `Services/ChangeServicePassword` hosting and servers Changes the panel password of the service. Body 1 passwordstringreqThe new panel password. It has to meet the operator's least length. Response fields data dataobjectHow the change went. Errors 5 not_found404No such service, it is not yours, it has no module, or it is not live. password_too_short422The password is under the least length. That least value comes in the answer's detail. password_not_supported422The service type does not suit, access is restricted, or the module lacks the ability. password_change_failed422The panel refused the change. insufficient_scope403The key lacks the required scope. Request cURL JavaScript PHP (HTTP) PHP (Internal) ```bash curl -X POST 'https://panel.example.com/api/v1/client/services/622/password' \ -H "Authorization: Bearer $CLIENT_KEY" \ -H 'Content-Type: application/json' \ -d '{"password":"a-long-new-secret"}' ``` ```javascript const res = await fetch(`https://panel.example.com/api/v1/client/services/${id}/password`, { method: 'POST', headers: { Authorization: `Bearer ${clientKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ password }), }); if (res.status === 422) showRule(await res.json()); ``` ```php $ch = curl_init('https://panel.example.com/api/v1/client/services/' . $id . '/password'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $clientKey, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode(['password' => $new]), ]); $body = json_decode(curl_exec($ch), true); curl_close($ch); ``` ```php // The panel password is not THE ACCOUNT password: a change here never touches the customer's sign-in. $d = Kernel::internal('client:Services/GetServiceDashboard', ['owner_id' => $uid, 'id' => $id])['data']; if ($d['can_change_password']) Kernel::internal('client:Services/ChangeServicePassword', ['owner_id' => $uid, 'id' => $id, 'password' => $new]); ``` ### Pitfalls > **These endpoints go live to the panel** > > The overview, the tools and the tool data come from **the panel itself** rather than the WISECP database. A slow panel makes a long answer and an unreachable one makes the endpoint fail. Do not call these once per row on a listing screen; call them when the user truly asks. > **Only declared methods can be called** > > The method endpoint takes the methods a module declared **open to the customer**. The breadth of the admin side is absent: creating, suspending and terminating are never reachable here. Read the list from the panel overview. > **A tool's data shape is no contract** > > What a tool endpoint returns is **the module's own output** and can move when the server panel does. An interface bound tightly to the field names breaks on a module update. Read the capabilities from the tool listing and build from those. > **Restricting access closes two endpoints at once** > > Where the operator turns access restriction on for a product, both single sign-on and the panel password change **close**, and the sign-in details drop from the overview. The tools keep working. Read the two flags in the overview rather than treating it as a fault. > **The sign-on address is not for keeping** > > The single sign-on address is **short-lived and single use**. Keeping it somewhere and opening it later does not work, and while it lives it carries the right to sign in. Make a new one on each open and write it to no log. > **The panel password is not the account password** > > The password changed here belongs to **the panel on the server** and the customer's WISECP sign-in is untouched. The ability exists on hosting and server services alone, and the module has to support it. ### Related Articles - [The Services You Own](https://dev.wisecp.com/en/the-services-you-own) - [Asking for Support](https://dev.wisecp.com/en/asking-for-support) # Integration / Extending WISECP ## Adding Custom Fields https://dev.wisecp.com/en/adding-custom-fields Store extra information against a client or a ticket without adding a column. ### Overview Two engines share the name. Client profile fields live per language in `users_custom_fields` with key/value values; ticket fields in `tickets_custom_fields` on two axes. Neither needs a schema change: a definition row, and the value lands in a generic store. ### Prerequisites - An operator account, or a token with the settings and tickets scopes. - Familiarity with hooks: transformation is done with two filters. - Installed language codes: **one row per language**, so two languages are two ids. ### Structure Definition and value are separate; the value row is named `field_` plus the id. | Table | Holds | Keyed by | | --- | --- | --- | | `users_custom_fields` | Language, type, label, options, flags, rank | `id`, one row per language | | `users_informations` | The value, as text | `owner_id` plus `field_{id}` | | `tickets_custom_fields` | The ticket field and its department | `id`; label and type in the `_lang` twin | | `tickets_access_groups` | The reply-side grouping and its field ids | `id`; the relation lives on the group | The store is untyped text: multi-choice persists comma joined, an empty answer is an empty string. ### Walkthrough #### Define the Field 1. Create the definition once per language, from Settings or the client-fields API. 2. Choose the type: `text`, `textarea`, `select`, `checkbox`, `radio`; choice types take an `options` list. 3. Set the visibility flags; they are independent booleans and they compose. 4. Note the id the row is given. Your code uses the id, not the label. #### Read and Write the Value 1. Write with the info helper, keyed by `field_{id}`. 2. Read with the info reader: a name with no row comes back as `null`, never as a missing key. 3. The reader memoises per request; pass the third argument to bypass it after a write. 4. Delete rather than blank when "not answered" is meaningful: only a removed row triggers a fallback. #### Transform the Value 1. Listen on the save filter: once per `field_*` key, before the write. 2. Listen on the load filter: on every read, after the rows are collected. 3. Keep the two symmetric, or ciphertext ends up on the profile screen. 4. Guard on the field id: both filters fire for **every** custom field. ### Reference #### Definition Flags Five of six are integers holding 0 or 1; `status` is a string, and treating it alike reads every field as disabled. - **status**: `active` or `inactive`; an inactive field appears only in the operator's list. - **required**: An empty answer feeds the completeness gate. - **uneditable**: Visible but not writable; the profile save loop skips these fields. - **client_hidden**: Operator only, and it **overrides the others**: dropped from sign-up, invoice and required. - **invoice**: Printed on the invoice view; empty values are dropped. - **signForm**: Asked during registration; without it, only reachable from the profile screen. #### The Value Helper ```php // Upsert. $values is a map of name => value; a custom field's name is "field_{id}". // Returns true when at least one row was written. public static function AddInfo($owner_id = 0, $values = []); public static function setInfo($owner_id = 0, $values = []); // alias of AddInfo // Read. $names is an array (a comma separated string is also accepted and split). // Returns a map with EVERY requested name present; a missing row reads as null. // $noCache = true skips the per-request memo, which you need right after a write. public static function getInfo($owner_id = 0, $names = [], $noCache = false): array; // Remove rows entirely. $name is a single name or an array of names. public static function deleteInfo($owner_id = 0, $name = ''): int|bool; // Row id of an existing value, 0 when there is none. public static function isInfo($owner_id = 0, $name = ''): int; // Reverse lookup: which members hold this value. $first = true returns one row. public static function findInfo($name = '', $value = '', $first = true): array; // The completeness gate. Returns [field key => label] for everything still empty; // an empty array means the account may use the client area. public static function missing_required_fields($id = 0): array; ``` - **User::AddInfo()**: One row per entry. `phone` and `company_name` mirror onto the member record. - **User::getInfo()**: Memoised per request, storing the **raw** value, so the load filter still runs. - **User::missing_required_fields()**: Admin required fields plus the built-in ones, per language. - **Order trap: findInfo()**: It takes the **name first, value second**: it searches across members. A member id there returns an empty array. - **Value shape**: All text. A `checkbox` is comma joined on save; the other four are plain strings. #### The Two Value Filters Both fire by reference and are scoped to `field_` keys, so `timezone` never reaches them. ```php // Fired from User::AddInfo(), immediately before the insert or update. // $value mixed, by reference. What you leave here is what is stored. // $field_id int, the definition id parsed out of "field_5". // $owner_id int, the member the value belongs to. Hook::add('filter:custom_field.save_value', 10, function (&$value, $field_id, $owner_id) { // ... }); // Fired from User::getInfo(), after the rows are collected and before they are returned. // Same three arguments, same order. Hook::add('filter:custom_field.load_value', 10, function (&$value, $field_id, $owner_id) { // ... }); ``` - **filter:custom_field.save_value**: The persistence boundary. Change `$value` in place; the return is not used. - **filter:custom_field.load_value**: The read boundary: decrypt, format, or inject. By reference; the return is not used. - **action:client.profile_updated**: Fires after a profile save with the member id, the changed columns and the info map. A key means saved, not changed. The return is ignored. #### Where the Definitions Are Read Every client surface applies its own filter, and they deliberately disagree. | Surface | Reader | Filter it applies | | --- | --- | --- | | Account profile | `get_custom_fields()`, account model | active, not hidden, member's language, by rank | | Registration form | `get_custom_fields()`, sign model | the same, plus `signForm = 1` | | Invoice view | `invoice_custom_fields()` | `invoice = 1`; empty values dropped | | Validation of one field | `get_custom_field()` | the list filter, on one id | | Operator surfaces | `get_custom_fields()`, users model | language only; hidden included | #### Ticket Fields and Their Two Axes One definition table, two composers; the two memberships live in different places. ```php // Department axis, used by the ticket CREATE form. $did = 0 returns every field. // Rows carry the localized name, description, type, properties and options, plus // department_name. Ordered by did DESC, then rank ASC. public static function custom_fields($lang = '', $did = 0, $status = ''); // Access-group axis, used by the REPLY composer's credential rows. Reads the group's // own comma separated list of field ids and returns them IN THAT ORDER, which is the // drag-and-drop order the operator set. An empty group returns []. public static function custom_fields_by_group($lang = '', $groupId = 0, $status = 'active'); // The groups themselves, for building the selector. public static function access_groups($lang = '', $status = 'active'); public static function get_access_group($id = 0, $lang = '', $select = ''); ``` - **Tickets::custom_fields()**: Department axis. An empty `$status` returns inactive definitions too. - **Tickets::custom_fields_by_group()**: Access-group axis; values are encrypted under their own key namespace and shown masked. - **filter:ticket.custom_fields**: Filters the department-axis list (language, department, status). By reference, return not used. - **filter:ticket.access_group_fields**: The same for the access-group list; the return is not used. - **Ticket field types**: text, textarea, number, password, select, radio, checkbox. A `password` skips the sanitiser. #### Over the API | Endpoint | What it does | Note | | --- | --- | --- | | `GET /settings/client-fields` | Lists definitions | Language is a parameter | | `POST /settings/client-fields` | Creates one definition | Two languages, two calls | | `PATCH /settings/client-fields/{id}` | Updates one definition | Not partial for flags: an omitted flag becomes 0 | | `PUT /settings/client-fields/order` | Reorders the list | Rank drives the display order | | `GET /tickets/custom-fields` | Lists ticket definitions | Separate engine and scope | ### Example A field whose value must never sit in the database in the clear. ```php // The definition ids of the same logical field in each installed language. A client // field is one row per language, so a single label maps to several ids. const ACME_TAX_FIELD_IDS = [11, 12]; Hook::add('filter:custom_field.save_value', 10, function (&$value, $field_id, $owner_id) { if (!in_array($field_id, ACME_TAX_FIELD_IDS, true)) return; $value = trim((string) $value); if ($value === '') return; // an empty answer stays empty $value = Crypt::encode($value, Config::get('crypt/system') . '_ACME_TAX'); }); Hook::add('filter:custom_field.load_value', 10, function (&$value, $field_id, $owner_id) { if (!in_array($field_id, ACME_TAX_FIELD_IDS, true)) return; if ((string) $value === '') return; // decode() returns false on a value that was written before this listener existed, // so the original text is kept rather than blanking the member's answer. $plain = Crypt::decode((string) $value, Config::get('crypt/system') . '_ACME_TAX'); if ($plain !== false) $value = $plain; }); ``` The reading side of the same contract; nothing downstream knows the value was encrypted. ```php $uid = 42; // Ask for the names you want. Every name comes back, missing rows as null. $info = User::getInfo($uid, ['field_11', 'gsm']); $tax = (string) ($info['field_11'] ?? ''); // A multi-choice field is one comma joined string; split it back yourself. $picked = array_values(array_filter(explode(',', (string) ($info['field_12'] ?? '')))); // Write. The same names, the same "field_{id}" convention. User::setInfo($uid, ['field_11' => 'GB123456789']); // The reader memoises per request, so re-read with the third argument after a write. $fresh = User::getInfo($uid, ['field_11'], true); // Removing a row is not the same as writing an empty string: only the removal makes // the account look like one that never answered. if ($tax === '') User::deleteInfo($uid, ['field_11']); ``` The operator side, when your module ships its own field: one definition per language at install, and keep the ids. ```php $ids = []; foreach (['en' => 'VAT Number', 'tr' => 'Vergi Numarası'] as $lang => $label) { $ids[$lang] = WDB::insert('users_custom_fields', [ 'lang' => $lang, 'type' => 'text', // text | textarea | select | checkbox | radio 'name' => $label, 'status' => 'active', // STRING, unlike every flag below 'required' => 0, 'uneditable' => 0, 'client_hidden' => 0, 'invoice' => 1, 'signForm' => 1, 'options' => '', // comma separated, for select/checkbox/radio 'rank' => 90, ]) ? WDB::lastID() : 0; } // Keep the ids: they are the only stable handle to the field, and they differ per language. Config::setd('acme_vat_field_ids', Utility::jencode($ids)); ``` ### Pitfalls > **A field is one row per language, and the ids differ** > > No shared parent id joins the language variants, so hiding one leaves the other visible. Collect the ids as a set. > **Hidden plus required would lock the member out** > > The gate redirects until every required field is answered, so required lookups exclude hidden fields. > **The singular lookup is what refuses the write** > > A field left out of the list is a UI decision a forged post ignores; the singular reader is what refuses the write. > **Adding a flag touches eight places, not one** > > A new flag needs the column, the write path, the form and its JavaScript branches, the list column, the locale keys, both API directions and every client surface. > **The invoice reader returns no ids** > > It returns name and value pairs and drops empty values, so a lookup by id reports missing. ### Related Articles - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Changing the Database Schema](https://dev.wisecp.com/en/changing-the-database-schema) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Adding a Custom Operation](https://dev.wisecp.com/en/adding-a-custom-operation) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) ## Adding a Scheduled Task https://dev.wisecp.com/en/adding-a-scheduled-task Run your own code on a schedule: a handler class in the queue, with retries and telemetry. ### Overview Scheduled work is a queue, not a crontab. A scheduler picks due tasks, each becomes a row in `cronjob_queue`, and a handler turns that row into work. A task splits in two: discovery dispatches one job per target, execute does the work for one. Retries are per target. ### Prerequisites - A running worker; nothing runs until the tick reaches this system. - Where the handler lives: the core cron directory, or the module that owns it. - A stable target identifier; it becomes the idempotency key against duplicates. ### Structure One class per file, named after the class. | Piece | Where | Rule | | --- | --- | --- | | Handler class | `coremio/cronjobs`, `WISECP\CronJobs` | Execute has no suffix, discovery ends in `Discover` | | Registration | `coremio/cronjobs/registry.php` | Auto-discovery; a class declaring `TYPE` is registered | | Queue rows | `cronjob_queue` | One row per job: payload, attempts, logs, result | | Schedule state | `cronjob_runtime` | Per task: last run, next run, last slot, disable switch | | Task settings | `coremio/configuration/cronjobs.php` | `cronjobs/tasks/{task}`: type minus stage suffix, dots as hyphens | ### Walkthrough #### Write the Handler 1. One file per class in the cron directory, named after the class. 2. Declare `TYPE`: a dotted string ending in `discover` or `execute`. 3. Add `FREQUENCY` only to the handler the scheduler should trigger. 4. Implement the one interface method: a boolean, or the rich array. #### Register It 1. Nothing more for a core task; the registry scans the directory. 2. A module task registers from the module's own hook file. 3. Include the file yourself: the autoloader maps only the core cron namespace. 4. List the registered handlers and confirm your type appears. #### Dispatch the Work 1. In discovery, query targets and dispatch one execute job each. 2. Give every dispatch an idempotency key. 3. Pass the parent job id so children link to the scan, and cap the batch. 4. Snapshot into the payload anything shown later. #### Verify the Run 1. Open the automation dashboard and use the manual run control. 2. Read the queue row: status, attempts, error log, result payload. 3. If nothing appears, check the tick first: a scan with no work leaves no row. 4. Add a summary formatter once the result payload has settled. ### Reference #### The Handler Contract ```php namespace WISECP\CronJobs; interface CronJobHandler { // $payload the decoded array given to dispatch() for THIS job // $job the whole queue row: id, type, attempts, max_attempts, parent_id, // idempotency_key, started_at, process_logs and the rest public function handle(array $payload, array $job): bool|array; } ``` ```php // MANDATORY. Without it the class is not a task and the registry skips it. // Last segment is the stage: '.discover' for a scan, '.execute' for atomic work. public const TYPE = 'acme.sync.discover'; // OPTIONAL, and it means "the scheduler triggers this one directly". // One of: 'minute' | 'hour' | 'day' | 'month'. An operator can override it per task. public const FREQUENCY = 'hour'; // OPTIONAL, default 3. Retry budget; the delay is 2 to the power of the attempt, // in minutes, so 2, 4, 8. Set 1 when a retry would repeat an external side effect. public const MAX_ATTEMPTS = 1; // OPTIONAL, default 120. Seconds before the worker aborts this job with an alarm. // Raise it for long jobs such as a backup; the worker kills the job, not the tick. public const MAX_RUNTIME = 300; ``` - **WISECP\CronJobs\CronJobHandler**: One method. Without it the registry skips the class. - **CronScheduler::tick()**: Dispatches handlers whose slot has arrived; safe from several workers. - **register:cronjobs**: Where a module registers its own types; the return is ignored. #### What Your Return Means | Return | Row becomes | Use it when | | --- | --- | --- | | `true` | `completed`, no telemetry | Success with nothing worth showing | | `false` | Retry with backoff, then `failed` | Failure that a repeat might fix | | A thrown `Throwable` | Identical to `false`, message captured | You do not want to swallow it | | `['success' => bool, 'result' => array]` | `completed` or `failed`, result stored | You want telemetry | | `['success' => true, 'status' => 'cancelled', 'result' => array]` | `cancelled`; childless rows deleted | No work this tick | - **Cancelled, not a log line**: A no-work tick returns the cancelled signal instead of a log entry. - **result.reason**: A short machine-readable string: `no-targets`, `disabled`, `already-exists`. - **Failure message**: A throw stores its message in the process log. On bare `false` the queue reads `error`, `gateway_error` or `message`. - **Stage and counters**: Throughput counts only `.execute`, so a scan is always `.discover`. #### The Queue Helper ```php // Enqueue. Returns the new row id, or the EXISTING row id when the idempotency // key is already taken. A return value greater than zero is not proof of an insert. public static function dispatch(string $type, array $payload = [], array $opts = []): int; // Registration. Called for you by the registry for core handlers; a module calls it itself. public static function register(string $type, string $handlerClass): void; // Run one claimed row. $job is the full queue row, not an id. public static function process(array $job): bool; // Introspection. public static function resolve_handler(string $type): ?string; public static function registered_handlers(): array; public static function resolve_task_type(string $task): string; // Housekeeping, both already wired into the platform's own tasks. public static function recover_stale(int $minutes = 5): void; public static function cleanup(array $retention = []): array; ``` - **CronJobQueue::dispatch()**: Third argument is the options below; the payload is JSON encoded, no objects. - **opts.idempotency_key**: Unique across the table; present already means no insert, old id returned. - **opts.parent_id**: Links an execute job to its discovery job; deleting the parent cascades. - **opts.priority**: Integer, default 5, lower first; equal priorities run oldest first. - **opts.scheduled_at**: Holds the job until a future moment; null means the next tick. - **opts.title**: The queue-list label, 255 characters, stored rather than resolved later. - **opts.max_attempts**: Overrides the handler constant for this dispatch; without either, 3. - **CronJobQueue::resolve_task_type()**: Turns a task name into a handler type; never swap hyphens for dots. #### The Results Tab Built on demand from a static method on your handler; no HTML is stored. ```php // $resultPayload is exactly the 'result' array your handle() returned. // Called only when the admin opens the tab, resolved through the registry. public static function renderSummary(array $resultPayload): string; ``` - **Read the payload, never the database**: The row is historical; current state shows today under yesterday's date. - **Labels come from the language files**: Write both language files; a missing key should show as the key. - **Escape everything you print**: The payload holds user and remote text; the formatter returns raw markup. ### Example A complete pair: the hourly scan, and the worker that reconciles one service. ```php namespace WISECP\CronJobs; class AcmeQuotaSyncDiscover implements CronJobHandler { public const TYPE = 'acme.quotasync.discover'; public const FREQUENCY = 'hour'; private const BATCH = 200; public function handle(array $payload, array $job): bool|array { // The operator switch. Reading it on the first line keeps a disabled task // from doing anything at all, which is required for destructive tasks. if ((int) (\Config::get('cronjobs/tasks/acme-quotasync/enabled') ?? 0) !== 1) return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'disabled']]; $stmt = \WDB::select('id,name,owner_id')->from('users_products'); $stmt->where('status', '=', 'active', '&&'); $stmt->where('module', '=', 'AcmeCloud'); $stmt->limit(self::BATCH); $rows = $stmt->build() ? $stmt->fetch_assoc() : []; if (!$rows) return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'no-targets']]; $parentId = (int) ($job['id'] ?? 0); $dispatched = []; foreach ($rows as $row) { $sid = (int) ($row['id'] ?? 0); if ($sid <= 0) continue; // The key carries the hour slot as well as the service: a fixed key would // still exist next hour (completed rows keep theirs until retention runs) // and every later dispatch would silently return the old row instead. $key = 'acmeq_' . $sid . '_' . date('YmdH'); $childId = \CronJobQueue::dispatch(self::stage_execute(), [ 'service_id' => $sid, 'service_label' => trim((string) ($row['name'] ?? '')), 'owner_id' => (int) ($row['owner_id'] ?? 0), ], ['idempotency_key' => $key, 'parent_id' => $parentId]); $dispatched[] = ['service_id' => $sid, 'child_job_id' => $childId ?: null]; } return ['success' => true, 'result' => ['items' => $dispatched, 'count' => count($dispatched)]]; } private static function stage_execute(): string { return AcmeQuotaSync::TYPE; } } ``` ```php namespace WISECP\CronJobs; class AcmeQuotaSync implements CronJobHandler { // No FREQUENCY: this one is never scheduled, only dispatched by the scan above. public const TYPE = 'acme.quotasync.execute'; public const MAX_ATTEMPTS = 2; public function handle(array $payload, array $job): bool|array { $sid = (int) ($payload['service_id'] ?? 0); if ($sid <= 0) return false; // a payload this broken will not fix itself $service = \Services::get($sid); if (!$service) return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'service-gone', 'service_id' => $sid]]; $remote = \Modules::getInstance('Servers', 'AcmeCloud')->quota_of($sid); $before = (int) ($service['options']['quota'] ?? 0); $after = (int) ($remote['quota'] ?? 0); if ($before === $after) return ['success' => true, 'status' => 'cancelled', 'result' => ['reason' => 'in-sync', 'service_id' => $sid]]; // Options round-trip through Services::set(), which JSON encodes the array // for you. There is no set_options() helper: read, merge, write the whole map. $options = $service['options'] ?? []; $options['quota'] = $after; \Services::set($sid, ['options' => $options]); // Everything renderSummary() will need is snapshotted here, while it is true. return ['success' => true, 'result' => [ 'service_id' => $sid, 'service_label' => (string) ($payload['service_label'] ?? ''), 'quota_before' => $before, 'quota_after' => $after, ]]; } public static function renderSummary(array $r): string { $esc = static fn (string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $L = static fn (string $k): string => (string) \Language::gc('admin/automation/' . $k); $sid = (int) ($r['service_id'] ?? 0); $href = (string) \LinkGenerator::admin('services-1', ['detail'], '', ['id' => $sid]); $name = trim((string) ($r['service_label'] ?? '')); return '
    ' . '
    ' . $esc($L('telemetry-acme-service')) . '
    ' . '
    ' . $esc($name !== '' ? $name : (string) $sid) . '
    ' . '
    ' . $esc($L('telemetry-acme-quota')) . '
    ' . '
    ' . (int) ($r['quota_before'] ?? 0) . ' → ' . (int) ($r['quota_after'] ?? 0) . '
    ' . '
    '; } } ``` The same pair inside a module. ```php use WISECP\Modules\Addons\Acme\CronJobs\QuotaSyncDiscover; use WISECP\Modules\Addons\Acme\CronJobs\QuotaSyncExecute; // Fired while the registry loads, after the core handlers are in place. Hook::add('register:cronjobs', 1, function () { // Required: the autoloader resolves WISECP\CronJobs, but not a nested // namespace inside a module directory, so the file is included by hand. require_once __DIR__ . DS . 'cronjobs' . DS . 'QuotaSyncDiscover.php'; require_once __DIR__ . DS . 'cronjobs' . DS . 'QuotaSyncExecute.php'; CronJobQueue::register(QuotaSyncDiscover::TYPE, QuotaSyncDiscover::class); CronJobQueue::register(QuotaSyncExecute::TYPE, QuotaSyncExecute::class); }); ``` Driving the queue by hand. ```php // Is the type registered at all? An unregistered type fails the job, not the boot. $handler = CronJobQueue::resolve_handler('acme.quotasync.execute'); // Enqueue one job. Mind the return: an existing key gives you the OLD row's id. $id = CronJobQueue::dispatch('acme.quotasync.execute', ['service_id' => 5001], ['idempotency_key' => 'acmeq_manual_5001', 'priority' => 1]); // Run it here and now, bypassing the worker. process() wants the ROW, not the id. $job = CronJobQueue::get($id); $ok = $job ? CronJobQueue::process($job) : false; // Read back what the handler reported; this is the Results tab's own source. $row = CronJobQueue::get($id, 'status,attempts,result_payload'); $result = Utility::jdecode((string) ($row['result_payload'] ?? ''), true) ?: []; ``` ### Pitfalls > **A fixed key stops the second run** > > A completed row keeps its key for seven days and returns the old row's id. Put the time slot into the key when a target can legitimately run twice. > **Destructive tasks ship switched off** > > A task that removes records or sends data outside defaults to disabled and checks its own switch first. > **Empty minutes are not a stopped scheduler** > > A childless cancelled row is deleted, so counting queue rows shows gaps. The proof is the schedule state table, not the queue. > **Long jobs are killed after two minutes** > > The worker arms an alarm and aborts the job when it overruns; prefer splitting long work across dispatches. > **Retry repeats the side effect** > > Failure retries three times by default, repeating what the handler already did. Make it idempotent or set the budget to one. ### Related Articles - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Error Handling](https://dev.wisecp.com/en/error-handling) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Changing Billing Behaviour](https://dev.wisecp.com/en/changing-billing-behaviour) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) ## Adding a Custom Operation https://dev.wisecp.com/en/adding-a-custom-operation Add a POST endpoint that changes data and answers in JSON, without a new route or error format. ### Overview An operation is a method that changes data and answers in JSON. You post an `operation` parameter to an existing controller: no route to register. The controller dispatches on the name, checks privileges, and turns a throw into an error. Operations live in traits, mixed in with `use`, so a method reaches the controller's model and helpers. ### Prerequisites - A controller that already answers your surface, admin or client. - A privilege key on the operator panel: an empty declaration lets anyone signed in call it. - A caller: the panel's request helper acts on your `status`, `message` and `redirect` keys. ### Structure | Piece | Where | What it carries | | --- | --- | --- | | The trait | `coremio/operations`, namespace `WISECP\operations` | One public method per operation | | The controller | `coremio/controllers`, admin or website | `use` for the trait, plus the declaration array | | The caller | A template, or your own JavaScript | Posts `operation` plus your fields, reads the JSON | ### Walkthrough #### Write It 1. Add a public method to a trait: one operation-object parameter, returning `bool`. 2. Call the demo guard on the first line if the method writes. 3. Read inputs through the filter helper, never the raw request arrays; a password needs its own sanitiser. 4. Validate by throwing; there is no error return value to build. 5. Return the response through the output helper, from a variable the after hook can replace. #### Declare It 1. Import the trait and add it with `use` in the controller class. 2. Merge it into the constructor's declaration array, keyed by the posted name. 3. Give it the privileges the surface uses; the check runs first. 4. Confirm the entry point forwards to the dispatcher; a client controller does it explicitly. #### Call It 1. Post to the controller's own address with `operation` set to your name. 2. Send it as an asynchronous call, or an admin operation refuses it. 3. Standard keys: a message notifies, a redirect navigates, `reload` reloads. 4. Use the request helper's callbacks only for what those keys cannot do. ### Reference #### The Operation Object ```php class Operation { public ?string $name = ''; // the posted operation name public static ?string $last = ''; // the last name constructed, per request // Built by the dispatcher: $properties is the declaration array entry. public function __construct($name = '', $properties = []); // Throws on a demonstration system. First line of every mutating operation. public function demo(): void; // Fires a hook and normalises the answers. 'before' and 'after' are shorthands // for the two generic admin-operation filters; any other string is used as-is. // Returns [] or ['overwrite' => array] or ['output' => mixed]. public function hook(string $name = '', array $vars = []): array; // Sends the response. An array is JSON encoded with the JSON header set; // anything else is echoed as-is. Always returns true, so `return $op->output(...)` // satisfies the bool return type. public static function output($response): bool; public static function name(): ?string; public function assertContext(): void; public static function resolveContext(): bool; } ``` - **$operation->demo()**: Only on operations that write; a read-only one must not carry it. - **Operation::output()**: Sets the JSON content type and runs the API response filter. - **$operation->hook()**: Passes the controller name, the operation name and your variables. An error status throws. - **The bool return**: Nothing reads it; it does not signal success. #### The Declaration Array ```php $this->operations = array_merge($this->operations, [ // The common shape: posted name => the privileges required to run it. 'save_acme_settings' => ['privileges' => ['SETTINGS_OPERATION']], // 'method' points the posted name at a differently named method, which is how // two posted names share one implementation. 'acme_retry' => ['privileges' => ['SETTINGS_OPERATION'], 'method' => 'acme_run'], // 'allow_navigation' drops the asynchronous-request requirement. Only for // operations meant to be opened as a URL, such as a file download, and only // together with a one-time token of the operation's own. 'download_acme_log' => ['privileges' => ['SETTINGS_OPERATION'], 'allow_navigation' => true], // A client-side operation usually declares no privileges: it gates itself on // the member session inside the method instead. 'acme_client_action' => [], ]); ``` - **privileges**: An array of keys; the caller needs any one. A refusal is a message, not a redirect. - **method**: The method actually called; defaults to the posted name. - **allow_navigation**: Exempts the operation from the transport check. Downloads only, with a token. - **Undeclared but present**: A method on the controller is dispatchable without a declaration entry, with **no privileges**. #### What the Dispatcher Does | Step | Behaviour | On failure | | --- | --- | --- | | Name cleanup | Filtered to a route-safe string; `main` goes to not-found | Unknown names are refused | | Licence check | An active licence; help and polling operations exempt | An error, before privileges | | Transport check | An asynchronous request with `X-Requested-With: XMLHttpRequest` | HTTP 403 unless navigation is allowed | | Privilege check | Runs when the declaration lists privileges | Names the missing privilege | | Your method | Called with a fresh operation object | Any exception becomes `{"status":"error","message":"..."}` | | Fallback | No declaration and no method: a registration hook gets the name | Its array answer is sent | - **register:admin.operations**: The last chance to answer an operation nobody claimed; an array return is sent. - **filter:admin.operation.before**: Fired by the `before` shorthand. A listener returns an array: `error` blocks, `overwrite_vars` replaces variables, `output` answers instead. Empty continues. - **filter:admin.operation.after**: The `after` shorthand, once the response variable exists. Same return shape, but `error` throws. - **filter:api.response**: Runs in the output helper on every array response, with the operation name. Exceptions never reach it; changes are by reference, the return ignored. #### The Response the Caller Reads | Key | Value | What the panel does | | --- | --- | --- | | `status` | `successful` or `error` | Anything but success with a message is an error | | `message` | Text already translated | A notification, or a toast if the caller asked | | `redirect` | A URL, or `reload`, or `script` | Navigates, reloads, or evaluates `script`; with a message it waits | | `redirect_delay` | Milliseconds | Overrides the wait: five seconds with a message, immediate without | | `successToast` | Boolean | Forces the toast style over the caller's preference | | `data` | Anything | Nothing automatic; your own callback reads it | ### Example An operation that rotates an integration key, and the code that calls it. ```php namespace WISECP\operations; use Operation; use Filter; use Exception; trait AcmeSettings { public function rotate_acme_key(Operation $operation): bool { /** @var \WISECP\controllers\admin\settings $this */ // 1. Refuse on a demonstration system, before anything is read. $operation->demo(); // 2. Read input. Never $_POST: the filter type is chosen per field, and a // secret must go through 'password' so its punctuation survives intact. $id = (int) Filter::init('POST/id', 'numbers'); $label = (string) Filter::init('POST/label', 'hclear'); $confirm = (string) Filter::init('POST/confirm_password', 'password'); // 3. BEFORE HOOKS: after reading, before validating, so a listener can // substitute the inputs or answer instead of us. # BEFORE HOOKS $hook = $operation->hook('before', get_defined_vars()); if ($hook && $hook['overwrite'] ?? []) extract($hook['overwrite']); if ($hook && $hook['output'] ?? false) return $operation->output($hook['output']); // 4. Validate by throwing. The dispatcher turns each of these into // {"status":"error","message":"..."} with no work on your side. if (!$id) throw new Exception(\Language::gc('admin/settings/error-missing-id')); if ($label === '') throw new Exception(\Language::gc('admin/settings/error-label-empty')); $adata = \UserManager::LoginData('admin'); if (!\User::_password_verify('admin', $confirm, $adata['password'])) throw new Exception(\Language::g('needs/permission-delete-item-invalid-password')); // 5. The work itself, through the model the trait inherits from the controller. $key = \Utility::generate_hash(48); $this->model->set_acme_credentials($id, ['label' => $label, 'api_key' => $key]); // 6. Audit trail. The third argument is a key in the actions language file. \User::addAction((int) $adata['id'], 'alteration', 'changed-acme-key', ['id' => $id]); // 7. The response goes into a variable so the after hook can replace it. $response = [ 'status' => 'successful', 'message' => \Language::gc('admin/settings/success-acme-key-rotated'), 'data' => ['masked' => substr($key, 0, 6) . str_repeat('*', 10)], ]; # AFTER HOOKS $hook = $operation->hook('after', get_defined_vars()); if ($hook && $hook['overwrite'] ?? []) extract($hook['overwrite']); if ($hook && $hook['output'] ?? false) return $operation->output($hook['output']); // 8. One exit. output() encodes, sets the header and returns true. return $operation->output($response); } } ``` ```php namespace WISECP\controllers\admin; use WISECP\operations\AcmeSettings; use Controllers; use Filter; class settings extends Controllers { use AcmeSettings; public function __construct() { parent::__construct(); $this->checkLogin(); // Registration and access control in one place. Without the entry the // method would still be reachable, and it would run unprivileged. $this->operations = array_merge($this->operations, [ 'rotate_acme_key' => ['privileges' => ['SETTINGS_OPERATION']], ]); } public function main() { // The fork: a posted operation never reaches the page methods below. if ($operation = Filter::init('REQUEST/operation')) return $this->operation($operation); // ... page_* dispatch continues here return ''; } } ``` The calling side. ```javascript function rotateAcmeKey(btn) { WcpRequest(CONTROLLER_LINK, { method: 'POST', button: btn, // disabled and given a spinner for the round trip buttonLoader: saving_loader, options: { headers: { 'X-Requested-With': 'XMLHttpRequest' } }, data: { operation: 'rotate_acme_key', // the declared name, exactly id: ACME_ID, label: document.getElementById('acmeLabel').value, confirm_password: document.getElementById('acmeConfirm').value, }, // status/message/redirect are handled for us; this is only for the extra. afterDone: (res) => { if (res.status !== 'successful') return; document.getElementById('acmeKeyMasked').textContent = res.data.masked; }, }); } ``` A client-area operation has no generic before and after hook. ```php namespace WISECP\operations; use Operation; use Filter; use Exception; trait ClientAcme { public function acme_disconnect(Operation $operation): bool { $operation->demo(); // Context is resolved INSIDE the operation: the declaration array is // registration, not a gate, and a method is dispatchable without an entry. $member = \UserManager::LoginData('member'); if (!$member) throw new Exception(\Language::gc('website/account/err-auth')); $uid = (int) ($member['id'] ?? 0); $sid = (int) Filter::init('POST/service_id', 'numbers'); $service = \Services::get($sid); if (!$service || (int) ($service['owner_id'] ?? 0) !== $uid) throw new Exception(\Language::gc('website/account/err-not-found')); // Your own domain hooks, at the point where they belong. A gate refuses by // returning a non-empty reason, and the operation turns that into an error. // Both names live in your module's namespace: never fire a core hook, and // never invent one that the catalogue does not carry. foreach (\Hook::run('gate:service.acme_disconnect', $sid, $uid) as $veto) if ($veto) throw new Exception((string) $veto); // There is no set_options() helper. Options round-trip through Services::set(), // which JSON encodes the array for you, so read, merge, write the whole map. $options = $service['options'] ?? []; $options['acme_linked'] = 0; \Services::set($sid, ['options' => $options]); \Hook::run('action:service.acme_disconnected', $sid, $uid); return $operation->output([ 'status' => 'successful', 'message' => \Language::gc('website/account/acme-disconnected'), ]); } } ``` ### Pitfalls > **A public method is already an endpoint** > > Dispatch accepts any name matching a declaration entry or an existing method, so keep helpers private. > **The API needs the same rule** > > API resources reach the helper or the model directly, so a rule added only to an operation stays bypassable over the token. > **Encoding by hand skips the output filter** > > Echoing your own encoding bypasses the response filter and its flags. Return through the output helper, hook branches included. > **An operation is not reachable as a link** > > An admin operation is refused unless the request identifies itself as an asynchronous call. For a download, declare the exemption and add a one-time token. > **The demo guard belongs only on writes** > > On a listing or a preview it breaks the demonstration system for nothing. If the method writes, the guard is its first line. ### Related Articles - [Operations](https://dev.wisecp.com/en/operations) - [Controllers and Routing](https://dev.wisecp.com/en/controllers-and-routing) - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Admin JavaScript Library](https://dev.wisecp.com/en/admin-javascript-library) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Security Practices](https://dev.wisecp.com/en/security-practices) ## Overriding Text and Templates https://dev.wisecp.com/en/overriding-text-and-templates Change a shipped wording or piece of markup, in a way the next upgrade will not undo. ### Overview Every visible string comes out of a language file and every screen out of a template. Editing those works until the next version overwrites them. Four seams: a translation filter for one string, the theme content layer for website copy. Then a variables filter for what a template gets, injection points for what it prints. ### Prerequisites - The exact key; the same word appears in several packages. - Somewhere for a listener: the hooks directory or your module. - The installed language list, which is a directory listing. ### Structure Four stores hold text, each with its own accessor. The wrong one fails silently. | Store | Path | Read with | | --- | --- | --- | | Root packages: needs, date, errors, actions, constants, blocks, routes | `coremio/locale/{lang}/{name}.php` | `Language::g()` | | Controller packages, one per surface | `coremio/locale/{lang}/cm/{admin\|website\|system}/{name}.php` | `Language::gc()` | | Module strings | `coremio/modules/{Type}/{Name}/lang/{lang}.php` | `$this->lang['key']` | | Theme strings and page copy | `templates/website/{Theme}/locale/{lang}.php` | `Language::gc("theme/key")` or the theme's accessor | Panel edits go to `content/{lang}/{scope}.php` and win. An upgrade replaces `locale/`, never `content/`. ### Walkthrough #### Find the Key 1. Decide the store from the screen. 2. Search the language directory for the visible text. 3. Confirm on the command line. 4. Note the language: one says nothing about the others. #### Override an Existing String 1. For a controller string, listen on the translation filter and rewrite the value in place. 2. Keep the listener cheap: it runs on every controller string. 3. For website copy, use the theme's content layer. 4. The filter does not reach root package strings. Change those at the point of use. #### Add a New String 1. Pick the surface's package and match the neighbours' shape. 2. Add it to **every** installed language; a missing key shows nothing. 3. Nest the array when the key contains a slash. 4. Never hardcode a visible string. #### Override a Template 1. Decide: what the template *receives*, or what it *prints*. 2. To change the data, filter the variables. 3. To change the markup, use the nearest injection point. 4. To replace a public page, copy the view into your theme. ### Reference #### The Accessors ```php // Root packages. $key is "{file}/{key}/{subkey}", one slash per array level. // Returns false when any segment is missing. There is NO language fallback. public static function g($key = '', $replaces = [], $slang = ''): array|string|int|bool; // Controller packages under cm/. Same shape, one extra leading segment for the area. public static function gc($name = '', $replaces = [], $slang = ''): array|string|int|bool; // The active language code, for instance "en". public static function selected(): string; // Machine value to human label. $dictionary is the locale prefix the value is // appended to ("admin/orders/status-"), '@toggle', or '@list:{prefix}'. // An unknown value falls back to itself rather than to an empty string. public static function enum_label($value = '', $dictionary = '', $slang = ''): string; // Writes one package file. See the warning below: this REPLACES, it does not merge. public static function save($key = '', $data = [], $lk = ''): int|bool; ``` - **Language::g()**: Root packages only; a controller key returns `false`. - **Language::gc()**: Controller packages only. A root package name can return an **empty array**, which passes a truthiness guard. - **Argument order**: Both static accessors take replacements second, language third. The instance methods disagree. - **Language::enum_label()**: Turns a stored status into a label. Audit rows keep the raw value. #### What a Key May Look Like ```php 'Services', // With placeholders. Two notations exist and both are literal search targets: // the replacement map's keys are matched as written, braces and colons included. 'welcome' => 'Hello {name}, you have {count} messages.', 'meta-title' => 'Service detail - :name', // Declared form. g()/gc() return the 'content' string; 'variables' is metadata // for the translation tooling and never reaches the screen. 'quota-warning' => [ 'content' => 'Only {left} of {total} remaining.', 'variables' => '{left},{total}', ], // A slash in a key is DEPTH, not part of the name. This is the only way to // make Language::gc('admin/services/labels/renew') resolve. 'labels' => [ 'renew' => 'Renew', 'suspend' => 'Suspend', ], ]; ``` - **A missing key**: Resolves to `false`, with no language fallback and nothing in the log. - **Placeholder substitution**: Plain textual replacement. Pass the keys exactly as they appear, braces or colon included. - **The site address token**: Controller strings get one free substitution: `{SITE-URL}` becomes the base address. #### The Four Seams - **filter:i18n.translation**: Fires on every **controller-package** string, by reference. Arguments: value, key, language. Root packages and missing keys never reach it; the return value is unused. - **filter:template.variables**: Fires for every template, before extraction. Arguments: template path and data array. Returning an array **replaces** the data wholesale, so return all of it. - **The theme content layer**: Files under `content` override matching keys in `locale`. Written from the panel. - **ui: injection points**: Several hundred positions print whatever their listeners return, unescaped. This is the panel's stylesheet slot. - **ui:client.head.css**: The public-site twin of the row above: a stylesheet here restyles a theme. Return the markup; a non-string return is discarded. - **filter:i18n.translation_save**: Filters what the operator types in the translation editor. Values arrive by reference and the return value is unused. #### How a Theme Resolves a String ```php // One string, with optional placeholder substitution. public function lang(string $key, array $vars = []): string; // The content editor's own surface, for a module that wants to read or write it. public static function contentScopes(string $themeName): array; public static function scopeDefaults(string $themeName, string $lang, string $scope): array; public static function scopeOverrides(string $themeName, string $lang, string $scope): array; public static function writeScopeOverrides(string $themeName, string $lang, string $scope, array $values): bool; ``` | Order | Source | Note | | --- | --- | --- | | 1 | The operator's override | Wins over all below | | 2 | The theme's own text | `locale/{lang}.php` plus `locale/{lang}/{scope}.php` | | 3 | The same key in English | The only language fallback | | 4 | The core packages, controller first then root | Lets a theme reuse a platform string | | 5 | The key itself | Never blank, unlike the core accessors | A scope is `common` or a view path, and only the active one is loaded. A view under `page` drops that prefix. #### Writing a Package File > **The writer replaces the file, it does not merge** > > The array you hand it becomes the whole file. Paired with an accessor that answers with an empty array, a read-modify-write cycle can reduce a package to one key. ### Example Renaming a term without a language file. ```php // Key => replacement, per language. A map lookup keeps the listener O(1); a // str_replace over every string in the panel would not be acceptable here. const ACME_WORDING = [ 'en' => [ 'admin/services/page-list' => 'Subscriptions', 'admin/services/page-title' => 'Subscription detail', ], 'tr' => [ 'admin/services/page-list' => 'Abonelikler', 'admin/services/page-title' => 'Abonelik detayı', ], ]; Hook::add('filter:i18n.translation', 10, function (&$value, $key, $lang) { // Only controller-package strings arrive here, and only when they resolved // to a string: a missing key never fires this hook, so a gap cannot be // filled from here. $value = ACME_WORDING[$lang][$key] ?? $value; }); ``` Changing what a template receives; the path test narrows it. ```php Hook::add('filter:template.variables', 10, function ($template_path, $data) { // Normalise: the path arrives with the platform's directory separator. $path = str_replace('\\', '/', (string) $template_path); if (!str_ends_with($path, 'admin/services/detail.php')) return null; // not ours // The return REPLACES $data entirely. Building on the incoming array is not // a style choice: dropping template_dir or ui_lang breaks the render. $data['acme_banner'] = Language::gc('admin/services/acme-banner'); return $data; }); // Markup, at a position the platform announces. The return value is printed // as-is, so anything variable in it has to be escaped here. Hook::add('ui:admin.head.css', 10, fn (): string => ''); ``` Adding your own string, in every language. ```php // The installed languages are a directory listing, never a hardcoded pair. $langs = array_map('basename', glob(ROOT_DIR . 'coremio' . DS . 'locale' . DS . '*', GLOB_ONLYDIR) ?: []); foreach ($langs as $lang) { // Resolve in that language explicitly: the third argument, not the second. $existing = Language::gc('admin/services/acme-banner', [], $lang); // false means the key is missing THERE, even if it resolves in English. if ($existing === false) echo 'missing in ' . $lang . PHP_EOL; } // Reading one key in one language, from the command line, is how you settle an // argument about which accessor a package needs. var_dump(Language::g('needs/untitled', [], 'tr')); // string, root package var_dump(Language::gc('needs/untitled', [], 'tr')); // false, wrong accessor ``` The theme side: an override is a file, not a listener. ```php 'Hosting that stays out of your way', 'hero-subtitle' => 'Deploy in a minute, scale when you need to.', ]; ``` ```php $theme = Theme::active()->getName(); // Read the shipped defaults and the operator's current overrides separately: // they are two layers, and merging them before writing would freeze the defaults. $defaults = Theme::scopeDefaults($theme, 'en', 'home'); $overrides = Theme::scopeOverrides($theme, 'en', 'home'); $overrides['hero-title'] = 'Hosting that stays out of your way'; // An empty array removes the override file entirely, restoring the defaults. Theme::writeScopeOverrides($theme, 'en', 'home', $overrides); ``` ### Pitfalls > **The wrong accessor fails silently, and then destroys data** > > There is no language fallback and no warning. A missing segment returns false or an empty array. > **Two languages is not the language list** > > Read the installed languages from the directory. A key added to some shows nothing in the rest. > **The variables filter replaces, it does not merge** > > Returning only your own additions wipes out everything the template needed. Add to the incoming array and return all of it. > **The translation filter runs hundreds of times per page** > > Every resolved controller string passes through it, so the cost is paid once per string. Keep it to a map lookup. > **A slash in a key name is a level, not a character** > > Each segment is another array level, so a flat key with a slash is never found. ### Related Articles - [Translations and Language Files](https://dev.wisecp.com/en/translations-and-language-files) - [Views and Templates](https://dev.wisecp.com/en/views-and-templates) - [Translating a Theme](https://dev.wisecp.com/en/translating-a-theme) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) - [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade) ## Changing Billing Behaviour https://dev.wisecp.com/en/changing-billing-behaviour Adjust what gets invoiced, for how much and when, using the seams the billing engine exposes instead of editing it. ### Overview Billing is a chain: renewal falls due, price is worked out, unpaid invoice is written. Only payment moves the service forward. Each link has its own extension point. The wrong link gives a correct total with a due date that never advances. One rule holds it together: **issuing an invoice never extends anything**. The due date moves on payment, in a separate handler, from the period stamped onto the invoice item. ### Prerequisites - Comfort with hook listeners: almost every seam here is one. Know the by-reference contract too. Most of these filters hand you the value to mutate rather than to return. - A test service you can actually renew. Locked and live pricing differ only on a real row. - A clear answer to "which link am I changing". The choices: the decision to bill, the amount, the invoice document, or payment. ### Structure The recurring chain and its seams. | Step | What happens | Your seam | | --- | --- | --- | | Discovery | A scheduled task finds services whose renewal date has arrived | A gate that can skip one target with a reason | | Pricing | The unit price, quantity, tax exemption and discounts are resolved | A filter over the whole price result | | Invoice | An unpaid invoice and its items are written, stamped with the period being renewed | Filters over the payload, the item description and the totals | | Payment | The item event advances the due date and writes the cycle back to the service | An action fired after the extension | Metered billing is a second chain feeding the first. Usage is collected hourly; a closed month becomes its own invoice or a sub-item on the next renewal invoice. ### Walkthrough #### Choose the Seam 1. Write the sentence describing your change and find the noun. "Do not bill this customer" is discovery. "Charge 10 percent less" is pricing. "Add a line" is the invoice. "Tell our system it renewed" is payment. 2. Check whether the point you picked fires for every path. The renewal price filter does; a filter on an operator screen does not. 3. Confirm the direction. A by-reference filter expects mutation in place and ignores what you return. 4. If nothing fits, do not edit the helper. Ask for a generic hook at the point you need and put your own condition in your listener. #### Change What Something Costs 1. Listen on the renewal amount filter. You receive the resolved result array by reference, plus the target type, the target row and the customer's billing data. 2. Modify the unit amount, not the total. Quantity, taxes and discounts are applied around your value, so multiplying the wrong field double-counts. 3. Respect the pricing source already in the result. It says whether the number came from a frozen price on the row or from the product's live price. A discount that suits one is often wrong for the other. 4. Verify against a real renewal, not a hand calculation: inclusive tax is stripped after your filter runs. #### Skip or Redirect a Renewal 1. To stop one renewal, return a non-empty reason string from the creation gate. The task reports itself as cancelled with your string as the reason. 2. To stop renewals permanently, set the skip flag on the service. The discovery step reads it, so a flagged service is never dispatched. 3. To bill a term other than the service's own cycle, pass the period through the renewal entry point. Leave the service row alone. The engine prices the requested term live and stamps the invoice. Payment then extends by that term, and the stored cycle stays put. 4. Never advance the due date yourself. The service would be extended without a paid invoice behind it, and the next run would invoice it again. #### React to the Money 1. For "the service was extended", listen on the renewal action. It fires after the new due date is written and hands you the snapshot from *before* the extension plus both dates. 2. For "an invoice changed hands", listen on the status change action, which gives you the reloaded invoice plus the previous status. 3. Do not use the invoice creation action as a proxy for payment. An unpaid invoice may never be paid at all. 4. Make the listener idempotent. An operator can set a status again, and a payment can be recorded twice through different routes. ### Reference #### The Entry Points ```php // THE single entry point for renewing. $target_type is 'service' or 'addon'. // It writes an UNPAID invoice and returns; it does not touch the due date. public static function process_renewal(string $target_type, int $target_id, array $opts = []): array; // Called by the above once the target and the customer are resolved. public static function generate_renewal(string $target_type, array $target, array $user_data, array $opts = []): int|false; // The price. Reads period and period_time OFF $target, which is why pricing a // different term is a matter of handing it a modified target rather than a new method. public static function calculate_renewal_amount(string $target_type, array $target, array $user_data): array; // The de-duplication query behind 'already-invoiced'. The period is part of the key. public static function has_renewal(string $target_type, int $target_id, string $duedate, string $period = ''): bool; // The single INSERT point for every invoice in the product. public static function create(array $data): int; public static function add_item(array $data): int; // Recomputes subtotal, taxes, commission and total. $persist = false to preview. public static function recalculate_totals(int $invoiceId, bool $persist = true): array|false; // The status transition, with all of its side effects. public static function change_status(int $invoiceId, string $status, array $options = []): bool; ``` - **Invoices::process_renewal()**: Every renewal path in the product goes through it: the scheduled task, the operator button, the customer's own renew action and the API. Extending renewal behaviour here reaches all four at once. - **opts.period and opts.period_time**: Bill a term other than the service's own. Accepted values are hour, day, week, month and year. Passing the service's own cycle is a no-op; a different one switches pricing to live and marks the invoice so the stored cycle is preserved on payment. - **opts.duedate**: Overrides the period being renewed, which is what the catch-up path uses to invoice an older period that was missed. - **opts.source, opts.is_catchup**: Provenance stamped onto the work, defaulting to the scheduled run. Set them when you drive a renewal from your own code so the record says where it came from. - **opts.run_hook, opts.dispatch_notify**: Both default to true. Set them false when you are producing an invoice as part of a larger flow that will announce and notify once at the end, rather than once per target. - **Invoices::calculate_renewal_amount()**: Reads the term off the target array it is given, so pricing a different term means handing it a copy of the row with the period changed. There is no separate method for that. - **Invoices::recalculate_totals()**: Pass `false` as the second argument to compute without writing, which is how a report gets today's figure without touching the document. - **The return value**: Always an array. `success` plus `invoice_id`, which is **null** when nothing was billed, and `reason` carrying why: `already-invoiced`, `skip-renewal-invoice-flag`, `recurring-cycles-limit-reached`, `invalid-period` and their siblings. A skip is a success, so branch on the invoice id. #### Pricing and Document Seams - **filter:invoice.renewal_amount**: By reference, over the whole price result: `amount` (the unit price), `quantity`, `currency`, `taxexempt`, `additional_taxes`, `discounts`, `pricing_source` and `period_time`. Context: the target type, the target row and the customer's billing data. Fires after discounts are resolved and before inclusive tax is stripped. Change the array in place; the return is ignored. - **filter:invoice.renewal_description**: The line's text, by reference, after the localised period range has been built and before the item is added. Context is the target type and the target row. Change the string in place; the return is ignored. - **filter:invoice.create_payload**: The insert data of **every** invoice in the product, by reference, before the row is written: checkout, renewal, upgrade, metered and API. The widest seam here, and the easiest to make too wide. Change the payload in place; the return is ignored. - **filter:invoice.totals**: The computed `subtotal`, `tax`, `additional_tax`, `pmethod_commission`, `total` and `discounts`, by reference, before they are stored. The invoice row and the items come along read-only. Fires on every recalculation, which includes adding an item and recording a payment. Change the totals in place; the return is ignored. - **filter:invoice.late_fee_amount**: The computed fee, by reference, with the invoice and the fee cycle as context. The value is rounded again after you, and a result at or below zero skips the fee entirely. Change the fee in place; the return is ignored. - **filter:order.cart_totals**: The other half of the money surface: the cart summary, by reference, with the priced items, the subtotal, the applied coupons and the tax context. It feeds both the preview the visitor sees and the order that is actually persisted, so a listener here has to be deterministic. Change the summary in place; the return is ignored. #### Decision and Event Seams - **gate:invoice.create**: Fires in the renewal task before the entry point is called, with the target type, the target id and the due date. Returning a non-empty string refuses: the job reports cancelled and your string is the recorded reason. Returning empty or null continues. - **gate:invoice.client_pay**: Refuses a customer payment attempt. Note the arguments: the customer id is the invoice's **owner**, and a share-link payment has no identified payer at all, which the fourth argument tells you. Returning a non-empty string blocks the payment; no commission is booked and no balance is taken. Empty or null continues. - **action:service.renewed**: The real "it was extended" signal, fired after the new due date is written. Arguments: the service id, the snapshot from **before** the extension, the new due date and the old one. Do not confuse it with the recurring-limit announcement, which fires while the invoice is being produced and before any payment. The return is ignored. - **action:invoice.status_changed**: Fired at the end of the status transition, once the write, the revenue record and the history entry are done. Arguments: the reloaded invoice, the new status, the previous status and the transition options. The return is ignored. - **action:invoice.created**: An invoice document now exists. It says nothing about money having moved, and an integration that treats it as a sale will book revenue that may never arrive. The return is ignored. - **action:invoice.renewal_generated**: The narrower twin of the row above, for renewals specifically. Useful when you want to hear about recurring billing without also hearing about every checkout. The return is ignored. #### How the Due Date Actually Moves Two candidate dates are produced; the choice depends on the state of the service, not only on the operator's setting. | Service state | New due date | Why | | --- | --- | --- | | Active, not yet overdue | The item's period end, whatever the setting says | Paying early must never cost the customer days | | Active but overdue | Follows the operator's setting | The operator decides whether lateness is forgiven | | Suspended | Follows the operator's setting | Same decision, same switch | | Anything else | Now plus the period | The old cycle is no longer meaningful | | An older unpaid renewal exists | Forced to the item's period end | Paying a backlog must not skip the months it covers | | Hourly cycle | Forced to now plus the period | An hourly period end is already in the past by the time it is paid | The result is then clamped so it can never move backwards. When the invoice was produced for a term other than the service's own, payment advances the date by that term. The stored cycle, amount and currency stay untouched. #### Usage-Based Billing ```php // The price of a period's overage. $scheme is 'per_unit', 'volume' or 'graduated'; // $pricing is the tier map; $ccode is the CURRENCY CODE, because tiers are priced // per currency rather than converted. public static function calculate(string $scheme, float $billable, array $pricing, string $ccode): float; // Usage minus the included allowance, floored at zero. public static function get_billable(float $usage, float $included): float; // The month's figure from the hourly snapshots. Metered periods are billed on the // average, not on the last reading. public static function monthly_average(array $snapshots): float; ``` - **Metrics::calculate()**: The whole pricing engine for usage, in one call. The customer surface uses it live for an estimate and the closing task uses it for the real figure, which is why the two agree. - **Where the configuration lives**: On the service row as a JSON snapshot, one entry per metric key, not on the product. Both the scheduled tasks and the customer surfaces read that snapshot, so changing a product's metric definition does not retroactively change what an existing service is billed for. - **Turning a metric off does not cancel its bill**: Usage recorded while it was on is billed at the end of the period regardless. Only collection stops. The closing task scans services that have usage as well as services that have the metric enabled. - **The unpaid lock is service-wide**: While any metric on a service has a billed, unpaid invoice, no metric on that service can be switched on. Switching off stays allowed, and nothing re-enables automatically once the invoice is paid. - **Overdue usage disables itself**: A metric whose usage invoice is unpaid past its due date is switched off automatically, one day after the date, and the service is suspended two days later by a separate task. The automatic switch-off stamps a reason onto the service so the customer can see why. - **One invoice or a sub-item**: A closed period becomes its own invoice when the service's due date is more than about a month away, and otherwise waits and rides on the next renewal invoice as a sub-item. That is why a yearly service gets monthly usage invoices while a monthly service gets one combined document. - **Cancelling the invoice rolls the period back**: Deleting, refunding or cancelling a usage invoice returns its billing row to the pending state with no invoice attached, and the chain re-bills it on the next pass. Do not clean up those rows by hand. ### Example A loyalty discount on renewals, a skip rule, and the payment side that tells an external system. ```php // 1. PRICE. Everything arrives by reference; change the UNIT amount, because // quantity, taxes and discounts are applied around it. Hook::add('filter:invoice.renewal_amount', 20, function (&$result, $target_type, $target, $user_data) { if ($target_type !== 'service') return; $uid = (int) ($target['owner_id'] ?? 0); if ($uid <= 0) return; $years = (int) (User::getInfo($uid, ['acme_loyalty_years'])['acme_loyalty_years'] ?? 0); if ($years < 3) return; // A frozen price was agreed with this customer; discounting it again would // break an agreement the operator made deliberately. if (($result['pricing_source'] ?? '') !== 'live') return; $result['amount'] = round((float) ($result['amount'] ?? 0) * 0.9, 4); // The discount list is what the invoice shows the customer. Leaving it out // produces a cheaper invoice with no explanation on it. $result['discounts']['acme_loyalty'] = [ 'label' => Language::gc('admin/invoices/acme-loyalty-label'), 'rate' => 10, ]; }); // 2. DECIDE. A non-empty string refuses; the job records it as the reason. Hook::add('gate:invoice.create', 10, function ($target_type, $target_id, $duedate) { if ($target_type !== 'service') return ''; $service = Services::get((int) $target_id, 'id,options'); $opts = $service['options'] ?? []; // A service under migration should not be invoiced until it lands. if ((int) ($opts['acme_migrating'] ?? 0) === 1) return 'acme-migration-in-progress'; return ''; }); // 3. REACT. Fired AFTER the due date is written, so this is the extension signal. // $service is the snapshot from BEFORE the move: read old values from it. Hook::add('action:service.renewed', 30, function ($serviceId, $service, $newDuedate, $oldDuedate) { Utility::HttpRequest([ 'url' => 'https://crm.example.com/renewals', 'type' => 'POST', 'data' => [ 'service' => (int) $serviceId, 'from' => (string) $oldDuedate, 'to' => (string) $newDuedate, 'cycle' => (string) ($service['period'] ?? ''), ], ]); }); ``` Driving a renewal yourself, for a term the customer chose rather than the one stored on the service. ```php $result = Invoices::process_renewal('service', 5001, [ // Bill two years even though the service is stored as monthly. Pricing switches // to live for this one invoice, and payment will extend by two years while the // stored monthly cycle and its frozen amount stay exactly as they are. 'period' => 'year', 'period_time' => 2, 'source' => 'acme-portal', ]); // invoice_id is null on a skip, and a skip is NOT a failure: branch on the id. if (($result['invoice_id'] ?? null) === null) { // already-invoiced | skip-renewal-invoice-flag | recurring-cycles-limit-reached // | invalid-period | service-not-found | user-data-unavailable | ... throw new Exception('Renewal not issued: ' . (string) ($result['reason'] ?? 'unknown')); } // The invoice exists and is UNPAID. Nothing about the service has changed yet. $invoiceId = (int) $result['invoice_id']; ``` The reading side of the same money, for a report or a reconciliation job. The check is on the status transition, not on the existence of a document. ```php Hook::add('action:invoice.status_changed', 10, function ($invoice, $status, $old_status, $options) { // Only the transition INTO paid is a sale; a repeat write is not. if ($status !== 'paid' || $old_status === 'paid') return; $id = (int) ($invoice['id'] ?? 0); // Recompute rather than trusting a stored figure: an operator may have edited // the items after the invoice was issued. false = preview, nothing persisted. $totals = Invoices::recalculate_totals($id, false); AcmeLedger::record([ 'invoice' => $id, 'customer' => (int) ($invoice['user_id'] ?? 0), 'currency' => (string) ($invoice['currency'] ?? ''), 'net' => (float) ($totals['subtotal'] ?? 0), 'tax' => (float) ($totals['tax'] ?? 0), 'gross' => (float) ($totals['total'] ?? 0), 'method' => (string) ($options['pmethod'] ?? ''), ]); }); ``` ### Pitfalls > **Issuing an invoice is not extending a service** > > The renewal entry point writes an unpaid invoice and stops. Treating invoice creation as the renewal event extends services nobody paid for. > **A frozen price is only frozen for its own cycle** > > A locked price belongs to the cycle it was agreed for. Applying it to another term charges a monthly amount for a year. Pricing another term means going live for that call, which the renewal entry point does when you pass a period. > **The widest seams fire more often than you expect** > > The invoice payload filter sees every invoice, including checkout, upgrades and usage; the totals filter fires on every recalculation. A listener without a narrow condition adds its line repeatedly. > **A skip is a success** > > A declined call still returns success, with a null invoice id and a reason. The reasons: already invoiced, skip flag set, recurring limit reached. Branch on the invoice id, not on success. > **Usage is averaged, and its tiers are per currency** > > A closed metered period is priced from the average of the hourly snapshots, not the final reading. The tier table is keyed by currency code, so a currency with no entry prices at nothing. ### Related Articles - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) - [Domain Helpers](https://dev.wisecp.com/en/domain-helpers) - [Writing a Payment Gateway](https://dev.wisecp.com/en/writing-a-payment-gateway) - [Common Hook Recipes](https://dev.wisecp.com/en/common-hook-scenarios) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) # Integration / Upgrade-Safe Development ## Principles of Upgrade-Safe Work https://dev.wisecp.com/en/principles-of-upgrade-safe-work Six rules that decide whether your code still runs after the next core upgrade. Each one is anchored to what the upgrade run does to a file. ### Overview Upgrade-safe is a measurable property, not a style. The upgrade run copies the release's files over this installation, deletes the paths the release names, and leaves everything else alone. Which bucket your work lands in decides whether it survives. You pick it when you decide where the code goes. The run is inspectable. Its steps are a constant in the runner, and every overwrite is copied aside before it happens. The two things that cannot be undone are written down by name. The property has a second direction: your code has to keep working against a core that moved. A hook you listen to can be renamed. A method you call can change its signature, and a template you print into can be rewritten. The last rule below is about proving those assumptions still hold. ### Prerequisites - Shell access, or at least the ability to read `coremio/VERSION` and the admin update screen. - A rough map of the extension points: `hooks/INDEX.md`, `coremio/modules`, `templates/website`. - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work), if the word hook does not yet mean something specific to you. ### Structure #### What an Upgrade Actually Touches The apply step builds one list from the package's own file tree and copies each entry over the installation. Nothing else is visited, and deletions come from a separate explicit manifest. | Where the file is | What the run does to it | What that means for you | | --- | --- | --- | | A core file the release ships (class, helper, controller, admin template) | Overwritten byte for byte, after the pre-run copy is kept | Your edits are gone. The kept copy restores the previous release, not your edit | | A path named in the release's delete manifest | Deleted, after the pre-run copy is kept | A rename ships as an add plus a delete, so a file you attached code to can vanish | | `coremio/configuration`, file already present | Skipped | Your edits survive, and so do stale defaults: keys the new release adds there never arrive | | `coremio/locale` | Merged key by key, incoming wins on a collision | A key you added survives. A core string you rewrote returns the moment the release ships that key | | `coremio/storage` | Never removed: the delete manifest refuses those paths | Runtime state is yours to keep | | A path the package neither ships nor names | Untouched, not even copied aside | Your module, your hook file and your theme all live here | #### The Six Rules - **1. Register, never edit**: Every file under `coremio/hooks` and every `hooks.php` inside a module directory is included automatically at boot. Attaching behaviour costs a new file, never a changed one. - **2. Own a directory, do not colonise one**: A directory that is entirely yours (a module, a theme, one hook file) is invisible to the apply list. A single line added to a core file is not. - **3. Bind to names, not to positions**: Hook names, POST field names, config keys and route keys are contracts; they change loudly. Line numbers, array offsets, template markup and CSS structure are not, and they change silently. - **4. Keep your state in your own store**: Your own table, your module's `config.php`, or the database-backed settings pair. Never a core configuration file: the run skips it, so keys a later release adds there never reach you. - **5. When there is no seam, ask for one**: A missing extension point is a request, not a licence to patch the core. Opening a generic hook is the supported answer. - **6. A check that could not run is not a verdict**: Report it as skipped and carry on. The upgrade machinery draws the same line: a probe that cannot execute is not evidence of a fault. #### The Classes of Seam Each of these has its own article. What matters here is the shape: five kinds of extension point, each with a different cost and a different limit. - **Hooks**: 979 published points across 17 domains. The cheapest seam, and the only one that reaches inside a flow the core owns. Limited to the points that exist: a new trigger needs a core change. - **Modules**: 16 types, each a directory of your own with a contract the core calls into. The widest seam, and the only one that can own database tables, settings and admin pages together. - **Themes**: A directory under the website template root replaces the whole visitor-facing presentation. The admin panel has no equivalent layer; it is reached only through the published markup hooks. - **Configuration and data**: Database-backed settings, operator-defined custom fields, your own tables. Nothing here is a file a release can overwrite. - **Version guards**: The running version is a file the runner stamps last. Reading it lets your code decline on a core it was not written for instead of throwing. ### Reference #### The Primitives Every Safe Change Goes Through ```php // coremio/classes/Hook.php - attaching to a published point. public static function add($name, $priority, $properties = []): void; public static function run($name, ...$args): array; public static function runRefs($name, &...$args): array; // coremio/classes/Config.php - settings that live in the database, not in a file. public static function getd($name = ''); public static function setd($name = '', $content = ''); // coremio/classes/Config.php - the FILE-backed side. Read freely, write only your own file. public static function get($arg = NULL); public static function save($name = '', $data = []): bool; // coremio/classes/Modules.php - the canonical way to obtain a module instance. public static function getInstance(string $type, string $name, array $params = []): ?object; // coremio/helpers/license.php - the running core version, read from coremio/VERSION. public static function version($realtime = false): string; ``` - **Config::getd()**: Reads one row from the `configurations` table. Returns the stored value, or an empty result when the name was never set. No file is involved. - **Config::get()**: Slash-addressed lookup into a file under `coremio/configuration`, lazy-loaded on first touch. `Config::get("general/cache")` reads `general.php` and then the `cache` key. A missing key returns `false`, not `null`. - **License::version()**: Reads `coremio/VERSION` and memoises it for the request. Pass `true` to bypass the memo, which only matters inside an upgrade run. Compare it with `version_compare()`, never as a string. - **Modules::getInstance()**: Loads the module and fills its configuration. It resolves the class name across the three naming shapes, supplies required constructor arguments by reflection and caches the instance. Writing `new` yourself skips all five. #### Where a Registration File Is Allowed to Live Two globs, evaluated once per request, decide what gets included. Anything outside them has to be reached from inside them. | Pattern | Loaded when | Use it for | | --- | --- | --- | | `coremio/hooks/*.php` | First time a hook is **fired**, once per request. Registering one does not trigger the pass | Behaviour that belongs to this one deployment | | `coremio/modules/{Type}/{Name}/hooks.php` | Same pass, same include loop | Behaviour that travels with a module | | `coremio/modules/{Type}/{Name}/router.php` | Admin routing, when the module registers an admin area | Module pages inside the admin panel | | `coremio/cronjobs/*.php` | Boot, by class scan: a class with a `TYPE` constant is registered | Core scheduled tasks. A module registers its own from `hooks.php` | > **A loose hook file is a deployment decision, not a distribution one** > > A file dropped into the hooks directory is invisible to the package system. Nothing updates it, versions it or removes it when your work is retired. Behaviour meant to ship to more than one installation belongs in a module directory. ### Example The same requirement twice: a surcharge on one payment method, once by editing the core and once through a seam. ```php // Inside a core helper, three lines added by hand to a method the release owns. // The apply step ships this file, so the copy loop overwrites it and the pre-image // ledger restores the PREVIOUS RELEASE, never the edit. There is no trace left of // what was changed or why. if ($pmethod === 'wire-transfer') $summary['total'] = (float) $summary['total'] + 2.50; ``` ```php // A new file in a directory no release names. Nothing overwrites it, nothing deletes it. // Rule 3: the listener binds to a hook NAME and to documented parameter names. // Rule 4: the amount is a database-backed setting, not a constant in a core file. Hook::add('filter:order.cart_totals', 20, function (&$summary, $items, $subtotal, $coupons, $ctx) { $fee = (float) Config::getd('acme_wire_fee'); if ($fee <= 0) return; $summary['total'] = (float) ($summary['total'] ?? 0) + $fee; $summary['tax']['total'] = $summary['total']; }); // Rule 6: a capability question, answered before the work is attempted, and reported // as "not measured" rather than as a failure. Hook::add('action:cron.day.run', 50, function () { if (!function_exists('exec')) { // Logger::log() wants a level first; the level shorthands take the message alone. Logger::info('acme: archive check skipped, exec() unavailable'); return; } // ... the probe that needs a subprocess }); ``` And the guard that keeps the same file quiet on a core it was not written for. ```php // version_compare, never a string comparison: "2.10.0" sorts BELOW "2.4.0" as text. if (version_compare(License::version(), '5.0.0', '<')) return; // The other half of the same guard: the method you are about to call may not be there. // method_exists costs nothing and turns a fatal error into a disabled feature. if (!method_exists('Checkout', 'payment_methods')) return; ``` ### Pitfalls > **Editing a core configuration file is a staleness risk, not an overwrite risk** > > The apply loop skips a configuration file that already exists, so your edit survives. So do the defaults from the version you first installed. Keys the release adds there are never delivered, and the feature that reads them behaves as if the operator turned it off. > **Rewriting a core translation string loses the argument every time** > > Language files are merged rather than replaced, and the incoming release wins on any key it ships. A string you added survives; a core string you rewrote comes back the first time that key is touched upstream. Add your own key and point your own code at it. > **A file can be deleted, not only replaced** > > The release carries an explicit list of paths to remove, which is how a rename is expressed. If your code lives inside a core file, an upgrade that renames that file removes your work without overwriting anything. The class you extended stops existing at the same moment. > **The safety net is short and it is not a backup** > > The pre-image ledger keeps three runs, and it only ever contained the files the release touched. It is a way back from one bad upgrade, not an archive of your customisations. It does not replace a backup taken before the run. ### Related Articles - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) - [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade) - [Security Practices](https://dev.wisecp.com/en/security-practices) - [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work) - [The Module System](https://dev.wisecp.com/en/the-module-system) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) ## Working Without Touching the Core https://dev.wisecp.com/en/working-without-touching-the-core A worked catalogue of the seams the platform publishes, and what each one can and cannot reach. Use it to pick the right one for the change in front of you. ### Overview Every seam below is a loaded extension point with a named entry file, a contract and a limit. They are not equivalent. A hook reaches only where one was published. A module owns a directory, its own tables and its own settings, but must fit one of the sixteen type contracts. A theme covers the website and has no admin counterpart. ### Prerequisites - [Principles of Upgrade-Safe Work](https://dev.wisecp.com/en/principles-of-upgrade-safe-work), for what the upgrade run does to a file that is not behind a seam. - Write access to the installation directory and the ability to reload a page you can see. - A named target: the surface, the flow or the value you want to alter. "Change checkout" becomes "change the value the cart total filter hands out". ### Structure #### The Seam Map Find the row that matches your change, then read its contract below. | What you need to change | Seam | Its limit | | --- | --- | --- | | A value the core calculates (a total, a list, a payload) | A `filter:` hook | Only where one was published. There is no generic "before every method" interception | | React to something that happened (sync, notify, audit) | An `action:` hook | Return value discarded, exception swallowed: failures are invisible unless you log them | | Refuse an operation under your own rule | A `gate:` hook | Refuse or allow, nothing in between. You cannot alter the operation | | Add markup to an existing page | A `ui:` hook | Only at the positions that exist. There is no template override layer for the admin panel | | Provision something, take a payment, register a name, send a message | A module of that type | Sixteen types, each with a fixed method contract. A need that fits none of them has no module type | | A whole admin page of your own | A module admin area | Recognised for eleven of the sixteen module types; the rest register silently as nothing | | An HTTP endpoint of your own | The API route filter | Your handler must be a real method on the module instance. There is no catch-all dispatch | | A new admin AJAX action on an existing page | The admin operation fallback hook | None of the wrapper protections apply: no privilege check, no demo guard, no request-header check | | Work on a schedule | A cron handler registered from the module | The queue owns retries and timing. You do not get to choose when your slice runs | | The visitor-facing look | A theme directory | Website only. The admin panel is plain PHP templates with no theme layer | | The wording of a component string | The translation filter | Component lookups only. Package-level strings have no read-time filter | | Variables reaching a template | The template variable filter | By value, and the last listener that returns an array replaces the whole set | | Data on a client, ticket or product record | Custom fields, defined by the operator | Configuration, not code. You get storage and display, not behaviour | #### Where the Files Go One directory holds all of it; nothing above reaches outside this tree. ```bash Acme.php # the module class the core instantiates config.php # settings, written by the module, never by a release hooks.php # auto-included: every Hook::add lives here router.php # auto-included for admin routing: registers the admin area AdminArea.php # your own admin page(s) lang/en.php # your strings, keyed however you like cronjobs/Sync.php # your scheduled handler, registered from hooks.php src/AcmeClient.php # plain classes of your own, constructed with `new` ``` > **Two files are loaded for you, the rest you include yourself** > > The hook loader globs `hooks.php` in every module directory, and admin routing reads `router.php`. Everything else is reached from those two, by an explicit `include_once` or through the module class the core builds for you. ### Walkthrough #### Name What You Are Actually Changing 1. Open the surface and find the exact value, control or moment you want to alter. 2. Find the code that produces it. Search the literal text on screen, the field name in the form, or the route key in the URL. Follow it back to the helper or operation that owns the value. 3. Decide which of the five verbs applies. Transform a value, react to an event, refuse an action, print markup, contribute an entry to a list. That verb is the hook category. #### Pick the Seam 1. Search the catalogue in `hooks/INDEX.md` for the domain your change lives in. It gives every hook with its domain and the exact file and line where it fires. 2. If a hook exists, read its parameters table first. Whether an argument arrives by reference is on that page, and it decides how your listener is written. 3. If no hook exists but the work is a whole capability (a panel, a gateway, a provider), it is a module. The type you pick fixes the methods the core will call. 4. If neither fits, ask for a generic extension point rather than patching the core. A hook that names your module, flag or table is not generic; one that names the decision is. #### Register It 1. Create `hooks.php` in your module directory if it is not there. It is included automatically, once per request, on the first hook activity. 2. Guard the whole file on your module being enabled. Load the configuration in the lightweight mode and read the status before registering anything. 3. Register with a priority. Low runs first, and a number already taken is incremented until free, so nothing is silently dropped. 4. Build the module instance through the canonical factory inside the listener body, not at the top of the file. #### Prove It Survives 1. Reload the surface and confirm the behaviour changed. A listener whose registration is fine but whose body throws looks exactly like a hook that never fired. The error is caught and logged. 2. Grep your own tree for core paths. Anything under `coremio/classes`, `coremio/controllers`, `coremio/helpers` or `templates/admin` that you edited is a future outage. 3. List every hook, class and method you call from outside your own directory. That list is the compatibility contract you re-check after an upgrade. 4. Disable the module and reload. The surface must return to its stock behaviour with no error. If it does not, something of yours is running outside the status guard. ### Reference #### Registration Signatures ```php // coremio/classes/Hook.php public static function add($name, $priority, $properties = []): void; // coremio/classes/Modules.php - load config without including the class ($nominc = true), // then read it. Both are needed for a cheap "is my module on?" guard in hooks.php. public static function Load($type = '', $name = '', $nominc = false, $status = ''); public static function Config($type, $module); public static function getInstance(string $type, string $name, array $params = []): ?object; // coremio/classes/ModuleAdminArea.php - called from the module's router.php. public static function register(string $areaClass): void; public static function get(string $type, string $name): ?array; // coremio/helpers/CronJobQueue.php - called from a register:cronjobs listener. public static function register(string $type, string $handlerClass): void; public static function dispatch(string $type, array $payload = [], array $opts = []): int; // coremio/cronjobs/CronJobHandler.php - an INTERFACE, so implement it, do not extend it. // Return true for success, false to fail and be retried, or ['success' => bool, // 'result' => array] to hand the admin Results tab a payload as well. public function handle(array $payload, array $job): bool|array; ``` - **Modules::Load()**: Third argument `$nominc = true` loads `config.php` and the language file without including the module class. This is the guard form: cheap enough to run in `hooks.php` on every request. - **Modules::Config()**: Reads from the load cache only. Calling it before `Load()` returns nothing, which reads as "module disabled" and switches your whole file off. Order is not optional. - **ModuleAdminArea::register()**: Takes the class name, resolves type and name from its namespace, adds three routes (`slug`, `slug/(?)`, `slug/(?)/(?)`) and registers the menu entry. Returns nothing, including when it rejects the class. - **CronJobQueue::register()**: First argument is the handler's `TYPE` constant, second is the class name. A discovery handler (a type ending in `.discover`) also needs a `FREQUENCY` constant, which is what the scheduler reads. #### What Each Seam Expects Back | Hook | Fired with | Contract | | --- | --- | --- | | `register:admin.operations` | `run()`, arguments: controller name, operation name | Return `null` to decline. Return an array and it is JSON encoded and sent as the whole response | | `filter:api.routes` | `runRefs()`, arguments: route list by reference, audience by reference | Append tuples to the list. Return value unused. First match wins, so priority decides shadowing | | `register:cronjobs` | `run()`, no arguments | Include your handler file and call the queue's register method. Return value unused | | `filter:i18n.translation` | `runRefs()`, arguments: text by reference, key, language code | Change the text in place. Fires only for component lookups and only when the resolved value is a string | | `filter:template.variables` | `run()`, arguments: template path, data array | Return the **whole** data array, modified. The last listener that returns an array wins outright | | `register:admin.menu` | `run()`, no arguments | Write into the menu tree and return `true`, or return `false` to contribute nothing | ```php // [0] METHOD [1] pattern (full path after /api/v1, {x} captures) // [2] Group [3] Action [4] public? [5] authOnly? [6] audience $routes[] = ['GET', 'acme/status', 'Module:Addons/Acme', 'status', false, true, 'admin']; // Group "Module:{Type}/{Name}" dispatches to the module instance method // api_{Action}(WISECP\Api\Core\Request $request, array $match) // and method_exists decides: there is no __call fallback. A typo in [3] is a 404, // not an error you can see. // // [4] public = true → no credential at all; the module polices its own access // [5] authOnly = true → a valid credential is enough, no scope is checked (the default // on the free surface, because module scopes are not in the catalog) // [6] audience → 'admin' | 'client' | 'any', only consulted when public is false ``` #### Where There Is No Seam Four gaps. None has a supported workaround. - **Admin templates cannot be overridden**: The website has themes; the admin panel does not. Its templates are plain PHP, loaded from one fixed directory. The only way in is a `ui:` hook at a position that already exists. A missing position is a request for a new hook. - **Five module types cannot open an admin page**: The admin area resolver accepts eleven types: Servers, Payment, Registrars, Product, Addons, SMS, Mail, Authentication, Pipe, Imports and Fraud. A SocialAuth, Captcha, Currency, IP or Storage module is rejected by the identity check. `register()` returns having done nothing, with no error anywhere. Ship an Addons module alongside it, or ask for the type to be added. - **Package strings have no read-time filter**: The translation filter fires inside the component lookup only; strings read through the package lookup pass through no hook. The only ways to change one are the operator's language editor and your own key in your own file. - **A column you add to a core table is unowned**: Nothing forbids it, and the migration importer will not trip over it: a duplicate-column error counts as already applied. But no core code reads or writes it. If a later release adds the same column name, its definition is the one skipped. Yours stays, and the core's expectation does not hold. Prefix the name with your module, and prefer your own table. ### Example One small addon on four seams, touching nothing outside its own directory. ```php 'error', 'message' => Language::gc('acme/no-privilege')]; return Modules::getInstance('Addons', 'Acme')->resync_request(); }); // 2. An endpoint of our own on the credentialed admin surface. Hook::add('filter:api.routes', 1, function (&$routes, &$audience) { if ($audience !== 'admin') return; $routes[] = ['GET', 'acme/status', 'Module:Addons/Acme', 'status', false, true, 'admin']; }); // 3. A scheduled task. The file is included here, not at boot: the cron registry // only scans the core directory. Hook::add('register:cronjobs', 1, function () { include_once __DIR__ . DS . 'cronjobs' . DS . 'Sync.php'; CronJobQueue::register(\WISECP\Modules\Addons\Acme\CronJobs\Sync::TYPE, \WISECP\Modules\Addons\Acme\CronJobs\Sync::class); }); // 4. Reword one component string without editing a language file. By reference, // and only ever for the exact key we own the opinion about. Hook::add('filter:i18n.translation', 1, function (&$text, $key, $lang) { if ($key !== 'admin/services/status-active') return; $text = Language::gc('acme/status-live'); }); } ``` The other side of the first seam. The method returns the array the core encodes, so it owns the response shape. ```php public function resync_request(): array { $id = (int) Filter::init('POST/id', 'rnumbers'); if (!$id) return ['status' => 'error', 'message' => Language::gc('acme/id-required')]; // Throwing is the module's way of reporting failure; here we are answering an // AJAX call directly, so the error is shaped by hand instead. try { $rows = $this->client()->resync($id); } catch (\Throwable $e) { return ['status' => 'error', 'message' => $e->getMessage()]; } return ['status' => 'successful', 'message' => Language::gc('acme/resynced'), 'count' => $rows]; } // The API endpoint registered above lands here. The name is api_ plus the Action // from the tuple, and method_exists is the only thing that decides. public function api_status(\WISECP\Api\Core\Request $request, array $match): array { return ['data' => ['enabled' => true, 'last_sync' => Config::getd('acme_last_sync')]]; } ``` The admin page: two files and one line of registration. ```php // coremio/modules/Addons/Acme/router.php namespace WISECP\Modules\Addons\Acme; include_once __DIR__ . DS . 'AdminArea.php'; \ModuleAdminArea::register(AdminArea::class); // coremio/modules/Addons/Acme/AdminArea.php namespace WISECP\Modules\Addons\Acme; class AdminArea extends \ModuleAdminArea { public static function manifest(): array { return [ 'title' => 'Acme', 'slug' => 'acme', 'privileges' => ['TOOLS_ADDONS'], 'menu' => ['path' => ['TOOLS'], 'name' => 'Acme'], ]; } // /{admin}/acme → page_home() // /{admin}/acme/report → page_report() public function page_home(array $params): string|array { return ['content' => '

    Acme

    ', 'page_title' => 'Acme']; } // POST to the same URL with operation=refresh public function op_refresh(\Operation $operation): bool { $operation->demo(); return $operation->output(['status' => 'successful']); } } ``` ### Pitfalls > **The operation fallback hook runs outside every wrapper** > > A normal operation goes through a wrapper. That wrapper checks the privilege list and refuses a request from outside the panel's AJAX layer. It also builds the object whose demo guard blocks writes in demo mode. The fallback hook is reached only after the wrapper declined, so none of it applies. Check the privilege yourself, in the listener, first. > **The template variable filter is winner-takes-all** > > It is fired by value, and the caller assigns each returned array over the previous one. The last listener that returns an array replaces the entire set; two listeners on the same template do not merge. Read the array you were handed, modify it, and return all of it. > **A rejected admin area registration is silent** > > The registration returns having done nothing in three cases. The class is not a subclass. The namespace does not have the expected shape. The module type is not one of the eleven recognised ones. No exception, no log line, no menu entry. When your page 404s, check the type before the routes. > **Work done at file scope in hooks.php runs on every request** > > The hook loader includes the file whether or not any of your hooks will fire. A database query, a remote call or a full module instantiation outside a listener body is paid on every page load. Load the configuration, check the status, and put everything else inside a closure. ### Related Articles - [Principles of Upgrade-Safe Work](https://dev.wisecp.com/en/principles-of-upgrade-safe-work) - [Surviving a Core Upgrade](https://dev.wisecp.com/en/surviving-a-core-upgrade) - [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener) - [Registering Hooks from a Module](https://dev.wisecp.com/en/registering-hooks-from-a-module) - [Adding an Admin Page](https://dev.wisecp.com/en/adding-an-admin-page) - [Exposing API Endpoints](https://dev.wisecp.com/en/exposing-api-endpoints) ## Surviving a Core Upgrade https://dev.wisecp.com/en/surviving-a-core-upgrade What the upgrade run does step by step, what it can take back, and the checks to run before and after. ### Overview An upgrade is a sequence of steps with a time budget, plus a ledger of everything it overwrote. A health gate sits between the last write and the version stamp. ### Prerequisites - A second installation you can break. Not the system that serves customers, and not a copy sharing its database. - A real backup taken before the run, and verified. - The current version from `coremio/VERSION`, written down before you start. - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core): every check below assumes your code sits behind a seam. ### Structure #### The Run One version is five steps in a fixed order. A chained upgrade applies each fully and in order, by the code of the version before it. | Step | What it does | Can it be taken back? | | --- | --- | --- | | `backup` | Optional. Database, files and uploads, before anything is touched | Not applicable, it only reads | | `download` | Fetches the package into the work directory | Yes, nothing outside the work directory changed | | `extract` | Unpacks it, a batch of entries per slice | Yes, same reason | | `database` | Runs the release's schema statements once. Already-applied statements are skipped, out-of-order ones are replayed at the end | **No.** Recorded as forward-only and named to the operator | | `configuration` | Runs the release's configuration script, then merges language files and notification templates | Merges yes, script **no**: arbitrary code cannot be pre-imaged | | `apply` | Copies the package's files over the system, then the delete manifest, health gate and version stamp | Yes, from the ledger, once | | `finish` | Sweeps the work directory | Not applicable | Each step runs until its time budget expires, writes its position into a cursor, and returns. A web request gets sixty percent of the configured execution limit, clamped between 5 and 45 seconds. The command-line slice the minutely cron uses gets 300 seconds. The cron handler stops starting new slices after 1440 seconds of its own 1800 second ceiling. #### The Pre-Image Ledger One directory per run under `coremio/storage/updates-preimage`, outside the work directory so it outlives the run. Every overwrite and manifest deletion is copied here first, and the copy kept is the pre-run state. | Entry | What is in it | What reads it | | --- | --- | --- | | `meta.json` | Source version, versions applied, whether it was a re-apply, the state before | The rollback button, deciding whether it can be offered | | `files/` | The pre-run copy of every file overwritten or deleted | The restore, which copies all of it back | | `added.list` | Paths the run created that did not exist before | The restore, which deletes them (undoing an addition is a removal) | | `changed.list` | Files whose contents actually changed, by checksum | The health gate, which lints exactly this list and nothing else | | `forward.list` | What cannot be undone: the schema migration, the release configuration script | The rollback summary, which names them to the operator | | `baseline.json`, `gate.json`, `restored.json` | Health before, health after, evidence of a restore | You, when you need to know what the gate saw | #### The Health Gate Health is measured twice: a baseline before the first byte of the apply step, then the gate. The verdict is relative, so a broken baseline rolls nothing back. Three questions are asked, cheapest first. Do the changed PHP files still parse? Does a fresh process boot and reach the database? Does the system answer its own home page? | Field | Meaning | Effect on the gate | | --- | --- | --- | | `errors` | A check ran and failed | Triggers the rollback, and the run fails as a health check failure | | `skipped` | A check could not run at all | No verdict. Recorded in the evidence file and otherwise ignored | | `http` | A state, not a boolean: ok, unreachable, or the failure | Compared against the baseline, and asked twice before it is believed | ### Walkthrough #### Before 1. Write your dependency surface as a file, not as a memory. Every hook name you listen to. Every class and method called from outside your directory. Every core table, column and configuration key you read. 2. Grep your own tree for core paths and confirm you edited none. The ledger restores the previous release, not your version. 3. Check that your module carries a `manifest.json` if it is meant to be updatable. Without one the updater never touches the directory. 4. Run the whole upgrade on the copy first, from the panel and not only from cron. #### After 1. Run your compatibility probe before you open a page. Does every class and method you call still exist? 2. Exercise each hook you depend on and confirm your canary recorded a hit. There is no runtime registry of hook names. 3. Read the error log. An exception inside a listener is caught, logged and swallowed, so a broken listener looks like a hook that never fired. 4. Check the configuration keys your code reads. The apply step skips an existing configuration file, and the module updater preserves your `config.php`. New keys are never created for you. 5. Re-run your own schema guard if your module owns tables or added columns. The core upgrade knows nothing about them. 6. Tell the operator the rollback window is one run, once. #### When a Seam Moved | Symptom | Check | Usually means | | --- | --- | --- | | Your listener no longer runs and nothing is logged | Search the hook catalogue for the name, then for a near neighbour of it | The hook was renamed, or its call site was removed. Your registration is still perfectly valid and attached to nothing | | Your listener runs but the change is ignored | Whether the point is fired by value or by reference | It became by-reference. Your return value is discarded, and the caller wants the argument modified in place | | Fatal: call to an undefined method | The class in the source, not the log | The name held, the method did not. Guard the call and degrade instead of throwing | | Too few or too many arguments | The parameters table on the hook's page | The call site's argument list changed. Declare only the parameters you use and give the rest defaults | | Your admin page returns not found | The module type, before the routes | A silently rejected admin area registration. Or a slug that now collides with a core route | | It works from cron and dies from the panel | `function_exists('exec')` in both contexts | **Not an upgrade regression.** Shared hosting disables the process functions in the web pool; the command-line binary keeps them | #### Shipping Your Own Update 1. Put a `manifest.json` in the module or theme directory. 2. Keep the version there and nowhere else. The one inside `config.php` freezes at first install, because an update never writes that file. 3. Never reuse or edit a version number after publishing it. It is the identity every installation compares against. 4. Two things survive on the customer's disk that you did not send. Their `config.php`, skipped wherever one exists. And any file you removed from the package: an update overwrites but never deletes. 5. Write the manifest last in your own tooling too. A half-finished apply must never claim a version that is not fully on disk. ### Reference #### The Upgrade API ```php const VERSION_STEPS = ['download', 'extract', 'database', 'configuration', 'apply']; const PREIMAGE_DIR = 'updates-preimage'; // under coremio/storage/ const PREIMAGE_KEEP = 3; // runs retained, pruned when a new run opens const LINT_SECONDS = 60.0; // gate lint budget, time not file count const LINT_MAX_FILES = 2000; // safety brake behind the time budget const HTTP_SETTLE_SECONDS = 5; // waited before asking, and after restoring public static function open(array $opts, string $workerId): int; public static function tick(int $runId, string $workerId): array; public static function health_probe(): array; public static function restorable(): array; public static function restore_last(): array; public static function parked(array $run): bool; ``` ```php public static function check_state(): array; public static function check_new_version(): array; public static function next_versions(string|int $ver = 0): array; public static function run_active(string $kind = 'core'): array; public static function last_completed_run(string $kind = 'core'): array; public static function run_get(int $id): array; public static function sc_state(): array; // coremio/helpers/license.php - what the installation currently claims to be. public static function version($realtime = false): string; ``` - **UpdateRunner::health_probe()**: The same three questions asked outside an upgrade, with an empty changed-file list so the lint pass skips itself. Returns `['ok' => bool, 'errors' => string[], 'skipped' => string[], 'http' => string]`. Use it as the shape your own probe copies. - **UpdateRunner::restorable()**: Returns the run that can be undone, or an empty array. Empty when a run is active, when there is no completed run, or when the ledger has no metadata. Also when a restore already happened. Rollback is offered once per run. - **Updates::run_active()**: A non-empty array while an upgrade is in flight. Worth checking at the top of your own scheduled work: a run in progress means the file tree is a mixture. - **License::version()**: Reads the stamp the runner writes last, so it only reports a version that finished. Compare with `version_compare()`: as text, a two-digit minor sorts below a one-digit one. #### The Update Manifest It sits next to the module class, or next to the theme definition rather than replacing it. ```json {"type":"marketplace","name":"AcmeBilling","version":"1.2.0","last_updated":"2026-07-28"} ``` | Field | Required | What it does | | --- | --- | --- | | `type` | Always | Which publisher answers for this directory. An unknown value makes the whole manifest invalid rather than half read | | `name` | Store products | The product key. The directory name is not used; it is yours to choose | | `id` | Marketplace listings | The listing identity, for the same reason: two developers can pick the same directory name | | `version` | Always | The installed version. This and only this drives the comparison | | `last_updated` | Optional | Shown to the operator. It takes part in no decision | > **Detection is automatic, installation is not** > > The daily task only looks for new versions and notifies the operator. No scheduled task installs an update. ### Example A compatibility probe your module can ship. ```php namespace WISECP\Modules\Addons\Acme\Src; class Compat { // The dependency surface, as data. Everything this module touches outside its // own directory is listed here, so the probe is the list and not a rewrite. private const NEEDS_METHOD = [ 'Checkout::payment_methods', 'Services::get', 'Invoices::create', ]; private const NEEDS_HOOK = [ 'filter:order.cart_totals', 'action:order.checkout_completed', ]; private const CORE_MIN = '5.0.0'; private const CORE_MAX = '6.0.0'; // exclusive: a major bump is opt-in, never assumed /** @return array{ok:bool,errors:string[],skipped:string[],version:string} */ public static function check(): array { $errors = $skipped = []; $core = \License::version(); if (version_compare($core, self::CORE_MIN, '<')) $errors[] = 'core ' . $core . ' is below the minimum ' . self::CORE_MIN; if (version_compare($core, self::CORE_MAX, '>=')) $skipped[] = 'core ' . $core . ' is newer than this module was tested against'; foreach (self::NEEDS_METHOD as $ref) { [$class, $method] = explode('::', $ref, 2); if (!class_exists($class)) $errors[] = 'missing class: ' . $class; elseif (!method_exists($class, $method)) $errors[] = 'missing method: ' . $ref; } // A capability, not a result. Under FPM these are usually disabled while the // same server's CLI keeps them, so "cannot ask" must never read as "failed". if (!function_exists('exec')) $skipped[] = 'subprocess probes (exec unavailable)'; foreach (self::NEEDS_HOOK as $hook) if (!self::hook_seen($hook)) $skipped[] = 'hook not observed yet: ' . $hook; return ['ok' => !$errors, 'errors' => $errors, 'skipped' => $skipped, 'version' => $core]; } /* * There is no runtime registry of hook NAMES: hooks are fired, not declared, and the * store is private. So the only honest answer is observational - the canary below * records a timestamp when the point actually fires, and "not seen" is skipped, * never an error, because it may simply not have been reached yet. */ public static function canary(string $hook): void { $seen = (array) \Utility::jdecode((string) \Config::getd('acme_hook_seen'), true); $seen[$hook] = time(); \Config::setd('acme_hook_seen', \Utility::jencode($seen)); } private static function hook_seen(string $hook): bool { $seen = (array) \Utility::jdecode((string) \Config::getd('acme_hook_seen'), true); return (int) ($seen[$hook] ?? 0) > 0; } } ``` The canary is registered next to the listener it watches, at a priority that puts it first. ```php use WISECP\Modules\Addons\Acme\Src\Compat; include_once __DIR__ . DS . 'src' . DS . 'Compat.php'; // Priority 1: ahead of the real listener, so the observation is independent of it. Hook::add('filter:order.cart_totals', 1, function (&$summary, $items, $subtotal) { Compat::canary('filter:order.cart_totals'); }); // The gate the module itself sits behind. An incompatible core disables the feature // instead of throwing inside somebody's checkout. $acme_compat = Compat::check(); if ($acme_compat['ok']) { Hook::add('filter:order.cart_totals', 20, function (&$summary, $items, $subtotal) { // ... the real work }); } else { Hook::add('ui:admin.body.end', 90, function () use ($acme_compat) { return ''; }); } ``` Two commands worth running by hand on the copy, right after the run. ```bash # What the gate actually saw, including everything it could not measure. cat coremio/storage/updates-preimage/*/gate.json # Errors since the run, where a swallowed listener exception is the only trace. php coremio/errlog.php list --limit=20 ``` ### Pitfalls > **Passes from cron, fatals from the panel** > > Shared hosting pools disable the process functions in the web configuration, while the same server's command-line binary keeps them. A disabled function raises an error, not a warning, so the silencing operator does not help. Check the capability before you spawn a subprocess. A missing one is skipped, not failed. > **Files roll back, the schema does not** > > Restoring the ledger puts every overwritten file back and deletes everything the run created. But the migration already moved the schema, and the configuration script already ran. If your module reads a core column, check the column rather than the version number. > **A half-applied core is a real state** > > The file tree can be a mixture of two versions for minutes. Scheduled work started in that window can load one class from the old release and another from the new. > **Rollback is one run, once, and only the last one** > > The button appears only for the most recent completed run, and only while nothing is running. It disappears once used. Anything older needs a full backup. ### Related Articles - [Principles of Upgrade-Safe Work](https://dev.wisecp.com/en/principles-of-upgrade-safe-work) - [Working Without Touching the Core](https://dev.wisecp.com/en/working-without-touching-the-core) - [Shipping Module Updates](https://dev.wisecp.com/en/shipping-module-updates) - [Packaging a Module for Distribution](https://dev.wisecp.com/en/packaging-a-module-for-distribution) - [Debugging and Logs](https://dev.wisecp.com/en/debugging-and-logs) - [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task) ## Security Practices https://dev.wisecp.com/en/security-practices The security layers this platform provides, and the call that engages each one. Alongside them, three mistakes that have shipped. A permanently open guard, a filter that destroys the value it protects, an upload served as executable markup. ### Overview Every layer below exists in this codebase, is engaged by one named call, and is enforced at one named place. None is automatic. A controller does not filter its own input, and a public form is not protected until it asks. Third-party code goes wrong in two of them. Input filtering is per field and per type. Ownership on the client API is per query, not per request. ### Prerequisites - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) for the full filter vocabulary. This article covers the consequences of choosing wrong. - A surface to secure, and knowledge of who reaches it. An admin operation, a public form, a client endpoint and a module endpoint engage different layers. - The panel's Security settings open in another tab. Every threshold below is a configuration key. A limit hardcoded into your module ignores the operator's setting. ### Structure #### The Layers | Layer | What it stops | Engaged by | | --- | --- | --- | | Input filtering | Injection and markup smuggled through a field | **Your code**, one call per field, with the type that matches the field | | Output encoding | Stored values executing when they are printed back | **Your code** in admin templates and JSON. Website templates escape automatically | | Privilege check | A signed-in operator doing something their role does not allow | The operation wrapper, from the operation's own declaration. **Your code** when a fallback hook answers instead | | API scope | A valid credential reaching an endpoint it was not granted | The kernel, unless the route sets the auth-only flag, in which case any valid key passes | | Owner scope | One client reading another client's rows | **Your code**, in every query, from the injected owner and never from the request body | | Form guards | Cross-site submission, flooding, bots and spam content | **Your code**, four to five calls in a fixed order, plus two template tags | ### Walkthrough #### Read Input Safely 1. Never touch a superglobal. Read through the filter helper with a source prefix. 2. Pick the filter type from what the field *is*, not from habit. 3. Filtering is not validating. After the type filter removes what cannot belong, check that the rest is usable. 4. Read every array value with a default and cast it in the same expression. A missing key in a loop is also a log write on every iteration. 5. Write the presence test against `false`, never against `null`. #### Authorise the Caller 1. In a normal admin operation, declare the privilege list in the operation's own properties. The wrapper checks it before your method runs. 2. In an operation answered by the fallback hook, check the privilege yourself, first in the listener. The wrapper has already declined, so nothing of it applies. Not the privilege list, not the demo-mode guard, not the refusal of non-AJAX requests. 3. On a module API route, choose between a scope and the auth-only flag deliberately. Auth-only lets in any valid credential of the right audience. Sensible for the free surface, not for anything that writes. 4. On any client-facing endpoint, take the identity from the injected owner. An account identifier in the request body is the caller's suggestion, not an answer. #### Protect a Public Form 1. Print the token tag inside the form element, and the captcha tag where the form can require one. 2. Verify the token first, right after the demo guard. Verification expects the AJAX header by default. A form that posts normally passes the non-AJAX flag as the third argument. 3. Check the hard block, then the captcha requirement. The spam guard comes last, after the fields are read. 4. Count the request at the end, and clear or record the shield window depending on whether a captcha was solved. 5. Add your form to the spam guard's call list. A guest surface that skips it has the four bot layers and none of the content rules. #### Serve an Uploaded File 1. Stream it through the helper. Never write the content type header yourself from the detected type. 2. Deny direct access to the upload directory with a rule file. 3. Leave image directories meant to be shown inline alone. 4. Verify all three. The static path returns forbidden and the image path still works. The controller forces a download for markup and shows plain text inline. ### Reference #### Reading Input ```php // $arg is "SOURCE/key" or "SOURCE/key/subkey"; sources: GET/ POST/ REQUEST/ FILES/ SERVER/ // $mod is the filter type; $special adds characters to the allowed set of some types. public static function init($arg = NULL, $mod = false, $special = false); // Raw access to one source. Nested keys with a slash. Absent key => false. public static function GET($arg = ''); public static function POST($arg = ''); public static function REQUEST($arg = ''); public static function SERVER($arg = ''); // Allowed-tag strip, used by the "hclear" type and callable directly. public static function html_clear($arg = NULL, $allow = ''); ``` | Type | What survives it | Use it for | | --- | --- | --- | | `password` | **Everything.** A deliberate pass-through that returns the argument untouched | Secrets of every kind. Any other type deletes the characters that make them strong | | `hclear` | Text with tags stripped | Free text with no other shape: a note, a subject line, a name | | `rnumbers` | An integer | Record identifiers, counts, anything you are about to put in a query | | `numbers` | Digits and the hyphen, as a string | Phone numbers and reference codes, where the leading zero matters | | `amount`, `rate` | Digits with separators, and a float respectively | Money and percentages. Never the plain text filter, which keeps a stray letter | | `email` | The address character set only | Addresses, and then a format check on top: the filter removes, it does not validate | | `ip` | Address characters | Addresses and ranges. Follow it with a real parse before you compare anything | | `route` | Letters, digits, hyphen, underscore and dot, with parent traversal removed first | Anything that becomes part of a path or a route key | | `letters_numbers` | Letters and digits, plus whatever the third argument allows | Identifiers you control the shape of: a database name, a module key, a slug | | `domain` | Alphanumerics, dots and hyphens, lowercased. It does **not** validate: `not a domain!!!` comes back as `notadomain` | Names, and then a real check on top, exactly like the address filter. It also ignores the third argument | > **An absent key is false, so a guard written against null is always open** > > Both the typed reader and the direct source readers return `false` for a missing key. They never return `null`. A not-equal-to-null condition is true on every request, so the branch behind it runs unconditionally. This has shipped. A mode flag guarded that way loaded a simulation layer on every visit. Nothing reached the server while the responses looked successful. Cast to string before comparing with an empty string. #### Authorising ```php // coremio/helpers/admin.php - true when the signed-in operator holds the privilege. public static function isPrivilege($privileges): bool; // coremio/api/Auth/Scope.php - the credential's grants against the route's requirement. public function __construct(array $granted); public function allows(string $required): bool; // coremio/api/Resources/Client/_ClientResource.php - the client the request acts for. protected function owner(): int; protected function assertOwned(mixed $row, string $message = 'Resource not found.'): array; ``` - **Admin::isPrivilege()**: Takes the same array a normal operation declares. There the wrapper calls it for you and refuses before your method runs. In a fallback-hook listener nothing calls it, so you do. - **Scope::allows()**: Grants are `Group/Action` strings and match three ways. The exact string, the whole group with a trailing wildcard, or a single wildcard meaning everything. An empty requirement passes: an unset route scope is an open route. - **owner()**: The acting client, injected by the kernel from the credential on an external call and from the input on an internal one. It throws rather than returning zero, so no query silently runs unscoped. - **assertOwned()**: Turns both "does not exist" and "belongs to somebody else" into the same not-found answer. The distinction is itself information: leaking it lets a caller enumerate which identifiers are real. #### Guarding a Public Form Five independent layers, in this order. The first four judge who is asking, the fifth what they sent. | Order | Call | When it says no | | --- | --- | --- | | 1 | `Validation::verify_csrf_token($token, $key, $nonAjax = false)` | Refuse immediately, before any other work. The key must match the one the template printed | | 2 | `ProcessRestriction::blocked($action)` | This address is inside a hard block window. Answer with the rate-limit message and stop | | 3 | `Captcha::enabled($area)` or `BotShield::triggered($action)` | A captcha is required. Record the attempt and answer with the captcha-required status | | 4 | `Validation::spam_guard($subject, $message, $email, $phone, $ip, $domain)` | Non-empty means blocked. The returned reason goes to the log, never to the visitor | | 5 | `ProcessRestriction::hit($action)` plus shield record or clear | After the real work. Skipping it is why a limit that looks configured never fires | ```php // coremio/classes/Validation.php public static function get_csrf_token($form_index = '', $input = true); public static function verify_csrf_token($incoming_data = '', $form_index = '', $nonAjax = false); public static function spam_guard(string $subject = '', string $message = '', string $email = '', string $phone = '', string $ip = '', string $domain = ''): string; public static function password_chars_error($password = ''): string; // coremio/helpers/processrestriction.php - null ip means "the caller's", proxy aware. public static function blocked(string $action, ?string $ip = NULL): bool; public static function hit(string $action, ?string $ip = NULL): bool; public static function clear(string $action, ?string $ip = NULL): void; // coremio/helpers/botshield.php - the adaptive captcha, counted per address. public static function active(string $action): bool; public static function triggered(string $action, ?string $ip = NULL): bool; public static function record(string $action, ?string $ip = NULL): void; public static function clear(string $action, ?string $ip = NULL): void; // coremio/helpers/captcha.php - the static setting, and the answer check. public static function enabled(string $area = ''): bool; public function check(): bool; // coremio/classes/FraudModule.php - order time only. Empty string means clean. public static function run_checks(array $params = []): string; ``` - **spam_guard() returns a reason, not a boolean**: An empty string is clean; anything else is the operator-facing reason, already written to the blocked list. Show the visitor the generic translated message instead. The reason names which rule fired, which is a tuning aid for an attacker. - **The token key is a literal contract**: The string the template printed and the string the handler verifies must be identical. They are not derived from the route, so a mismatch is not an error you will see. Every submission fails verification, quietly. - **Thresholds live in configuration**: Attempt counts, windows and block durations are settings the operator owns, in the options file the Security screens write. A number written into your module stopped working the moment the operator changed theirs. - **Fraud checks are a separate layer**: They run at order creation, before anything is persisted, and they are not the spam guard. A module that throws is treated as a pass and logged, so a provider outage cannot take checkout down. #### Output and Files ```php // coremio/classes/Utility.php - the JSON encoder every response goes through. public static function jencode($string = '', $flags = 0): string|false; public static function jdecode($string = '', $mode = false); // coremio/classes/Utility.php - the only supported way to hand a stored file to a browser. public static function stream_uploaded_file(string $diskPath, string $fileName, array $opt = []): void; ``` - **stream_uploaded_file()**: Serves inline only for a fixed inert set: portable documents, the four raster image types and plain text. Everything else is forced to a download, including markup and vector images. It carries a generic type, a no-sniff header and a sandbox policy. It strips line breaks and quotes from the file name, sends the content and exits. - **The cache option is for content-addressed URLs only**: The optional third argument overrides the cache policy, for addresses that change when the file changes. A sensitive document must not pass it: the short private default is the point. - **Utility::jencode()**: Used instead of the raw encoder so every response shares one flag set. That keeps non-Latin characters and slashes readable rather than escaped. Encoding is not escaping: a value printed into markup still needs escaping at the point of print. - **htmlspecialchars()**: Admin templates are plain PHP with no automatic escaping, so anything you print there escapes at the point of print. Website templates escape by default, the opposite trap: a pre-encoded value stored there is encoded twice and shows the entities on screen. ### Example A guest form end to end. The template prints the two tags, the handler applies the five layers in order. ```smarty
    {* the key here and the key in the handler are one literal contract *} {csrf form='acme-request'} {captcha area='acme-request' tray='acme-captcha'}
    ``` ```php public function submit(\Operation $operation): bool { // The wrapper hands every operation an Operation object. Open with the demo guard: // it throws before a write can happen on a demo system. $operation->demo(); // 1. Token before any other guard. Third argument true for a non-AJAX post. if (!\Validation::verify_csrf_token((string) Filter::init('POST/token', 'hclear'), 'acme-request')) return $operation->output(['status' => 'error', 'message' => Language::g('needs/csrf-failed')]); // 2. Hard block window for this address. if (\ProcessRestriction::blocked('acme-request')) return $operation->output(['status' => 'error', 'message' => Language::gc('acme/too-many')]); // 3. Captcha: the operator's static setting, OR the adaptive shield having tripped. $needCaptcha = \Captcha::enabled('acme-request') || \BotShield::triggered('acme-request'); if ($needCaptcha && !(new \Captcha())->check()) { \BotShield::record('acme-request'); return $operation->output(['status' => 'captcha_required']); } // One call per field, and the type is chosen from what the field IS. // The secret uses the pass-through filter: any text filter would silently delete // exactly the punctuation that makes a password strong. $company = Filter::init('POST/company', 'hclear'); $email = Filter::init('POST/email', 'email'); $note = Filter::init('POST/note', 'dtext'); $password = Filter::init('POST/panel_password', 'password'); // Filtering removed what cannot belong; validation decides whether the rest is usable. if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new Exception(Language::gc('acme/email-invalid')); if ($e = \Validation::password_chars_error($password)) throw new Exception($e); // 4. Content and sender rules, after the fields are read, before the real work. if (\Validation::spam_guard('', $note, $email, '', UserManager::GetIP()) !== '') throw new Exception(Language::g('needs/spam-blocked')); // ... the real work // 5. Count this request and settle the shield window. \ProcessRestriction::hit('acme-request'); if ($needCaptcha) \BotShield::clear('acme-request'); else \BotShield::record('acme-request'); return $operation->output(['status' => 'successful']); } ``` The other two surfaces, shaped so the guard cannot be skipped. ```php public function GetNote(array $body = []): array { $id = (int) ($body['id'] ?? 0); // The owner comes from the credential. An account id in the body is a SUGGESTION // from the caller and is never used to select the account. $row = WDB::select('*')->from('acme_notes') ->where('id', '=', $id) ->where('owner_id', '=', $this->owner()) ->build('assoc'); // Missing and foreign both answer 404: which ids exist is itself information. return ['data' => $this->assertOwned($row)]; } public function stream_note_file(int $id): void { $row = $this->model->note($id); if ((int) ($row['owner_id'] ?? 0) !== $this->owner()) { http_response_code(404); exit; } // Never build these headers by hand from the detected type: an uploaded page // served inline runs on this origin, against the next operator who opens it. \Utility::stream_uploaded_file(ROOT_DIR . $row['path'], $row['name']); } ``` ```apache # The ownership check above lives in PHP; the web server does not know about it and # will serve the same bytes straight from the path. Reading the file from disk is a # filesystem operation and is unaffected, only the direct HTTP route is closed. # Both syntaxes, the way every private upload directory in the tree is already written: # the 2.4 directive alone is an error on a server without mod_authz_core. Require all denied Order allow,deny Deny from all ``` ### Pitfalls > **A generic sanitiser breaks the secret fields first** > > Text filters remove punctuation, and punctuation is what makes a password strong. Run one over a password field and the value is silently shortened. The account is created with something the customer never typed, and shows up later as a login that fails. Filter per field, and give secrets the pass-through type. > **A web-reachable upload directory has no access control** > > Every ownership check in your controller is bypassed by requesting the file's path directly. The server serves an uploaded page as a page. Both halves are required. The stream helper forces the type inert, the deny rule closes the static route. > **Emptiness tests treat a stored zero as absent** > > The emptiness test is true for the string zero. A setting the operator explicitly turned off reads as one that was never configured. The code falls through to its default. Read the key with a default and cast it in the same expression, then compare. > **Never fetch a URL the caller supplied** > > An outbound request to an address the caller chose reveals the origin behind a content network to whoever owns it. Client-facing endpoints take uploaded bytes instead. Fetch a URL only on an operator surface, or from a source the provider signed. ### Related Articles - [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input) - [Securing Theme Forms](https://dev.wisecp.com/en/securing-theme-forms) - [API Authentication and Permissions](https://dev.wisecp.com/en/api-authentication-and-permissions) - [The Client API](https://dev.wisecp.com/en/client-api-overview) - [Operations](https://dev.wisecp.com/en/operations) - [Principles of Upgrade-Safe Work](https://dev.wisecp.com/en/principles-of-upgrade-safe-work)