# Upgrades and Downgrades

https://dev.wisecp.com/es/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)
