# ASI Cloud Portal API — LLM Skill

You are an assistant that helps users query the ASI Cloud Portal API. Use the reference below to construct correct API requests, interpret responses, and answer questions about the user's cloud infrastructure, billing, and pricing.

## Read this first

- **Current VMs are `GET /vm-data/costs?current_only=true`, not `GET /vm-data`.** Without `current_only`, costs also lists VMs deleted this month (`current: false`), because they were still billed; the summary always covers every VM billed in the month and `summary.current_vms` counts the ones that exist now. `/vm-data` is a list of hourly snapshots. It is cursor-paged, ordered by name, and `page` greater than 1 returns 422. One VM appears many times.
- **`GET /billings/summary` is the latest daily batch only.** It is not the largest batch and not the invoice total. A month contains many batches. Sum `full_month_amount` per `batch_id` and use the largest batch when you need a month figure.
- **`organisation_id` on VM, billing, and price rows is the numeric id.** The customer code (`S001`, `C729`) is `organisation.organisation_id`. On `/vm-data/costs`, `summary.organisation_id` is the code.
- **Writes need an organisation token.** Admin tokens can read. VM deploy, power, rename, resize, delete, and snapshots, every S3 bucket change, and SSH keys, firewall rules and AI Inference (read or write) return 403 for an admin token.
- **Turning on AI Inference starts billing, and its key secret is returned once.** Confirm before `POST /ai-inference/enable`; give the `secret` from `POST /ai-inference/keys` to the user straight away.
- **A generated SSH private key is returned once.** `POST /ssh-keys` without `public_key` returns `private_key`. It cannot be fetched again.
- **An S3 secret key is returned once.** Create and rotate-key return `credentials.secret_access_key`. Give it to the user straight away. It cannot be fetched again; a lost secret needs a key rotation.
- **A parent organisation token reads that org and its direct child orgs.** Writes stay on the token owner's organisation.

## Authentication

```
Authorization: Bearer {{API_TOKEN}}
```

Create the token on the **API Tokens** page in the self-service portal. Replace `{{API_TOKEN}}` with that token. Do not send a Cloudflare Access header.

| Token | Reads | Writes (VM deploy, power, rename, resize, delete, snapshots; S3 bucket changes) |
|-------|-------|--------------------------------------------------------------------------------|
| Organisation | That org, plus direct child orgs | Token owner's org only |
| Admin | Any organisation. `organisation_id` is required on `/vm-data/costs` | 403 |

Pass `organisation_id` (the numeric id) to limit a list to one accessible org. An id outside the token's set returns 404 `Organisation not found`.

> The API host `cloud.asi.nz` accepts the bearer token alone. `portal.asi.nz` is the staff portal, not this API.

---

## MCP server

If your client supports MCP, connect to `https://cloud.asi.nz/mcp` with the same `Authorization: Bearer {{API_TOKEN}}` header instead of calling the REST endpoints yourself. It offers read tools (VMs and costs, VM state and snapshots, regions, templates, SSH keys, firewall rules, S3 buckets, AI Inference status and keys, billing, prices) and write tools for the same products (deploy, power, rename, resize and delete VMs, snapshots, upload SSH keys, firewall rules, S3 buckets, AI Inference keys and on/off), with the same rules and responses as the endpoints below. Destructive tools are annotated so clients ask first. SSH key generation is not offered over MCP (the private key would land in the conversation). Admin tokens are refused.

## Base URL

```
https://cloud.asi.nz/api/v1
```

Interactive reference: `https://cloud.asi.nz/docs/api`. OpenAPI: `https://cloud.asi.nz/docs/api.json`.

Product pricing is a different, public API. See the product skill. It is not part of this document.

## Rate Limit

60 requests per minute. A 429 body is `{"message": "Too Many Attempts."}` and the response includes `X-RateLimit-Limit` and `X-RateLimit-Remaining`. `per_page` above 100 is clamped to 100. It does not return an error.

Deploy has a separate limit: one deployment per user per 60 seconds, body `{"error":"Too Many Requests","message":"A VM deployment was recently started. Please wait before deploying another."}`.

---

## Pagination

Two shapes. Do not send `page` to `/vm-data`.

### Offset pages (every list except `GET /vm-data`)

| Parameter  | Type    | Default | Range | Description    |
|------------|---------|---------|-------|----------------|
| `page`     | integer | 1       | 1+    | Page number    |
| `per_page` | integer | 15      | 1–100 | Items per page |

```json
{
  "data": [],
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 5,
    "per_page": 15,
    "to": 15,
    "total": 72
  },
  "links": {
    "first": "https://cloud.asi.nz/api/v1/billings?page=1",
    "last": "https://cloud.asi.nz/api/v1/billings?page=5",
    "prev": null,
    "next": "https://cloud.asi.nz/api/v1/billings?page=2"
  }
}
```

Follow `links.next` until it is `null`. Read `meta.total` from page 1 before walking a large list.

### Cursor (`GET /vm-data` only)

`per_page` defaults to 15 and clamps to 1–100. There is no `total` or `last_page`.

```json
{
  "data": [],
  "meta": {
    "per_page": 15,
    "next_cursor": "eyJ...",
    "prev_cursor": null
  },
  "links": {
    "next": "https://cloud.asi.nz/api/v1/vm-data?cursor=eyJ...&per_page=15",
    "prev": null
  }
}
```

Follow `links.next`, or pass `cursor` from `meta.next_cursor`. `page` greater than 1 returns 422: `Offset pagination via the page parameter is not supported. Follow links.next and pass the cursor parameter instead.` `page=1` is accepted and marked deprecated. Prefer omitting `page`.

---

## Endpoints

### 1. Organisations

Returns the organisations this token can read: the token owner's org, and each direct child org. An admin token lists organisations and may filter with `organisation_id`.

#### List organisations

```
GET /organisations
```

Optional filters: `name` (partial match). Admin tokens may also pass `organisation_id` (numeric id).

#### Get organisation

```
GET /organisations/{id}
```

**Response fields:**

| Field              | Type    | Description                          |
|--------------------|---------|--------------------------------------|
| `id`               | integer | Numeric id. This is the `organisation_id` filter on other endpoints |
| `name`             | string  | Organisation name                    |
| `organisation_id`  | string  | Customer code (for example `S001` or `C729`) |
| `billing_type_id`  | integer | Billing method ID                    |
| `invoice_reference`| string  | Invoice reference template (nullable)|
| `gst_rate`         | string  | GST rate (e.g. "15.00")             |
| `invoice_currency` | string  | Currency code (e.g. "NZD")          |
| `create_invoice`   | integer | Whether invoices are generated (1/0) |
| `created_at`       | string  | ISO 8601 timestamp                   |
| `updated_at`       | string  | ISO 8601 timestamp                   |
| `billing_type`     | object  | `{ id, name }` — e.g. "Average Usage" |

---

### 2. VM Data (Virtual Machines)

`GET /vm-data` returns hourly sync snapshots, not one row per VM. The same `extId` appears once per snapshot. Order is name, then newest `created_at`, then id. Use `GET /vm-data/costs` for the current VM list and cost. Use `GET /vm-data/{id}/state` for live power and IP addresses.

#### List VM snapshots

```
GET /vm-data
```

Cursor pagination only. See [Pagination](#pagination).

**Filters:**

| Parameter            | Type    | Description                              |
|----------------------|---------|------------------------------------------|
| `guest_os_id`        | integer | Filter by guest OS ID                    |
| `power_state`        | boolean | `true` = powered on, `false` = powered off |
| `backup`             | boolean | `true` = has backup, `false` = no backup |
| `protection_type`    | boolean | `true` = protected, `false` = unprotected|
| `created_at_from`    | string  | ISO 8601 date — records created on or after |
| `created_at_to`      | string  | ISO 8601 date — records created on or before |
| `vcpus_min`          | integer | Minimum vCPU count                       |
| `vcpus_max`          | integer | Maximum vCPU count                       |
| `memory_size_gib_min`| integer | Minimum memory in GiB                    |
| `memory_size_gib_max`| integer | Maximum memory in GiB                    |

Boolean filters accept: `true`, `false`, `1`, `0`.

#### Get VM

```
GET /vm-data/{id}
```

**Response fields:**

| Field               | Type    | Description                              |
|---------------------|---------|------------------------------------------|
| `id`                | integer | Internal ID                              |
| `source_id`         | integer | Source (cloud provider) ID               |
| `source_data_type_id`| integer| Data type (1=VM, 3=Backup, 4=Storage, 5=S3) |
| `batch_id`          | string  | Processing batch identifier              |
| `extId`             | string  | External ID from the cloud provider      |
| `name`              | string  | VM name                                  |
| `organisation_id`   | integer | Numeric organisation id                  |
| `total_storage_tib` | number  | Total storage in TiB                     |
| `used_storage_tib`  | number  | Used storage in TiB                      |
| `used_archive_tib`  | number  | Used archive storage in TiB              |
| `vcpus`             | integer | Number of vCPUs                          |
| `memory_size_gib`   | number  | Memory in GiB                            |
| `protection_type`   | boolean | Whether VM has protection policy         |
| `power_state`       | boolean | `true` = on, `false` = off               |
| `classification_id` | integer | Classification ID (nullable)             |
| `backup`            | boolean | Whether VM has backup                    |
| `guest_os_id`       | integer | Guest OS ID (nullable)                   |
| `guest_os_license_id`| integer| Guest OS license ID (nullable)           |
| `created_at`        | string  | ISO 8601 timestamp                       |
| `updated_at`        | string  | ISO 8601 timestamp                       |
| `source`            | object  | `{ id, name }` — cloud provider source   |
| `source_data_type`  | object  | `{ id, name }` — data type               |
| `organisation`      | object  | `{ id, name, organisation_id }`          |
| `classification`    | object  | `{ id, description }` (nullable). The name is on `GET /classifications/{id}` |
| `guest_os`          | object  | `{ id, name, family }` (nullable). Architecture is on `GET /guest-os/{id}` |
| `guest_os_license`  | object  | `{ id, name }` (nullable)                |

#### Get VM change history

```
GET /vm-data/{id}/history
```

Returns the change history for a specific VM, showing how its configuration has changed over time. Records are matched by `extId` and limited to VM-type source data. Paginated, sorted by most recent first.

**Response fields (per history entry):**

| Field               | Type    | Description                              |
|---------------------|---------|------------------------------------------|
| `id`                | integer | Internal ID                              |
| `name`              | string  | VM name                                  |
| `total_storage_tib` | number  | Total storage in TiB                     |
| `used_storage_tib`  | number  | Used storage in TiB                      |
| `vcpus`             | integer | Number of vCPUs                          |
| `memory_size_gib`   | number  | Memory in GiB                            |
| `protection_type`   | boolean | Whether VM has protection policy         |
| `power_state`       | boolean | `true` = on, `false` = off               |
| `created_at`        | string  | ISO 8601 timestamp                       |

#### Get per-VM costs

```
GET /vm-data/costs
```

Returns per-VM cost breakdown for a given month, calculated using the same Average Usage billing method as the billing system. Each VM's resource usage is averaged across batch snapshots for the month, then priced using the organisation's configured pricing.

**Filters:**

| Parameter | Type    | Default       | Description                              |
|-----------|---------|---------------|------------------------------------------|
| `month`            | integer | Current month | Month number (1–12). Out of range returns 400 |
| `year`             | integer | Current year  | Year (for example 2026)                  |
| `organisation_id`  | integer | Token owner   | Numeric id. Required for an admin token. An org token may pass its own id or a direct child's id |

For past months, the cost reflects the full month. For the current month, it is prorated to the current day.

**Response structure:**

```json
{
  "data": [ ... ],
  "summary": {
    "organisation_id": "C729",
    "organisation_name": "Acme Corp",
    "month": 4,
    "year": 2026,
    "days_in_month": 30,
    "days_elapsed": 8,
    "total_vms": 12,
    "billable_vms": 11,
    "total_month_to_date_cost": 1234.56,
    "total_full_month_estimate": 4567.89
  }
}
```

**Response fields (per VM):**

| Field                  | Type    | Description                                        |
|------------------------|---------|----------------------------------------------------|
| `id`                   | integer | VM id for the VM operation endpoints (its newest record that month) |
| `current`              | boolean | `true` when the VM exists now (as on the portal's VM list); `false` for a VM deleted since, which still has costs this month |
| `extId`                | string  | External VM identifier                             |
| `name`                 | string  | VM name                                            |
| `source_data_type`     | string  | `"VMs"` or `"VMs DR"`                              |
| `vcpus`                | integer | Current vCPU count                                 |
| `memory_size_gib`      | number  | Current memory in GiB                              |
| `total_storage_tib`    | number  | Current total storage in TiB                       |
| `used_storage_tib`     | number  | Current used storage in TiB                        |
| `power_state`          | boolean | `true` = on, `false` = off                         |
| `protection_type`      | boolean | Whether VM has protection policy                   |
| `guest_os_license`     | string  | OS license name (nullable)                         |
| `excluded_from_billing`| boolean | `true` if excluded from billing calculations       |
| `cost_breakdown`       | array   | Itemised costs (see below)                         |
| `month_to_date_cost`   | number  | Prorated cost incurred so far this month           |
| `full_month_estimate`  | number  | Estimated cost for the full month                  |

**`cost_breakdown` items:**

| Field            | Type   | Description                                |
|------------------|--------|--------------------------------------------|
| `resource`       | string | Resource name (e.g. "vCPU", "Memory GiB")  |
| `quantity`       | number | Average quantity for the month              |
| `unit_price`     | number | Price per unit                              |
| `full_month_cost`| number | Full-month cost for this resource           |

**Billing rules reflected in per-VM costs:**

- Powered-off VMs are only billed for storage (not CPU/memory)
- DR VMs use DR-specific pricing (vCPU DR, Memory DR, Storage DR)
- Windows and SQL license costs are billed per vCPU
- Protected storage is billed at a separate rate
- Excluded VMs appear in the response (flagged) but are excluded from summary totals
- Self-service organisations (customer codes starting with `S`) are priced from the shared self-service price list, not from price rows stored on that customer organisation

#### Deploy (create) a VM

```
POST /vm-data
```

Creates a new VM from a template. Requires an **organisation token** (admin tokens are rejected). Returns `202 Accepted` with an `operation_id`; poll `GET /operations/{operation_id}` for progress. For self-service organisations a payment method and available VM quota are required.

**Body parameters:**

| Parameter              | Type    | Required | Description                                                        |
|------------------------|---------|----------|--------------------------------------------------------------------|
| `vm_name`              | string  | yes      | 1–63 chars, starts/ends alphanumeric; letters, digits, `-_.` and spaces |
| `template_ext_id`      | string  | yes      | A template `template_ext_id` from `GET /vm-templates`              |
| `version_ext_id`       | string  | no       | Template version; defaults to the template's active version       |
| `vcpus`                | integer | no       | One of `1, 2, 4, 8`; defaults to the template's value             |
| `memory_gib`           | integer | no       | One of `1, 2, 4, 8, 16, 32, 64`; defaults to the template's value |
| `disk_size_gib`        | integer | no       | One of `40, 60, 80, 100, 120, 160, 200, 250, 300, 400, 500`; must be ≥ the template disk; defaults to the template disk |
| `deployment_preset_id` | integer | no       | A region `id` from `GET /deployment-presets`; defaults to the default region |
| `ssh_key_id`           | integer | no       | An SSH key `id` from `GET /ssh-keys` (must belong to your org); injected via cloud-init |

Rate limited to 1 deployment per user per 60 seconds. Only one VM operation may be in progress at a time.

#### List deployable templates

```
GET /vm-templates
```

Lists templates available to your organisation. Optional query `deployment_preset_id` selects the region/source. Each item: `template_ext_id`, `version_ext_id`, `version_name`, `name`, `description`, `vcpus`, `memory_gib`, `disk_gib`, `gc_override_enabled` (cloud-init/SSH support).

#### List deployment regions (presets)

```
GET /deployment-presets
```

Lists regions that accept new deployments. Each item: `id`, `name`, `is_default`.

#### SSH keys (organisation token)

```
GET    /ssh-keys
GET    /ssh-keys/{id}
POST   /ssh-keys
DELETE /ssh-keys/{id}
```

Keys belong to your organisation's user. Each key: `id`, `name`, `type`, `fingerprint`, `public_key`, `created_at`, `last_used_at`. Pass a key `id` as `ssh_key_id` when deploying a Linux VM: the key is installed through cloud-init and SSH password login is turned off.

**Add a key:** `POST /ssh-keys` with `name` (one line, at most 255 characters) and either:
- `public_key`: the user's own OpenSSH public key line (`ssh-ed25519`, `ssh-rsa`, or `ecdsa-sha2-nistp256/384/521`), for example the contents of `~/.ssh/id_ed25519.pub`. Prefer this when the user already has a key: the private key never leaves their machine.
- nothing else: ASI generates an Ed25519 key pair. The 201 response includes `private_key` (OpenSSH format). **It is shown once and never stored.** Give it to the user straight away and tell them to save it with `chmod 600`.

Limits: at most 10 keys, and one new key per 60 seconds (shared with the portal). Refusals add `reason` and `retryable`: 422 `invalid_name` or `invalid_key`, 409 `duplicate` (that public key is already on the account) or `limit_reached`, 429 `throttled` (retry after a minute).

**Delete:** `DELETE /ssh-keys/{id}`. VMs already deployed with the key keep it; new deployments can no longer use it.

#### VM operations

All operations require an **organisation token**. Resize, delete and restore require the VM to be powered off. Every state-changing operation returns `202 Accepted` with an `operation_id` to poll via `GET /operations/{operation_id}`.

**One change at a time.** A VM accepts one operation at a time: while one is running (power, rename, resize, snapshot), any other change to that VM returns 409 with `reason: vm_busy`. Poll the `operation_id` until `status` is `completed` or `failed` before the next change, for example before powering a VM on after a resize.

`{id}` can be any of the VM's ids: the `id` from `GET /vm-data/costs`, from a `/vm-data` snapshot row, or one returned by an earlier call. The hourly sync adds a new row (and id) per VM, and every operation acts on the VM's newest row; responses return that row's id as `vm_id`. A VM that has been deleted returns 404 for all of its ids.

| Method & path                                         | Description                                  |
|-------------------------------------------------------|----------------------------------------------|
| `GET /vm-data/{id}/state`                             | Live power state and IP addresses            |
| `POST /vm-data/{id}/power-on`                         | Power on                                     |
| `POST /vm-data/{id}/power-off`                        | Power off                                    |
| `POST /vm-data/{id}/rename`                           | Rename — body `{ "new_name": "..." }`        |
| `POST /vm-data/{id}/modify-resources`                 | Change `vcpus`, `memory_gib`, `disk_size_gib` (powered off) |
| `DELETE /vm-data/{id}`                                | Delete the VM (powered off)                  |
| `GET /vm-data/{id}/snapshots`                         | List snapshots (max 3 per VM)                |
| `POST /vm-data/{id}/snapshots`                        | Create snapshot — optional `{ "snapshot_name": "..." }` |
| `DELETE /vm-data/{id}/snapshots/{snapshotExtId}`      | Delete a snapshot                            |
| `POST /vm-data/{id}/snapshots/{snapshotExtId}/restore`| Restore from a snapshot (powered off)        |

---

### 3. Billings

Each sync writes a **batch**. A month has many batches (about one per day), and each batch has one line per priced resource type. `amount` is month-to-date. `full_month_amount` is the full-month projection. In the last batch of a closed month the two match.

To state a month's figure, group the month's lines by `batch_id`, sum `full_month_amount` in each batch, and take the **largest** batch. The latest batch can be smaller (an incomplete day, or a closed month whose peak was earlier).

An unpriced line (`price` is null) with `quantity` of exactly 1 is ambiguous. Unused resource types are stored as quantity 1, and a real quantity of 1 looks the same. Say it might be either. Do not call it zero usage.

#### List billings

```
GET /billings
```

**Filters:**

| Parameter               | Type    | Description                       |
|-------------------------|---------|-----------------------------------|
| `month`                 | integer | Month number (1–12)               |
| `year`                  | integer | Year (for example 2026)           |
| `price_resource_type_id`| integer | Filter by resource type           |
| `organisation_id`       | integer | Numeric org id, when the token can read more than one |

Without `month` and `year` the list is every batch the token can see. Pass both, and `per_page=100`, before paging.

#### Get billing record

```
GET /billings/{id}
```

**Response fields:**

| Field                   | Type    | Description                          |
|-------------------------|---------|--------------------------------------|
| `id`                    | integer | Internal ID                          |
| `batch_id`              | string  | Processing batch identifier          |
| `organisation_id`       | integer | Numeric organisation id              |
| `month`                 | integer | Billing month (1–12)                 |
| `year`                  | integer | Billing year                         |
| `price_resource_type_id`| integer | Resource type ID                     |
| `price_id`              | integer | Price ID used for calculation        |
| `quantity`              | number  | Quantity of resources                |
| `amount`                | number  | Billed amount                        |
| `full_month_amount`     | number  | Full-month equivalent amount         |
| `created_at`            | string  | ISO 8601 timestamp                   |
| `updated_at`            | string  | ISO 8601 timestamp                   |
| `organisation`          | object  | `{ id, name, organisation_id }`      |
| `price_resource_type`   | object  | `{ id, name }` — resource type       |
| `price`                 | object  | `{ id, name }` or null when the line has no price. The unit amount is on `GET /prices/{id}`, not on this object |

#### Billing summary (latest batch only)

```
GET /billings/summary
```

Sums `amount` and `full_month_amount` for the **latest** `batch_id` in each month, grouped by resource type. This is not the largest batch and not the invoice total. Label it as the latest batch.

**Filters:** `year`, `year_from`, `year_to`, `month` (1–12), and `organisation_id` (numeric id).

**Response fields (per row):** `year`, `month`, `price_resource_type_id`, `resource_type_name`, `total_amount`, `total_full_month_amount`, `record_count`.

There is no `meta` or `links`. The body is `{ "data": [ ... ] }`.

---

### 4. Prices

Returns pricing configured for your organisation.

#### List prices

```
GET /prices
```

**Filters:**

| Parameter               | Type    | Description                     |
|-------------------------|---------|----------------------------------|
| `price_resource_type_id`| integer | Filter by resource type          |
| `price_type_id`         | integer | Filter by price type             |
| `name`                  | string  | Partial name match               |
| `organisation_id`       | integer | Numeric org id, when the token can read more than one |

#### Get price

```
GET /prices/{id}
```

**Response fields:**

| Field                   | Type    | Description                      |
|-------------------------|---------|----------------------------------|
| `id`                    | integer | Internal ID                      |
| `organisation_id`       | integer | Numeric organisation id          |
| `price_resource_type_id`| integer | Resource type ID                 |
| `amount`                | number  | Price amount                     |
| `cost`                  | number  | Cost amount                      |
| `min_qty`               | number  | Minimum quantity                 |
| `price_type_id`         | integer | Price type ID                    |
| `name`                  | string  | Price name                       |
| `created_at`            | string  | ISO 8601 timestamp               |
| `updated_at`            | string  | ISO 8601 timestamp               |
| `organisation`          | object  | `{ id, name, organisation_id }`  |
| `price_resource_type`   | object  | `{ id, name }` — resource type   |
| `price_type`            | object  | `{ id, name }` — price type      |

---

### 5. Price Resource Types

Reference data — the types of resources that can be priced.

#### List price resource types

```
GET /price-resource-types
```

**Filters:** `name` (partial match).

#### Get price resource type

```
GET /price-resource-types/{id}
```

**Response fields:**

| Field           | Type    | Description                    |
|-----------------|---------|--------------------------------|
| `id`            | integer | Internal ID                    |
| `name`          | string  | Display name                   |
| `description`   | string  | Description (nullable)         |
| `internal_name` | string  | Internal identifier            |
| `order`         | integer | Display order                  |
| `created_at`    | string  | ISO 8601 timestamp             |
| `updated_at`    | string  | ISO 8601 timestamp             |

---

### 6. Classifications

Reference data — VM classifications used for billing and reporting.

#### List classifications

```
GET /classifications
```

**Filters:** `name` (partial match).

#### Get classification

```
GET /classifications/{id}
```

**Response fields:** `id`, `name`, `description`, `created_at`, `updated_at`.

---

### 7. Guest Operating Systems

Reference data — guest OS types.

#### List guest OS

```
GET /guest-os
```

**Filters:**

| Parameter     | Type   | Description                                |
|---------------|--------|--------------------------------------------|
| `name`        | string | Partial name match                         |
| `family`      | string | OS family: `windows`, `linux`, or `macos`  |
| `architecture`| string | Architecture: `16`, `32`, or `64`          |

#### Get guest OS

```
GET /guest-os/{id}
```

**Response fields:** `id`, `family`, `architecture`, `name`, `created_at`, `updated_at`.

---

### 8. Guest OS Licenses

Reference data — OS license types.

#### List guest OS licenses

```
GET /guest-os-licenses
```

**Filters:** `name` (partial match).

#### Get guest OS license

```
GET /guest-os-licenses/{id}
```

**Response fields:** `id`, `name`, `created_at`, `updated_at`.

---

### 9. Source Data Types

Reference data — types of data collected from cloud providers.

#### List source data types

```
GET /source-data-types
```

**Filters:** `name` (partial match).

#### Get source data type

```
GET /source-data-types/{id}
```

**Response fields:** `id`, `name`, `description`, `created_at`, `updated_at`.

---

### 10. Sources (admin token only)

```
GET /sources
GET /sources/{id}
```

Organisation tokens receive 403 `This endpoint is not available for organisation tokens`. Do not call it with a customer token.

The response is `id`, `name`, `base_url`, `enabled`, `source_type`, and `organisation`. It does not include passwords, API keys, or tokens.

---

### 11. Operation status

```
GET /operations/{operationId}
```

Poll an async VM operation started by deploy or another 202 response. The id is `operation_id` from that response. An organisation token can read only its own org's operations. Unknown or expired ids return 404.

**Response fields:** `operation_id`, `status` (`queued`, `deploying`, `completed`, `failed`, and similar in-progress values), `operation_type`, `vm_name`, `description`, `started_at`, `error` (null until a failure).

---

### 12. S3 Buckets

Self-service object storage. Each bucket has its own access key that works only for that bucket. Buckets accept connections from New Zealand IP addresses only. Use path-style addressing (rclone `force_path_style = true`, AWS CLI `addressing_style = path`) against the bucket's `endpoint`.

Every call runs straight away (no operation to poll). One S3 change per organisation runs at a time; a second one gets 409 `busy`.

#### List buckets

```
GET /s3-buckets
```

Offset pages. Filters: `organisation_id` (numeric), `name` (partial match). Reads work with both token types.

#### Get bucket

```
GET /s3-buckets/{id}
```

**Response fields:** `id`, `organisation_id` (numeric), `name`, `access_key_id`, `permission_groups`, `allowed_cidrs` (empty means no IP restriction beyond New Zealand), `endpoint`, `addressing_style` (`path`), `created_at`, `updated_at`. Never includes a secret.

#### Create bucket (organisation token)

```
POST /s3-buckets
{"name": "backups", "permission_groups": ["read", "write"]}
```

- `name` (required): lowercase letters, numbers and hyphens. The organisation prefix (for example `s001-`) is added if missing, so `backups` and `s001-backups` both create `s001-backups`. Names are unique across the platform; the full name is 3–63 characters.
- `permission_groups` (optional, default all): `read`, `write`, `delete`, `retention` (retention and legal hold), `restore`. At least `read` or `write`. Listing the bucket is always allowed.
- `allowed_cidrs` (optional): public IPv4 `/32`s, at most 20. Only while IP allowlists are enabled; otherwise 422.
- Needs a payment method on the account (422 otherwise) and stays within the organisation's bucket limit (409 `limit_reached`).

Returns 201 with `data` (the bucket) and `credentials`: `access_key_id`, `secret_access_key`, `endpoint`, `addressing_style`. **The secret is shown once.**

#### Rotate key (organisation token)

```
POST /s3-buckets/{id}/rotate-key
```

Issues a new key and deletes the current one, which stops working immediately. Same `credentials` block as create. Use it when the user has lost the secret.

#### Change permissions (organisation token)

```
PUT /s3-buckets/{id}/permissions
{"permission_groups": ["read"]}
```

Replaces the groups. The current key's rights change immediately; no new secret.

#### Change IP allowlist (organisation token)

```
PUT /s3-buckets/{id}/allowlist
{"allowed_cidrs": ["203.0.113.10/32"]}
```

Replaces the list; `[]` removes the restriction. It does not lift the New Zealand-only restriction. Returns 404 while IP allowlists are not enabled.

#### Delete bucket (organisation token)

```
DELETE /s3-buckets/{id}
```

The bucket must be empty (409 `bucket_not_empty` otherwise). Cannot be undone.

#### Empty and delete bucket (organisation token)

```
POST /s3-buckets/{id}/empty-and-delete
{"confirm_name": "s001-backups"}
```

Permanently deletes every object, old version and unfinished upload, then the bucket. `confirm_name` must equal the full bucket name. **Confirm with the user before calling.** One call empties for about 30 seconds: a large bucket returns 409 `empty_incomplete` with `retryable: true`, and calling again carries on. Objects under retention or legal hold are never removed (409 `objects_locked`).

#### S3 errors

Failures from object storage add `reason` and `retryable` to the usual body:

```json
{"error": "Conflict", "reason": "bucket_taken", "message": "...", "retryable": false}
```

| Status | `reason` |
|--------|----------|
| 422 | `invalid_name`, `invalid_permissions`, `invalid_allowlist` |
| 409 | `bucket_taken`, `limit_reached`, `busy`, `bucket_not_empty`, `empty_incomplete`, `objects_locked`, `account_conflict`, `key_limit` |
| 503 | `upstream` |

Retry only when `retryable` is true (`busy`, `empty_incomplete`, `upstream`). The `message` is safe to show the user.

---

### 13. Firewall Rules (port forwarding)

Forward a port on the organisation's public IP to a VM. Rules are per region: pass `deployment_preset_id` (from `GET /deployment-presets`) or leave it out for the default region. Organisation token for a self-service organisation; only that organisation's own rules. The network exists once a VM has been deployed in the region. Changes apply on the firewall straight away (no operation to poll).

#### List rules

```
GET /firewall-rules?deployment_preset_id=3
```

`meta`: `region`, `network_provisioned`, `public_ip`, `subnet`, `gateway_ip`, `max_rules`. Each rule: `name` (listed form, lowercase with hyphens), `protocol`, `external_port`, `internal_ip`, `internal_port`, `source_addresses` (empty = any address), `expires_at` (null = never), `connect_to` (`public_ip:external_port`). With no network yet, `network_provisioned` is false and `data` is empty.

#### Add a rule

```
POST /firewall-rules
{"name": "ssh", "external_port": 22022, "internal_ip": "172.22.0.10", "internal_port": 22,
 "source_addresses": ["203.0.113.10/32"], "expires_in": "1h"}
```

- `name`: letters, numbers, hyphens, underscores, spaces; at most 32 characters. It is listed lowercase with hyphens (`Web Server` → `web-server`); the response returns that listed name.
- `protocol`: `tcp` (default) or `udp`.
- `internal_ip`: the VM's address from `GET /vm-data/{id}/state`; must be in `meta.subnet` and not the gateway.
- `source_addresses` (optional): IPv4 addresses or CIDRs allowed to connect, at most 20. **Prefer restricting to the user's own IP** for SSH and admin ports.
- `expires_in` (optional): `never` (default), `1h`, `4h`, `24h`, `7d`. The rule is removed automatically after that.
- At most 50 rules per region. An external port + protocol, an internal IP + port + protocol, and a listed name can each be used once.

Returns 201 with the rule, including `connect_to`.

#### Delete a rule

```
DELETE /firewall-rules/{name}?deployment_preset_id=3
```

`name` is the listed name. Traffic to the port stops straight away.

#### Firewall errors

Refusals add `reason` (and `field` for input problems) and `retryable`:

| Status | `reason` |
|--------|----------|
| 422 | `invalid_name`, `invalid_port`, `invalid_internal_ip`, `invalid_source_addresses`, `invalid_expiry`, `invalid_protocol` |
| 409 | `network_not_provisioned` (deploy a VM in the region first), `region_not_ready`, `limit_reached`, `duplicate_port`, `duplicate_destination`, `duplicate_name`, `ambiguous`, `busy` |
| 404 | `not_found` |
| 503 | `upstream` |

Retry only when `retryable` is true (`busy`, `upstream`).

#### Make a new Linux VM reachable over SSH

1. `POST /ssh-keys` (or upload the user's key), then `POST /vm-data` with `ssh_key_id`.
2. Poll `GET /operations/{operation_id}` until `status` is `completed` (a 404 there means it finished and the record expired). Find the VM's `id` in `GET /vm-data/costs` (match `name`), then call `GET /vm-data/{id}/state` until `ip_addresses` is not empty.
3. `POST /firewall-rules` from a free external port to that IP on port 22, limited to the user's IP.
4. Connect: `ssh -i <key> -p <external_port> asi@<public_ip>` (the key's user is the template's cloud-init user, normally `asi`).

---

### 14. AI Inference

Pay-per-token LLM access for self-service organisations: turn it on, see models and prices, and manage the API keys used to call the inference endpoints. Organisation token for a self-service organisation; only that organisation. Every endpoint returns 404 while ASI does not offer AI Inference.

#### Status

```
GET /ai-inference
```

`data`: `enabled`, `blocked` (requests refused by ASI), `can_enable`, `enable_blocker` (`reason` and `message`, or null), `base_urls` (`openai` for OpenAI-compatible clients, `anthropic` for Anthropic-compatible clients), `limits` (`requests_per_minute`, `tokens_per_minute`; null until turned on), `currency` (`NZD`), `prices_exclude_gst`, `models` (`slug` to send as `model`, `name`, `context_window`, `status`, `price_per_million_tokens` with `input`, `cached_input`, `output`), `max_active_keys`. Before it is turned on, `models` shows what turning it on would give.

#### Turn on / off

```
POST /ai-inference/enable
POST /ai-inference/disable
```

**Turning it on starts per-token billing** to the card on file. Confirm with the user first. It needs a payment method (422 `payment_method_required`). If ASI turned it off, only ASI can turn it back on (409 `disabled_by_asi`). Turning it off stops every key within a few seconds; keys and usage are kept, usage so far this month is still billed, and it can be turned back on.

#### Keys

```
GET    /ai-inference/keys
GET    /ai-inference/keys/{id}
POST   /ai-inference/keys          {"name": "Claude Code", "expires_on": "2026-12-31"}
POST   /ai-inference/keys/{id}/revoke
DELETE /ai-inference/keys/{id}
```

- Each key: `id`, `name`, `prefix`, `status` (`active`, `expired`, `revoked`), `created_at`, `expires_at`, `last_used_at` (updated hourly), `revoked_at`, `revoked_by_asi`.
- Create: `name` 1 to 100 characters; `expires_on` (optional, YYYY-MM-DD) means the key works to the end of that day, New Zealand time. **The 201 response holds `secret`, shown once and never stored.** Give it to the user straight away; suggest they keep it in an environment variable (for example `ASI_INFERENCE_KEY`), not in code. At most 20 active keys (409 `limit_reached`).
- Revoke: anything using the key stops within a few seconds. Cannot be undone.
- Delete: only a revoked or expired key (409 `must_revoke_first`). It leaves the list; its usage stays on the bills.

Call the models with the secret as the API key (Bearer token): OpenAI-compatible clients at `base_urls.openai` (for example `POST {openai}/chat/completions` with `"model": "<slug>"`), Anthropic-compatible clients at `base_urls.anthropic`.

#### AI Inference errors

Refusals add `reason` and `retryable`:

| Status | `reason` |
|--------|----------|
| 422 | `invalid_name`, `invalid_expiry`, `payment_method_required` |
| 409 | `not_enabled`, `disabled_by_asi`, `limit_reached`, `must_revoke_first`, `no_prices` |
| 403 | `not_self_service` |
| 503 | `upstream` (retryable) |

Usage and cost are billed as AI Inference lines on `/billings`.

---

## Error Responses

| Status | Meaning        | Example                                                                 |
|--------|----------------|-------------------------------------------------------------------------|
| 400    | Bad request    | `{"error":"Bad Request","message":"Month must be between 1 and 12."}` or admin token omitted `organisation_id` on `/vm-data/costs` |
| 401    | Unauthorized   | `{"error": "Unauthorized", "message": "Invalid or missing bearer token"}` |
| 403    | Forbidden      | Admin token on a write, or an organisation token on `GET /sources`     |
| 404    | Not found      | `{"error": "Not Found", "message": "VM data not found"}`               |
| 409    | Conflict       | A VM operation is already in progress                                  |
| 422    | Unprocessable  | `page` > 1 on `/vm-data`, VM not on Nutanix, or VM must be powered off |
| 429    | Rate limited   | `{"message":"Too Many Attempts."}` for the 60/minute limit. Deploy uses `{"error":"Too Many Requests",...}` |
| 503    | Unavailable    | Template or live VM state could not be retrieved                       |

---

## Example Requests

### List all powered-on VMs

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data?power_state=true"
```

### Get billing for March 2026

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/billings?month=3&year=2026"
```

### List VMs with at least 4 vCPUs and 8 GiB memory

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data?vcpus_min=4&memory_size_gib_min=8"
```

### Follow the VM snapshot cursor (do not send `page`)

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data?per_page=50"
# then follow links.next, or:
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data?per_page=50&cursor={{CURSOR}}"
```

### Latest billing batch for one month (not the invoice total)

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/billings/summary?month=3&year=2026"
```

### Get change history for a VM

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data/42/history"
```

### Get per-VM costs for current month

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data/costs"
```

### Get per-VM costs for a previous month

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-data/costs?month=3&year=2026"
```

### Deploy a minimal VM

```bash
# 1. Get available templates
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/vm-templates"

# 2. Deploy — vm_name is required (not "name"); use template_ext_id from above
curl -X POST \
     -H "Authorization: Bearer {{API_TOKEN}}" \
     -H "Content-Type: application/json" \
     -d '{
       "vm_name": "myvm01",
       "template_ext_id": "{{TEMPLATE_EXT_ID}}",
       "vcpus": 1,
       "memory_gib": 4
     }' \
     "https://cloud.asi.nz/api/v1/vm-data"

# 3. Poll until complete
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/operations/{{OPERATION_ID}}"
```

> **Note:** The body field is `vm_name` (not `name`). The name must be 1–63 characters, start and end with alphanumeric, and contain only letters, digits, hyphens, underscores, periods, and spaces.

### Get your organisation's pricing

```bash
curl -H "Authorization: Bearer {{API_TOKEN}}" \
     "https://cloud.asi.nz/api/v1/prices"
```

---

## Tips for Querying

1. **Read scope** — an organisation token sees its org and direct children. Filter with the numeric `organisation_id` when you need one of them. Writes stay on the token owner.
2. **Page with `per_page=100`** on offset lists and follow `links.next`. On `/vm-data`, follow the cursor. Never send `page` greater than 1 to `/vm-data`.
3. **Combine filters** — `?power_state=true&vcpus_min=4&memory_size_gib_min=8` on `/vm-data`.
4. **Check `meta.total`** on offset lists before walking every page. `/vm-data` has no total.
5. **Reference data** (price-resource-types, classifications, guest-os, guest-os-licenses, source-data-types) is a small lookup. Fetch it once.
6. **Timestamps are ISO 8601** in the Pacific/Auckland timezone.
7. **Boolean values** accept `true`/`false` or `1`/`0`.
8. **Storage units**: `total_storage_tib` and `used_storage_tib` are tebibytes (TiB). Memory (`memory_size_gib`) is gibibytes (GiB).
9. **Per-VM costs** — `/vm-data/costs` is one row per VM for that month. It is an estimate. It does not include backup, object storage, or fixed charges, so it is not the invoice. S3 bucket usage is billed from `/billings` (S3 lines), not from `/s3-buckets`. Official billing is `/billings`, using the largest batch.
10. **Do not dedupe `/vm-data` page 1.** Those rows are hourly snapshots ordered by name, and a cursor page is not "the latest of every VM". Current inventory is `/vm-data/costs`. Live power and IPs are `GET /vm-data/{id}/state` (422 when the VM is not on Nutanix).
