# Pantech Dynamics > Pantech Dynamics is a cloud platform in Africa: virtual machines (VPS), VPC networks, firewalls, public IPs, volumes, snapshots, managed databases (PostgreSQL, MySQL, MariaDB) and managed Kubernetes, with signed webhooks for events, billed in NGN or USD. Its public API is at https://api.pantechdynamics.com/public/v1 and takes an API key (Authorization: Bearer PAN_…). The API's OpenAPI 3.1 contract: https://docs.pantechdynamics.com/openapi/public-api.yaml (JSON: https://docs.pantechdynamics.com/openapi/public-api.json); collections of it for Postman (https://docs.pantechdynamics.com/postman/pantech-public-api.postman_collection.json), Insomnia (https://docs.pantechdynamics.com/insomnia/pantech-public-api.insomnia.json) and Bruno (https://docs.pantechdynamics.com/bruno/pantech-public-api.bruno.zip). Every write needs an Idempotency-Key header and returns an operation to poll; errors are problem documents with a stable `code`. Agents: read https://docs.pantechdynamics.com/agent-brief.md first, and "Using the API from an AI agent" before acting on anyone's infrastructure. MCP clients can connect to the hosted MCP server at https://mcp.pantechdynamics.com/mcp (https://docs.pantechdynamics.com/ai/connect-your-ai); SDKs: Go github.com/Pantech-Dynamics/pantech-go, Python pantech, JavaScript @pantechdynamics/sdk (https://docs.pantechdynamics.com/sdks). --- # API reference Source: https://docs.pantechdynamics.com/api Manage instances, networks, storage, databases and keys from code, whether that is scripts, CI pipelines, Terraform or AI agents, with an API key. The public API does everything the console does for your infrastructure: create and manage instances, VPC networks, firewalls, public IPs, volumes, snapshots, managed databases, SSH keys and webhooks, and read what they cost. ```http https://api.pantechdynamics.com/public/v1 ``` ## Your first three calls You need an [API key](https://docs.pantechdynamics.com/getting-started/api-keys). Keep it in an environment variable, never in code. ```console $ export PANTECH_API_KEY=PAN_… ``` 1. Check the key, and what it may do: ```console $ curl https://api.pantechdynamics.com/public/v1/me \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` 2. See what you can create. Plans are instance sizes; images are operating systems: ```console $ curl https://api.pantechdynamics.com/public/v1/plans \ -H "Authorization: Bearer $PANTECH_API_KEY" $ curl https://api.pantechdynamics.com/public/v1/images \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` 3. List your instances: ```console $ curl https://api.pantechdynamics.com/public/v1/instances \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` From there, [Examples](https://docs.pantechdynamics.com/api/examples) builds a server end to end. ## How the API works These rules hold for every endpoint, so learn them once. - **Authentication.** Send your key on every request: `Authorization: Bearer PAN_…`. A read key can call every `GET`; a write key can also create, change and delete. See [Authentication](https://docs.pantechdynamics.com/api/authentication). - **Changes are asynchronous.** A write returns `202 Accepted` with an `operation_id`. Poll [the operation](https://docs.pantechdynamics.com/api/operations) until its `status` is `succeeded` or `failed`. A new instance or database returns an order instead: an [instance order](https://docs.pantechdynamics.com/api/instance-orders) or a [database order](https://docs.pantechdynamics.com/api/database-orders). - **Every write needs an `Idempotency-Key`.** Send a fresh UUID per change, and the same one when you retry it. A retry never does the work twice. - **Lists are paged.** They return `{ "data": [...], "next_cursor": "…" }`. Pass `next_cursor` back as `cursor` until it is `null`. - **Errors say what went wrong.** Every error is a JSON problem document with a stable `code`, like `INSUFFICIENT_CREDIT`. Branch on `code`. See [Errors](https://docs.pantechdynamics.com/api/errors). - **Rate limits are per key.** Every response says how many requests you have left in `RateLimit-Remaining`. - **Money is in minor units.** `amount_minor: 1059200` in NGN is ₦10,592.00. In USD it is cents. [Requests and responses](https://docs.pantechdynamics.com/api/conventions) covers each of these in full. ## What you can manage ::::cards :::card[Instances]{href=/api/instances cta="Reference"} Create, power, resize, rebuild, restore and delete virtual machines. ::: :::card[VPC networks]{href=/api/networks cta="Reference"} Private networks, subnets and subnet firewall rules. ::: :::card[Public IPs]{href=/api/public-ips cta="Reference"} Addresses for VPC instances, with static NAT or port forwarding. ::: :::card[Security groups]{href=/api/security-groups cta="Reference"} Reusable firewalls for standard instances. ::: :::card[Volumes]{href=/api/volumes cta="Reference"} Data disks you attach, grow and detach. ::: :::card[Snapshots]{href=/api/snapshots cta="Reference"} Point-in-time copies, restores and automatic schedules. ::: :::card[Databases]{href=/api/databases cta="Reference"} Managed PostgreSQL, MySQL and MariaDB: create, secure, set passwords and delete. ::: :::: Also: [SSH keys](https://docs.pantechdynamics.com/api/ssh-keys), [webhooks](https://docs.pantechdynamics.com/api/webhooks) that tell you when something changes, the [catalogue](https://docs.pantechdynamics.com/api/catalogue) of plans, images and prices, [operations](https://docs.pantechdynamics.com/api/operations), [usage](https://docs.pantechdynamics.com/api/usage), [billing](https://docs.pantechdynamics.com/api/billing) and [your key](https://docs.pantechdynamics.com/api/account). ## For AI agents and tools The API is described by an OpenAPI 3.1 contract, and these docs are published as plain text, so an agent or a code generator can read them directly: - The [agent brief](https://docs.pantechdynamics.com/agent-brief.md): the whole API in one Markdown file, written for agents. Give an agent this link and a key. - The contract: [`/openapi/public-api.yaml`](https://docs.pantechdynamics.com/openapi/public-api.yaml) and [`/openapi/public-api.json`](https://docs.pantechdynamics.com/openapi/public-api.json). - Collections of every endpoint, ready to run, for [Postman](https://docs.pantechdynamics.com/postman/pantech-public-api.postman_collection.json), [Insomnia](https://docs.pantechdynamics.com/insomnia/pantech-public-api.insomnia.json) and [Bruno](https://docs.pantechdynamics.com/bruno/pantech-public-api.bruno.zip). [Collections and OpenAPI](https://docs.pantechdynamics.com/api/postman-openapi) shows how to import each. - A [Terraform provider](https://docs.pantechdynamics.com/terraform), for managing your infrastructure as code. - An index of these docs: [`/llms.txt`](https://docs.pantechdynamics.com/llms.txt). The whole API reference as one file: [`/llms-full.txt`](https://docs.pantechdynamics.com/llms-full.txt). Any single page as Markdown: `/md/` plus its path, such as [`/md/api/instances`](https://docs.pantechdynamics.com/md/api/instances). [Using the API from an AI agent](https://docs.pantechdynamics.com/api/ai-agents) explains how to give an agent safe access. ## What the API does not do Billing, payments, team members, invites, organization settings and API keys themselves are managed in the [console](https://console.pantechdynamics.com) only. A key cannot reach them, so a leaked key cannot change who has access or how you pay. --- # Authentication Source: https://docs.pantechdynamics.com/api/authentication Every request carries an API key. What a key can do depends on its scope and on the member who created it. ## Send your key Create a key in the console under **Organization → API keys** ([how](https://docs.pantechdynamics.com/getting-started/api-keys)). Send it on every request as a bearer token: ```http Authorization: Bearer PAN_… ``` ```console $ curl https://api.pantechdynamics.com/public/v1/me \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` The key alone decides who you are. There is no organization id in the path: a key belongs to one organization, and every request acts in it. Keys work only on the public API, at `/public/v1` on api.pantechdynamics.com. They are not accepted anywhere else, and a console sign-in session does not work on the public API. :::warning[Treat a key like a password] Keep it in your CI's secret store or an environment variable, never in code or a repository. If one leaks, revoke it in the console at once; it stops working within about 30 seconds. ::: ## Scopes A key has one of two scopes, chosen when you create it: | Scope | Can call | | --- | --- | | `read` | Every `GET`, and the price estimate | | `write` | Everything a read key can, plus every create, change and delete | ### Keys for AI apps A key the console makes when you [connect an AI app](https://docs.pantechdynamics.com/ai/connect-your-ai) has `kind` `ai_app`. It is listed under **Organization › AI apps**, not **API keys**. It can carry a third scope, `delete`, which only AI app keys use and only together with `write`: | AI app access | Scopes | | --- | --- | | Read only | `read` | | Manage | `write` | | Manage and delete | `write`, `delete` | For an AI app key, `write` does not cover deleting or wiping data. These calls also need `delete`: every `DELETE` except `DELETE /me`, rebuilding or restoring an instance, and lowering a snapshot schedule's `retention_count`. Each one's entry in the reference says so. Without `delete` the call returns `403 INSUFFICIENT_SCOPE` and nothing changes. Restoring a snapshot to a new volume overwrites nothing, so it needs only `write`. A standard key's `write` still covers all of these. Each endpoint in this reference says which scope it needs. Calling one your key's scope does not cover returns `403` with code `INSUFFICIENT_SCOPE`, and no change is made. Use a read key wherever you only need to look: dashboards, monitoring, cost reports. ## A key acts as its creator A key never has more access than the member who created it. If that member's role does not allow something, the key cannot do it either, and the call returns `403 FORBIDDEN`. If the member leaves the organization, their keys stop working. ## Check a key `GET /me` returns the key's organization, its scopes, when it expires, its `kind` (`standard` or `ai_app`) and, for an AI app, the app's `client_name`. It is the cheapest way to confirm a key works before a long job: ```json { "organization_id": "org_06gk3x9tq7m2v8r4y1c5d0zp8w", "project_id": "acc_06gk3x9tq7m2v8r4y1c5d0zp8w", "api_key": { "id": "key_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "github-actions", "scopes": ["read", "write"], "expires_at": "2026-12-31T09:30:00Z", "kind": "standard", "client_name": null } } ``` ## Revoke the key you are using `DELETE /me` revokes the key that sends it, and returns `204`. Use it when a job is done, or to sign out. It needs only `read`, and no `Idempotency-Key`. Calling it again returns `401`, because the key is gone. ```console $ curl -X DELETE https://api.pantechdynamics.com/public/v1/me \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` ## The Pantech-Client header Pantech's own tools say who they are with a `Pantech-Client` header. The MCP server sends `Pantech-Client: mcp/`. When an organization has [turned AI apps off](https://docs.pantechdynamics.com/ai/ai-apps-security#turn-ai-apps-off-for-the-organization), the API refuses any request carrying it, whatever key it uses. You do not need to send this header yourself. ## When authentication fails | Status | `code` | Meaning | | --- | --- | --- | | `401` | `UNAUTHENTICATED` | No key, or one that is mistyped, revoked or expired. Check the URL starts with `/public/v1` | | `403` | `INSUFFICIENT_SCOPE` | The key's scope does not cover this endpoint | | `403` | `AI_APPS_DISABLED` | AI app access is turned off for this organization. An owner can turn it on under **Organization › AI apps** | | `403` | `FORBIDDEN` | The key's creator is not allowed to do this | | `429` | `RATE_LIMITED` | Too many failed attempts from your address, or the key's budget is used up | Repeated requests with a wrong key from one address are slowed down. Fix the key before retrying. --- # Requests and responses Source: https://docs.pantechdynamics.com/api/conventions Asynchronous changes, idempotency, pagination, filtering, rate limits and the shape of every response. ## Requests - **Base URL:** every path in this reference starts here. ```http https://api.pantechdynamics.com/public/v1 ``` - **Bodies are JSON.** Send `Content-Type: application/json`. Unknown fields are rejected with `422` and the field's name, so a typo is caught instead of ignored. - **Ids** look like `vm_06gk3x9tq7m2v8r4y1c5d0zp8w`. The prefix says what it is: `vm_` instance, `ord_` order, `op_` operation, `net_` VPC, `snet_` subnet, `aclr_` firewall rule, `pip_` public IP, `pfr_` port forwarding rule, `sg_` security group, `vol_` volume, `snap_` snapshot, `sshk_` SSH key, `sshg_` SSH access grant. - **Times** are RFC 3339 in UTC: `2026-10-03T09:30:00Z`. - **Money** is an integer in minor units with its currency: `{ "amount_minor": 1059200, "currency": "NGN" }` is ₦10,592.00. USD amounts are cents. ## Responses - **One resource** comes back as the object itself. - **A list** comes back in an envelope: ```json { "data": [ … ], "next_cursor": "eyJhdCI6…" } ``` - **No value is `null`,** never an empty string or a zero that could be mistaken for a real one. A field is always present. - **Every response** carries `X-Request-Id`. Quote it if you contact support. ## Asynchronous changes Creating, changing and deleting infrastructure takes time, so writes do not wait for it. A write returns `202 Accepted` with the operation doing the work: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` The `Location` header points at it. Poll `GET /operations/{operation_id}` every few seconds until `status` is `succeeded` or `failed`, then read the resource. Or open `GET /operations/{operation_id}/events`: a Server-Sent Events stream that sends the operation each time it changes and closes when it finishes. ```sh # Wait for an operation to finish. Needs jq. while :; do status=$(curl -s "https://api.pantechdynamics.com/public/v1/operations/$OP" \ -H "Authorization: Bearer $PANTECH_API_KEY" | jq -r .status) case "$status" in succeeded|failed) break ;; esac sleep 3 done echo "$status" ``` **A new instance is different.** It is paid for before it is built, so `POST /instances` returns an [order](https://docs.pantechdynamics.com/api/instance-orders). Poll the order until its `status` is `provisioned`; the instance is then ready. A resource also reports its own progress: `desired_state` is what you asked for, `observed_state` what the platform last saw. When they match, the change has landed. ## Idempotency Every `POST`, `PUT` and `DELETE` needs an `Idempotency-Key` header: a unique value per change, such as a UUID. ```http Idempotency-Key: 5f1c2a34-8d4e-4b8e-9a51-0e2f6f7b9c10 ``` If a request fails on the network, or you are not sure it arrived, **send it again with the same key**. You get the original result, and the change is never made twice, so you will not create two instances or pay twice. Using the same key for a different request returns `409 IDEMPOTENCY_CONFLICT`. Make a new key for each new change, and keep it until that change is done. :::note[Ten exceptions] These do not replay a retry the usual way: - `POST /ssh-keys`: if generating a key pair fails on the network, list your SSH keys before trying again. A second attempt with the same name returns `409`, and a generated private key is only ever shown once. - `DELETE /ssh-keys/{ssh_key_id}`: once the key is gone, a repeat returns `404`. Treat that as done. - `POST /instances/{instance_id}/console`: each call opens a new console URL. Retrying is safe. - `POST /instances/{instance_id}/volume-key-credential`: each call issues a new credential, shown once, and ends the previous one for that instance. If a response is lost, call again and use the new credential. - `POST /webhooks`: a retry with the same key returns the webhook the first request made, but with `secret: null`: the secret is only ever shown once. If you lost it, rotate it. - `PATCH /webhooks/{webhook_id}`: not replayed, but applying the same change twice leaves the webhook the same. - `DELETE /webhooks/{webhook_id}`: once the webhook is gone, a repeat returns `404`. Treat that as done. - `POST /webhooks/{webhook_id}/test`: each call sends another test event. - `POST /webhooks/{webhook_id}/rotate-secret`: each call rotates again, and only the latest secret and the one before it sign. If a response is lost, rotate again and use the new secret. - `POST /webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver`: a retry while the delivery is being sent returns `409 WEBHOOK_DELIVERY_IN_PROGRESS`. Treat that as done. ::: ## Pagination Lists return up to `limit` items (default 50, at most 100) and a `next_cursor`. Pass it back as `cursor` for the next page; it is `null` on the last page. ```console $ curl "https://api.pantechdynamics.com/public/v1/instances?limit=100&cursor=$NEXT" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` A cursor belongs to the filters it was made with. Changing the filters and keeping the cursor returns `400 INVALID_CURSOR`; start again without one. ## Filtering The lists of instances, VPCs, subnets, security groups, volumes and snapshots take three filters: - `q`: names containing this text. - `desired_state`: e.g. `running` or `stopped` for instances, `present` for everything else. - `observed_state`: e.g. `running`, `stopped`, `failed` for instances, `active` or `failed` for everything else. ```console $ curl "https://api.pantechdynamics.com/public/v1/instances?observed_state=failed" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` ## Rate limits Each key has a budget of requests per minute. Every response to a request with a valid key says where you stand: | Header | Meaning | | --- | --- | | `RateLimit-Limit` | Requests allowed in each window | | `RateLimit-Remaining` | Requests left in this window | | `RateLimit-Reset` | Seconds until the window resets | Past the limit you get `429 RATE_LIMITED` with `Retry-After` in seconds. Wait that long, then retry with the same `Idempotency-Key`. Slow down before you get there: when `RateLimit-Remaining` is low, wait for `RateLimit-Reset`. ## Retries | Response | Retry? | | --- | --- | | Network error, timeout | Yes, with the same `Idempotency-Key` | | `429` | Yes, after `Retry-After` seconds | | `503`, `500` | Yes, with backoff (1 s, 2 s, 4 s…), same `Idempotency-Key` | | `409 CONCURRENT_UPDATE` | Yes, same request and key | | Any other `4xx` | No, fix the request first | ## Versioning The version is in the path: `/public/v1`. Within v1, changes only add things: new endpoints, new optional request fields and new response fields. Write clients that ignore fields they do not know. Anything that would break a client comes as `/public/v2`, announced in the [changelog](https://docs.pantechdynamics.com/changelog) with time to move. --- # Errors Source: https://docs.pantechdynamics.com/api/errors Every error is a problem document with a stable code. Branch on the code, show the detail, and retry only what is worth retrying. ## The shape of an error Every error is `application/problem+json` (RFC 9457): ```json { "type": "https://api.pantechdynamics.com/problems/insufficient-credit", "title": "Insufficient credit", "status": 402, "code": "INSUFFICIENT_CREDIT", "detail": "This instance needs NGN 15,040.00 of credit upfront and NGN 2,000.00 is available. Top up your credit or add a card, then try again.", "request_id": "req_6Yq8mB2x0dKpL4vS" } ``` - `code` is the stable, machine-readable reason. **Branch on it.** It never changes within v1. - `detail` explains this occurrence for a person. Show it, log it, but do not parse it, because its wording can change. - `request_id` identifies the request. Quote it to support. `request_id` is also in every response's `X-Request-Id` header, errors or not, so log it with each call you make. A validation error lists every bad field in `errors`, so you can fix them all at once: ```json { "status": 422, "code": "VALIDATION_FAILED", "title": "Validation failed", "detail": "plan_slug: must identify an active plan.", "request_id": "req_6Yq8mB2x0dKpL4vS", "errors": [ { "field": "plan_slug", "code": "PLAN_NOT_FOUND", "message": "must identify an active plan" } ] } ``` Its `detail` names each bad field and what it must be (`field: message`, separated by `;`), so it is useful on its own. Show each entry of `errors` beside its field, and branch on its `code`. ## By status | Status | Means | Retry? | | --- | --- | --- | | `400` | The request could not be read | No, fix it | | `401` | No valid API key | No, fix the key | | `402` | Not enough credit to pay | After topping up | | `403` | The key may not do this | No | | `404` | Nothing with that id in your organization | No | | `409` | Not possible in the resource's current state | Sometimes; see the code | | `413` | Body over 64 KiB | No | | `422` | A field is invalid; see `errors` | No, fix it | | `429` | Rate limited | Yes, after `Retry-After` | | `500`, `503` | Our side failed | Yes, with backoff and the same `Idempotency-Key` | ## Request and authentication codes | `code` | Status | What to do | | --- | --- | --- | | `INVALID_REQUEST_BODY` | 400 | Send valid JSON | | `IDEMPOTENCY_KEY_REQUIRED` | 400 | Add an `Idempotency-Key` header | | `INVALID_IDEMPOTENCY_KEY` | 400 | Keep the key to 255 characters | | `INVALID_FILTER` | 400 | Use a listed filter value | | `INVALID_CURSOR` | 400 | Start the list again without a cursor | | `INVALID_PAGE` | 400 | Send a `limit` from 1 to 100, or leave it out for 50 | | `INVALID_CALLER_ADDRESS` | 400 | Retry from a normal internet connection | | `UNAUTHENTICATED` | 401 | Send a valid, unexpired key, and check the URL starts with `/public/v1` | | `INSUFFICIENT_SCOPE` | 403 | Use a key with `write` scope | | `FORBIDDEN` | 403 | The key's creator lacks the role for this | | `RESOURCE_NOT_FOUND` | 404 | Check the id and the organization | | `NOT_FOUND` | 404 | The feature is not offered yet | | `ROUTE_NOT_FOUND` | 404 | Check the method and path | | `REQUEST_TOO_LARGE` | 413 | Send a smaller body | | `VALIDATION_FAILED` | 422 | Fix each field in `errors` | | `RATE_LIMITED` | 429 | Wait `Retry-After` seconds | | `INTERNAL` | 500 | Retry with backoff; quote `request_id` if it persists | ## Account and billing codes | `code` | Status | What to do | | --- | --- | --- | | `INSUFFICIENT_CREDIT` | 402 | Top up credit or add a card in the console | | `ACCOUNT_NOT_ACTIVE` | 409 | Finish activating billing in the console | | `ACCOUNT_SUSPENDED` | 409 | Pay the overdue invoice; nothing new can be created until then | | `PROVIDER_ACCOUNT_NOT_READY` | 409 | Your account is still being set up; retry in a minute | | `STATIC_NAT_LIMIT_EXCEEDED` | 403 | Your organization has as many one-VM public IPs as it may. Release one, or contact support for a higher limit | | `PUBLIC_IP_LIMIT_EXCEEDED` | 403 | Your organization has as many public IPs, of any purpose, as it may (20 by default). Release one, or contact support for a higher limit | | `CREDIT_PURCHASE_UNAVAILABLE` | 409 | Finish billing setup in the console | | `EMAIL_VERIFICATION_REQUIRED` | 409 | The key's creator must verify their email first | | `AMOUNT_OUT_OF_RANGE` | 422 | Buy an amount within the per-purchase limits | | `BILLING_UNAVAILABLE` | 503 | Retry in a few seconds | ## Resource state codes These mean the request was fine but the resource cannot do it now. | `code` | Status | What to do | | --- | --- | --- | | `INVALID_RESOURCE_STATE` | 409 | Wait for the resource to settle, then retry | | `CONCURRENT_UPDATE` | 409 | Retry the same request with the same key | | `IDEMPOTENCY_CONFLICT` | 409 | Use a new `Idempotency-Key` for a different request | | `INSTANCE_NAME_TAKEN` | 409 | Choose another name | | `INSTANCE_MUST_BE_STOPPED` | 409 | Stop the instance first | | `INSTANCE_HAS_PUBLIC_IP` | 409 | A public IP still points at it. [Detach](https://docs.pantechdynamics.com/networking/ip-addresses#move-an-address-to-another-vm) the address to keep it, or release it, first | | `INSTANCE_HAS_PORT_FORWARDS` | 409 | Delete its port forwards first | | `INSTANCE_ALREADY_HAS_PUBLIC_IP` | 409 | It has a static NAT address already; detach or release that one first | | `CONSOLE_INSTANCE_NOT_RUNNING` | 409 | Start the instance first | | `SSH_ACCESS_INSTANCE_NOT_RUNNING` | 409 | Start the instance first | | `SSH_ACCESS_SECURITY_GROUP_SHARED` | 409 | Give the instance its own security group | | `SSH_ACCESS_NOT_SUPPORTED_IN_ZONE` | 409 | Not available for VPC instances yet | | `SECURITY_GROUPS_NOT_SUPPORTED_IN_ZONE` | 409 | VPC instances use firewall rules | | `DEFAULT_SECURITY_GROUP_UNDELETABLE` | 409 | Edit its rules instead | | `FIREWALL_RULES_NOT_SUPPORTED` | 409 | Firewall rules are for VPC subnets | | `NETWORK_NAME_TAKEN` | 409 | Choose another name | | `NETWORK_NOT_READY` | 409 | Wait for the VPC to be active | | `NETWORK_HAS_INSTANCES` | 409 | Delete its instances first | | `NETWORK_HAS_PUBLIC_IPS` | 409 | Release its public IPs first | | `SUBNET_HAS_KUBERNETES_CLUSTERS` | 409 | Delete the [Kubernetes clusters](https://docs.pantechdynamics.com/kubernetes/overview) in the subnet first | | `PUBLIC_IP_NOT_FOR_PORT_FORWARDING` | 409 | Use a `port_forwarding` address | | `PUBLIC_IP_NOT_STATIC_NAT` | 409 | Only a `static_nat` address can be attached or detached; change a port forwarding address's rules, or a load balancer's targets, instead | | `PUBLIC_IP_HAS_PORT_FORWARDS` | 409 | Delete its rules first | | `PUBLIC_IP_HAS_LOAD_BALANCERS` | 409 | Delete the address's [load balancers](https://docs.pantechdynamics.com/networking/load-balancers) first | | `INSTANCE_BEHIND_LOAD_BALANCER` | 409 | Remove the instance from its load balancers' targets first | | `PORTS_IN_USE` | 409 | Another rule forwards these ports | | `SNAPSHOT_NAME_TAKEN` | 409 | Choose another name | | `SECURITY_GROUP_NAME_TAKEN` | 409 | Choose another name | | `SSH_KEY_NAME_TAKEN` | 409 | Choose another name | | `SSH_KEY_ALREADY_EXISTS` | 409 | The key is already registered | | `SNAPSHOT_SCHEDULE_NOT_FOUND` | 404 | No schedule is set; `PUT` one | | `PROVISIONING_UNAVAILABLE` | 503 | Retry with backoff | | `CONSOLE_UNAVAILABLE` | 503 | Retry shortly | | `OPERATION_STREAM_UNAVAILABLE`, `OPERATION_STREAM_TIMEOUT` | event | The operation's event stream ended; reconnect, or poll the operation | | `SECURITY_GROUP_ATTACHED_TO_DATABASE` | 409 | Detach the group from the database named in `detail`, then delete it | | `INSTANCE_NOT_SETTLED` | 409 | Wait until the instance is running or stopped with nothing else in progress | ## Database codes The database endpoints have codes of their own, with a page that explains each one: [database error codes](https://docs.pantechdynamics.com/databases/errors). In short: | `code` | Status | What to do | | --- | --- | --- | | `DATABASE_NOT_FOUND`, `DATABASE_ORDER_NOT_FOUND`, `DATABASE_SNAPSHOT_NOT_FOUND` | 404 | Check the ids; a snapshot is found only under its own database | | `DATABASE_NAME_TAKEN`, `DATABASE_SNAPSHOT_NAME_TAKEN` | 409 | Choose another name | | `DATABASE_NOT_RUNNING` | 409 | Start the database first (password changes and storage resizes need it running) | | `DATABASE_NOT_STOPPED` | 409 | Stop the database first | | `DATABASE_SNAPSHOT_NOT_READY` | 409 | Wait until the database is running or stopped | | `DATABASE_STORAGE_RESIZE_IN_PROGRESS` | 409 | Wait for the resize in flight to finish | | `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED` | 422 | Keep rules plus security-group ranges to 1,024 | | `DATABASE_HOSTNAME_UNAVAILABLE` | 503 | Retry with a new `Idempotency-Key` | | `DATABASE_STORAGE_UNAVAILABLE` | 503 | Storage is not offered in that zone yet; try later | | `DATABASES_UNAVAILABLE` | 503 | Databases are unavailable right now; try later | ## Kubernetes codes The [managed Kubernetes](https://docs.pantechdynamics.com/kubernetes/overview) endpoints have codes of their own; the guide's [troubleshooting](https://docs.pantechdynamics.com/kubernetes/overview#troubleshooting) explains each, field codes included. In short: | `code` | Status | What to do | | --- | --- | --- | | `KUBERNETES_CLUSTER_NOT_FOUND` | 404 | Check the cluster id (`k8s_…`). A deleted cluster answers this at once, although its nodes are destroyed 12 hours later | | `KUBERNETES_CLUSTER_NAME_TAKEN` | 409 | Choose another name | | `KUBERNETES_CLUSTER_LIMIT` | 409 | Delete a cluster, or contact support to raise the limit | | `KUBERNETES_CLUSTER_NOT_RUNNING` | 409 | Start the cluster first; scaling, autoscaling, API server access and upgrades need it running | | `KUBERNETES_CLUSTER_BUSY` | 409 | Wait until the cluster is running or stopped | | `KUBERNETES_CLUSTER_NOT_CHANGEABLE` | 409 | It is stopped, being deleted, or failed | | `KUBECONFIG_UNAVAILABLE` | 409 | Wait until the cluster is running | | `KUBERNETES_UNAVAILABLE` | 503 | Managed Kubernetes is not open on the platform yet | ## Private network codes From [adding a private database network interface](https://docs.pantechdynamics.com/databases/standard-vps) to a standard instance: | `code` | Status | What to do | | --- | --- | --- | | `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | 409 | Narrow the rules named in `detail` so none admits `10.250.0.0/20`, ICMP included | | `PRIVATE_NETWORK_NOT_AVAILABLE` | 409 | Only standard instances in `af-abj-1` can have one | | `PRIVATE_NETWORK_ALREADY_ATTACHED` | 409 | It already has one; read `private_network_ip` | | `PRIVATE_NETWORK_NOT_ATTACHED` | 409 | It has none to remove | | `PRIVATE_NETWORK_CHANGE_IN_PROGRESS` | 409 | Follow the attach or detach already running | ## Field codes These appear inside `errors` on a `422`, each with the `field` it is about. | `code` | Typical field | Means | | --- | --- | --- | | `REQUIRED` | any | The field is missing | | `UNKNOWN_FIELD` | any | Not a field of this request | | `PLAN_NOT_FOUND` | `plan_slug` | Not an active plan | | `PLAN_NOT_BIGGER` | `plan_slug` | Resize needs a bigger plan | | `IMAGE_NOT_FOUND` | `image_slug` | Not an active image | | `IMAGE_NOT_AVAILABLE_IN_ZONE` | `image_slug` | Not offered where this instance runs | | `ENCRYPTION_NOT_SUPPORTED` | `encrypted` | `encrypted` is `true`, but this image has no encrypted version available to your organization | | `ENCRYPTION_CONFLICT` | `encrypted` | `encrypted` is `false`, but `image_slug` is already an encrypted image | | `SSH_KEY_NOT_FOUND` | `ssh_key_id` | Not a key on this account | | `REGION_NOT_AVAILABLE` | `region` | Not available for this resource | | `SUBNET_REQUIRED` | `subnet_id` | Needed when `network_id` is set | | `SUBNET_NOT_FOUND` | `subnet_id` | Not a subnet on this account | | `SUBNET_NOT_IN_NETWORK` | `subnet_id` | Not in `network_id` | | `SUBNET_NOT_USABLE` | `subnet_id` | Being deleted or failed | | `SECURITY_GROUP_NOT_FOUND` | `security_group_id` | Not a group on this account | | `CONFIRM_NAME_MISMATCH` | `confirm_name` | Not the instance's current name | | `DISK_OFFERING_NOT_FOUND` | `disk_offering_slug` | Not an active offering | | `SIZE_REQUIRED` | `size_gb` | Needed for a custom-size offering | | `INVALID_SIZE` | `size_gb` | Must be a whole number of GB | | `INSTANCE_NOT_FOUND` | `instance_id` | Not an instance on this account | | `INSTANCE_NOT_RUNNING` | `instance_id` | Must be running | | `INSTANCE_WRONG_ZONE` | `instance_id` | Must be in the volume's zone | | `INSTANCE_NOT_IN_NETWORK` | `instance_id` | Must be in the address's VPC | | `NOT_ALLOWED` | `instance_id` | Not taken here | | `INVALID_MOUNT_POINT` | `mount_point` | Must be an absolute path | | `INVALID_RULE_NUMBER` | `number` | Must be 100–9999 | | `RULE_NUMBER_TAKEN` | `number` | Used by another rule | | `INVALID_PORTS` | `port_start` | Ports invalid for the protocol | | `INVALID_FIREWALL_RULE` | `protocol` | Direction, protocol or action invalid | | `PURPOSE_NOT_AVAILABLE` | `purpose` | Not available yet | | `INVALID_SSH_PUBLIC_KEY` | `public_key` | Not an OpenSSH public key | | `INVALID_NAME`, `NAME_TOO_LONG` | `name` | 1–255 characters | | `INVALID_METRIC_INTERVAL` | `interval` | One of `1h`, `24h`, `7d`, `30d` | | `INVALID_PERIOD` | `period` | One of `30d`, `7d`, `month`, `last_month` | | `INVALID_PLACEMENT` | `placement` | `standard` or `vpc` | | `UNSUPPORTED_CURRENCY` | `currency` | `NGN` or `USD` | | `INVALID_MACHINE` | `machines` | A line in the estimate is invalid | | `TOO_MANY_MACHINES` | `machines` | At most 50 lines | | `INVALID_DATABASE_NAME` | `name` | 1–255 characters | | `INVALID_DATABASE_ENGINE` | `engine` | `postgresql`, `mysql` or `mariadb` | | `INVALID_DATABASE_VERSION` | `version` | Not an offered version line | | `INVALID_DATABASE_ZONE` | `zone_id` | Not a zone you can use | | `DATABASE_VERSION_NOT_AVAILABLE_IN_ZONE` | `zone_id` | That version is not offered in that zone | | `DATABASE_SUBNET_REQUIRED`, `DATABASE_SUBNET_NOT_ALLOWED` | `subnet_id` | Required in `af-abj-2`, refused in `af-abj-1` | | `DATABASE_SUBNET_NOT_FOUND`, `DATABASE_SUBNET_WRONG_ZONE`, `DATABASE_SUBNET_NOT_USABLE` | `subnet_id` | Not a usable subnet of yours in `zone_id` | | `INVALID_DATABASE_ADMIN_USERNAME` | `admin_username` | 3–32 lowercase letters, digits, underscores; not reserved | | `INVALID_DATABASE_PASSWORD` | `password` | 16–128 printable characters, no spaces, quotes or backslashes | | `INVALID_DATABASE_STORAGE` | `storage_gb` | Plan's size to 2,000 GB, in 10 GB steps; a resize must grow | | `INVALID_DATABASE_SNAPSHOT_NAME` | `name` | 1–255 characters | | `INVALID_ACCESS_RULE` | `access_rules` | An IPv4 CIDR no wider than `/8`, never `0.0.0.0/0` | | `TOO_MANY_ACCESS_RULES` | `access_rules` | At most 50 | | `DUPLICATE_ACCESS_RULE` | `access_rules` | Each CIDR once | | `INVALID_DATABASE_SECURITY_GROUPS` | `security_group_ids` | At most 5, each once | | `DATABASE_SECURITY_GROUP_NOT_FOUND`, `DATABASE_SECURITY_GROUP_WRONG_ZONE`, `DATABASE_SECURITY_GROUP_NOT_USABLE` | `security_group_ids` | Your own usable groups in the database's zone | | `RULE_ALLOWS_PRIVATE_NETWORK` | `rules` | An instance on this group has a private network interface; leave `10.250.0.0/20` out | | `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | `security_group_id` | The instance has a private network interface; pick a group that admits nothing from `10.250.0.0/20` | | `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED` | `rules` | An attached database would get more than 1,024 ranges | | `INVALID_KUBERNETES_WORKER_COUNT` | `workers` | 1 to 10 | | `INVALID_KUBERNETES_AUTOSCALING` | `autoscaling` | `min_workers` and `max_workers` 1 to 10, minimum no more than maximum, `workers` within them; no `workers` while autoscaling is on | | `INVALID_API_ALLOWED_CIDRS` | `api_allowed_cidrs` | At most 20 IPv4 ranges, like `203.0.113.0/24` | | `API_ALLOWED_CIDRS_VPC_ONLY` | `api_allowed_cidrs` | Only clusters in a VPC zone can limit API server access | ## When an operation fails An accepted change (`202`) can still fail later. Its [operation](https://docs.pantechdynamics.com/api/operations) then ends `failed`, and its `failure` says why: ```json { "id": "op_…", "kind": "attach_private_network", "status": "failed", "failure": { "code": "SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK", "reason": "The instance's security group was changed to allow 10.250.0.0/20 before the interface was added. Narrow it, then try again." } } ``` Branch on `failure.code`, such as `DATABASE_STORAGE_GROW_FAILED` or `INSTANCE_PRIVATE_NETWORK_ATTACH_FAILED`; show `failure.reason`, whose wording can change. The resource is left as it was before. These are listed with the guide for each feature, for example the [database error codes](https://docs.pantechdynamics.com/databases/errors). Retry a failed operation with a **new** `Idempotency-Key`: the old one replays the failure. A change the platform itself could not carry out (creating, starting or resizing a machine, for example) fails with one of these codes, whatever the resource: | `failure.code` | Means | What to do | | --- | --- | --- | | `PROVISIONING_CAPACITY_UNAVAILABLE` | No capacity for it in the zone right now | Try a different plan or zone, or try again later | | `PROVISIONING_LIMIT_EXCEEDED` | Your account reached its limit for this kind of resource. A Kubernetes cluster or scale counts every node it could have, up to its autoscaling maximum, and fails before anything is created | Delete what you no longer use, ask for less, or contact support to raise the limit | | `PROVISIONING_ADDRESSES_EXHAUSTED` | No free addresses in the zone right now | Try again later, or choose another zone | | `PROVISIONING_IMAGE_UNAVAILABLE` | The image is not available in this zone right now | Choose another image or zone, or try again later | | `PROVISIONING_RESOURCE_BUSY` | The resource was busy with another change | Wait for it to settle, then retry | | `PROVISIONING_RESOURCE_CONFLICT` | A resource with these settings already exists | Use a different name or settings | | `PROVISIONING_RESOURCE_NOT_FOUND` | The resource no longer exists on the platform | Refresh, then retry | | `PROVISIONING_INVALID_REQUEST` | A setting is not valid here | Check the request, then retry | | `PROVISIONING_UNAVAILABLE` | The platform is temporarily unavailable | Retry in a few minutes | | `PROVISIONING_INTERNAL_ERROR` | Something failed on our side; we have been alerted | Retrying will not help; contact support with the operation id | | `PROVISIONING_FAILED` | The change could not be completed | Retry; contact support with the operation id if it keeps failing | New codes may be added within v1. Treat an unknown code by its status. --- # Examples Source: https://docs.pantechdynamics.com/api/examples Complete scripts for the common jobs, including a standard server, a web server in a VPC, nightly snapshots and a clean teardown. Each script runs as it is with `bash`, `curl` and `jq`, and a key in `PANTECH_API_KEY` (a write key, for anything that creates). Start every script with these helpers: ```sh title="pantech.sh" #!/usr/bin/env bash set -euo pipefail API="https://api.pantechdynamics.com/public/v1" # pt METHOD PATH [JSON]: call the API. Writes get a fresh Idempotency-Key. pt() { local method=$1 path=$2 body=${3:-} local args=(-sS --fail-with-body -X "$method" "$API$path" -H "Authorization: Bearer $PANTECH_API_KEY") if [ "$method" != GET ]; then args+=(-H "Idempotency-Key: $(uuidgen)"); fi if [ -n "$body" ]; then args+=(-H "Content-Type: application/json" -d "$body"); fi curl "${args[@]}" } # wait_op OPERATION_ID: wait for an operation to finish; fail if it failed. wait_op() { local status while :; do status=$(pt GET "/operations/$1" | jq -r .status) case "$status" in succeeded) return 0 ;; failed) pt GET "/operations/$1" | jq .failure >&2; return 1 ;; esac sleep 3 done } ``` :::info[Retrying safely] `pt` makes a new `Idempotency-Key` per call, which is right for a script run once. If you retry a failed call yourself, reuse its key so the change cannot happen twice. ::: ## A standard server A VPS with its own public IPv4 address, reachable over SSH with your key. ```sh source pantech.sh # Your public key, registered once. KEY_ID=$(pt POST /ssh-keys "$(jq -n --arg k "$(cat ~/.ssh/id_ed25519.pub)" '{name: "laptop", public_key: $k}')" | jq -r .id) # Order the instance. It is paid for, then built. ORDER=$(pt POST /instances "$(jq -n --arg key "$KEY_ID" \ '{name: "app-1", plan_slug: "starter", image_slug: "ubuntu-24-04", ssh_key_id: $key}')") ORDER_ID=$(echo "$ORDER" | jq -r .order_id) VM=$(echo "$ORDER" | jq -r .instance_id) # Wait for the order to finish. while :; do STATUS=$(pt GET "/instance-orders/$ORDER_ID" | jq -r .status) case "$STATUS" in provisioned) break ;; failed|payment_failed) echo "order $STATUS" >&2; exit 1 ;; esac sleep 5 done IP=$(pt GET "/instances/$VM" | jq -r .public_ipv4) echo "ssh ubuntu@$IP" # the image's user: ubuntu on Ubuntu, debian on Debian ``` The default security group drops all inbound traffic. Open SSH to your own address with a [security group](https://docs.pantechdynamics.com/api/security-groups) rule, or ask for [temporary SSH access](https://docs.pantechdynamics.com/api/instances). ## A web server in a VPC A private network with one subnet, a firewall that lets HTTPS in, an instance in the subnet, and a public IP mapped to it. ```sh source pantech.sh # 1. The VPC and a subnet. Wait for each before using it. OP=$(pt POST /networks '{"name": "prod", "cidr": "10.0.0.0/16", "region": "af-abj"}') NET=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" OP=$(pt POST "/networks/$NET/subnets" '{"name": "web", "cidr": "10.0.1.0/24"}') SUBNET=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" # 2. Let HTTPS in. Lower numbers are checked first. OP=$(pt POST "/subnets/$SUBNET/firewall-rules" \ '{"number": 100, "protocol": "tcp", "port_start": 443, "cidr": "0.0.0.0/0"}') wait_op "$(echo "$OP" | jq -r .operation_id)" # 3. An instance in the subnet. ORDER_ID=$(pt POST /instances "$(jq -n --arg s "$SUBNET" --arg k "$KEY_ID" \ '{name: "web-1", plan_slug: "developer", image_slug: "ubuntu-24-04", subnet_id: $s, ssh_key_id: $k}')" | jq -r .order_id) until [ "$(pt GET "/instance-orders/$ORDER_ID" | jq -r .status)" = provisioned ]; do sleep 5; done VM=$(pt GET "/instance-orders/$ORDER_ID" | jq -r .instance_id) # 4. A public IP mapped to it. OP=$(pt POST /public-ips "$(jq -n --arg n "$NET" --arg vm "$VM" '{network_id: $n, instance_id: $vm, purpose: "static_nat"}')") PIP=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" echo "https://$(pt GET "/public-ips/$PIP" | jq -r .address)" ``` ## Nightly snapshots Let the platform do it: a schedule takes a snapshot every day and keeps the newest seven. ```sh source pantech.sh pt PUT "/instances/$VM/snapshot-schedule" '{"frequency": "daily", "retention_count": 7}' ``` Or take one yourself before a risky change, and roll back if it goes wrong: ```sh source pantech.sh OP=$(pt POST "/instances/$VM/snapshots" '{"name": "pre-upgrade"}') SNAP=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" # … the upgrade went wrong. Everything written since the snapshot is lost: NAME=$(pt GET "/instances/$VM" | jq -r .name) pt POST "/instances/$VM/restore" "$(jq -n --arg s "$SNAP" --arg n "$NAME" '{snapshot_id: $s, confirm_name: $n}')" ``` ## Find what costs the most A read key is enough. ```sh source pantech.sh pt GET "/usage/by-resource?period=month" \ | jq -r '.data | sort_by(-.cost_minor) | .[:5][] | "\(.cost_minor / 100)\t\(.resource_kind)\t\(.resource_name)"' ``` ## Tear everything down Delete in the reverse of creation: what uses a thing goes before the thing. ```sh source pantech.sh wait_op "$(pt DELETE "/public-ips/$PIP" | jq -r .operation_id)" wait_op "$(pt DELETE "/instances/$VM" | jq -r .operation_id)" wait_op "$(pt DELETE "/subnets/$SUBNET" | jq -r .operation_id)" wait_op "$(pt DELETE "/networks/$NET" | jq -r .operation_id)" ``` Deleting an instance keeps a safety snapshot of its root disk for 24 hours. Volumes attached to it are detached, not deleted, so delete them separately. ## Walk every page of a list ```sh source pantech.sh CURSOR="" while :; do PAGE=$(pt GET "/instances?limit=100${CURSOR:+&cursor=$CURSOR}") echo "$PAGE" | jq -r '.data[] | "\(.id)\t\(.observed_state)\t\(.name)"' CURSOR=$(echo "$PAGE" | jq -r '.next_cursor // empty') [ -z "$CURSOR" ] && break done ``` --- # Postman, Insomnia, Bruno and OpenAPI Source: https://docs.pantechdynamics.com/api/postman-openapi Download a ready-to-run collection for Postman, Insomnia or Bruno, or the OpenAPI contract for any other tool or generated client. Every endpoint in this reference is in each of these downloads. They are all generated from the same contract the API is tested against, every time the reference is, so they always match it and each other. ::::cards :::card[Postman collection]{href=/postman/pantech-public-api.postman_collection.json cta="Download"} Every request, ready to run: grouped like this reference, with example bodies, your key and the `Idempotency-Key` already set up. ::: :::card[Insomnia collection]{href=/insomnia/pantech-public-api.insomnia.json cta="Download"} The same requests as an Insomnia export, with the key in its base environment. ::: :::card[Bruno collection]{href=/bruno/pantech-public-api.bruno.zip cta="Download"} The same requests as a Bruno collection folder, zipped, with the key as a secret environment variable. ::: :::card[OpenAPI contract]{href=/openapi/public-api.yaml cta="Download"} The OpenAPI 3.1 description of the API, for any other tool, code generators and AI agents. ::: :::: The contract is also published as [JSON](https://docs.pantechdynamics.com/openapi/public-api.json). The Postman collection and the contract are also at the top of the sidebar on every API page. ## Use it in Postman 1. In Postman, choose **Import**, and paste this link: ```text https://docs.pantechdynamics.com/postman/pantech-public-api.postman_collection.json ``` 2. Open the **Pantech Dynamics public API** collection, then its **Variables** tab. Put your [API key](https://docs.pantechdynamics.com/getting-started/api-keys) in the **Current value** of `apiKey`. The current value stays on your machine; it is not synced to your Postman workspace. 3. Send **Account → Describe this API key**. A `200` with your organization means the key works. Then try any request. Ids in the example URLs and bodies are placeholders: replace them with ids from your own account. For example, run **Instances → List instances** and copy an `id` into the `instance_id` path variable. :::note[What the collection sets for you] - `Authorization: Bearer {{apiKey}}` on every request, from the collection's auth. - `Idempotency-Key: {{$guid}}` on every `POST`, `PUT` and `DELETE`: a new value each time you press **Send**. To retry a change safely, replace it with the value you sent the first time. - `baseUrl`: the API's base URL, `/public/v1` on api.pantechdynamics.com. ::: When you import a newer collection, Postman offers to replace the one you have. Replacing it clears its variables, so if you update often, keep `apiKey` in a Postman environment instead. ## Use it in Insomnia 1. In Insomnia, choose **Import**, and paste this link, or pick the downloaded file: ```text https://docs.pantechdynamics.com/insomnia/pantech-public-api.insomnia.json ``` 2. Open the **Pantech Dynamics public API** collection, then **Base Environment**, and set `apiKey` to your [API key](https://docs.pantechdynamics.com/getting-started/api-keys). 3. Send **Account → Describe this API key**. Every request sends `Authorization: Bearer` with your key, and every write a fresh `Idempotency-Key` (`{% uuid 'v4' %}`). Ids in the URLs are placeholders: replace them with your own. ## Use it in Bruno Bruno keeps a collection as a folder of plain-text `.bru` files, which you can commit next to your code. 1. Download the [Bruno collection](https://docs.pantechdynamics.com/bruno/pantech-public-api.bruno.zip) and unzip it. 2. In Bruno, choose **Open Collection** and pick the unzipped `pantech-public-api` folder. 3. Choose the **Production** environment, and set its `apiKey`. It is a secret variable, so Bruno keeps its value on your machine and out of the files. 4. Send **Account → Describe this API key**. Writes send `Idempotency-Key: {{$randomUUID}}`, a new value each time. Path ids are in each request's **Params** tab: replace the placeholders with your own. ## Use the OpenAPI contract The contract describes every path, parameter, request body, response and error code. Most tools take it directly: - **Postman:** **Import** → paste the contract's link: ```text https://docs.pantechdynamics.com/openapi/public-api.yaml ``` Postman builds its own collection from it; the collection above is the same, with the auth and idempotency already set up. - **Insomnia** and **Bruno:** both import the contract too; the collections above are the same requests with the auth and idempotency already set up. - **Generated clients:** point a generator at the contract to get a typed client: ```console $ npx @openapitools/openapi-generator-cli generate \ -i https://docs.pantechdynamics.com/openapi/public-api.yaml \ -g typescript-fetch -o ./pantech-client ``` - **AI agents:** see [Using the API from an AI agent](https://docs.pantechdynamics.com/api/ai-agents). Every operation in the contract carries an `operationId` (such as `createInstance`), and `x-scope` says whether it needs a `read` or a `write` key. ## Keeping up to date Within v1 the API only adds: new endpoints, new optional fields, new response fields. A file you downloaded keeps working; download it again to see what is new. Changes are listed in the [changelog](https://docs.pantechdynamics.com/changelog). --- # Using the API from an AI agent Source: https://docs.pantechdynamics.com/api/ai-agents Connect an AI app through MCP, build an agent on the SDKs, or give any agent one link, a key scoped to what it should do, and the rules that keep it from doing harm. An AI agent can run your infrastructure through this API the same way a script does. There are three ways in, easiest first. ## Connect an AI app with MCP If you use Claude, ChatGPT, Cursor, VS Code, Windsurf, Codex, Gemini CLI or Grok, connect it to Pantech's MCP server. Paste this link into the app, then approve it in the console: ```text https://mcp.pantechdynamics.com/mcp ``` You choose the organization, the access (**Read only**, **Manage**, or **Manage and delete**) and when it expires. The app gets [ready-made tools](https://docs.pantechdynamics.com/ai/mcp-tools), and anything that costs money, opens access from the internet, or deletes asks you first. Step-by-step setup for each app: [Connect your AI](https://docs.pantechdynamics.com/ai/connect-your-ai). To run the server on your own machine instead, use `pantech-mcp stdio`. ## Build your own agent on an SDK Writing the agent yourself? The [SDKs](https://docs.pantechdynamics.com/sdks) for Go (`github.com/Pantech-Dynamics/pantech-go`), Python (`pantech`) and JavaScript (`@pantechdynamics/sdk`) handle idempotency keys, safe retries, pagination and waiting for operations, so your tools only describe what to do. ## Any other agent For an agent that can only make HTTP calls, the rest of this page is what to give it, and what to tell it. ## Give it one link The [agent brief](https://docs.pantechdynamics.com/agent-brief.md) is everything an agent needs in one Markdown file: the base URL, how to authenticate, the rules it must follow, common jobs step by step, and every endpoint with its scope, parameters and what it returns. It is generated from the same contract the API is tested against, so it is always current. ```text https://docs.pantechdynamics.com/agent-brief.md ``` How to hand it over depends on the agent: - **A chat assistant** (Claude, ChatGPT): start the conversation with "Read https://docs.pantechdynamics.com/agent-brief.md, then help me with my Pantech servers." Every page of this reference also has **Open in Claude** and **Open in ChatGPT** under its title, which do this for you with that page attached. - **A coding agent** working in your repository (Claude Code, Cursor and similar): save the brief next to your code and point the agent's instructions file at it. ```console $ curl -o docs/pantech-agent-brief.md https://docs.pantechdynamics.com/agent-brief.md ``` Then add a line such as "Before calling the Pantech API, read docs/pantech-agent-brief.md" to `AGENTS.md` or `CLAUDE.md`. - **An agent you build yourself:** put the brief in its system prompt, and turn the [OpenAPI contract](https://docs.pantechdynamics.com/openapi/public-api.yaml) into its tools. Each operation has an `operationId` (such as `createInstance`), typed parameters and bodies, and `x-scope` (`read` or `write`), which is what tool-calling frameworks need. For more detail, every page is available as Markdown at `/md/` plus its path (this one is [`/md/api/ai-agents`](https://docs.pantechdynamics.com/md/api/ai-agents)), and [`/llms-full.txt`](https://docs.pantechdynamics.com/llms-full.txt) is the whole reference in one file. To try calls yourself before an agent does, use the [Postman collection](https://docs.pantechdynamics.com/api/postman-openapi) or **Try it** on any endpoint. ## Give it the least access that works - **Start with a read key.** An agent that only answers questions, like "what is running?" or "what did last month cost?", never needs to write. A read key cannot create, change, delete or spend. - **Use a separate key per agent,** named after it (`ops-agent`, `cost-bot`), so its calls are easy to tell apart and to cut off. - **Give a write key a short expiry.** 7 or 30 days. Make a new one when it lapses. - **A write key spends money.** Creating an instance takes its initial payment from credit or your card, with no person in the loop. Keep credit at a level you are comfortable with. - **A write key can open a shell.** `POST /instances/{id}/console` returns a logged-in console. Do not give an agent a write key on an organization whose servers it should not log in to. Every request a key makes is recorded with its time, address, endpoint and result. Revoke the key in the console and it stops within about 30 seconds. An agent can also end its own key when its job is done: `DELETE /me` revokes the key that sends it. ## Rules to give the agent The brief already contains these rules. If your agent cannot fetch a URL, paste this short version into its instructions instead: ```text You manage Pantech Dynamics infrastructure through its public API. - Base URL: https://api.pantechdynamics.com/public/v1. Send the header Authorization: Bearer $PANTECH_API_KEY on every request. Keys work on this base URL only. - Call GET /me first. Its api_key.scopes says whether you may write. - Every POST, PUT and DELETE needs an Idempotency-Key header. Generate a new UUID for each new change. If a request fails or times out, retry it with the SAME key; never invent a new key for a retry. - Writes return 202 with an operation_id. Poll GET /operations/{id} every 3-5 seconds until status is "succeeded" or "failed" before relying on the result. POST /instances returns an order_id instead: poll GET /instance-orders/{id} until status is "provisioned". - Read the catalogue (GET /plans, /images, /disk-offerings) for valid slugs. Never guess a slug or an id. - Errors are JSON with a "code". Act on the code, not the wording. On 429 wait Retry-After seconds. On 5xx retry with backoff and the same key. On other 4xx, fix the request; do not repeat it unchanged. - Lists return {"data": [...], "next_cursor": ...}. Follow next_cursor until it is null before concluding something does not exist. - Amounts are integers in minor units: amount_minor 150000 NGN is N1,500.00. - Ask a human before anything destructive or expensive: deleting an instance, network, volume or snapshot; rebuild or restore (they erase data); resizing; creating more than one instance; opening the console. Say what it will cost first: POST /pricing/estimate prices a plan. ``` ## Patterns that work well - **Look before you change.** List or get the resource first, and confirm it is the one you mean. Names are not unique; ids are. - **Wait for each step.** A subnet must be `active` before an instance can go in it; an instance must be `running` before a volume attaches to it. Follow each operation to the end before the next step. - **Check `observed_state`, not just the operation.** When `observed_state` matches `desired_state`, the resource is where you asked it to be. - **Estimate before buying.** `POST /pricing/estimate` prices any mix of plans, storage, networks and snapshots, and creates nothing. - **Clean up in reverse order.** Delete what uses a thing before the thing: public IPs, then instances, then subnets, then the VPC. ## A worked exchange > **You:** Create a small Ubuntu server for a staging site. > > **Agent:** Calls `GET /me` (write scope, so that's fine), `GET /plans` and `GET /images`. Replies with the smallest plan's size, its monthly estimate and the initial payment, all read from `GET /plans`: "I'll install **ubuntu-24-04** on that plan with your SSH key `laptop`. Shall I go ahead?" > > **You:** Yes. > > **Agent:** Sends `POST /instances` with a new `Idempotency-Key`, follows the order to `provisioned`, reads the instance, and replies with its address and the SSH command. The agent asked before spending money, used only slugs it had read, and followed the order to the end before claiming success. --- # Instances Source: https://docs.pantechdynamics.com/api/instances Create, power, change and delete virtual machines (VPS). An instance is a virtual machine. A **standard** instance gets its own public IPv4 address and a security group. An instance created with a `subnet_id` lives in a [VPC](https://docs.pantechdynamics.com/api/networks) instead: it has a private address, and is reached through a [public IP](https://docs.pantechdynamics.com/api/public-ips). A standard instance can also be given a second interface on its zone's private database network, to reach [managed databases](https://docs.pantechdynamics.com/databases/standard-vps). Every change is asynchronous. Creating one returns an [order](https://docs.pantechdynamics.com/api/instance-orders); every other change returns an [operation](https://docs.pantechdynamics.com/api/operations) to follow. Endpoints: - `GET /public/v1/instances`: List instances - `POST /public/v1/instances`: Create an instance - `GET /public/v1/instances/{instance_id}`: Get an instance - `POST /public/v1/instances/{instance_id}/start`: Start an instance - `POST /public/v1/instances/{instance_id}/stop`: Stop an instance - `POST /public/v1/instances/{instance_id}/reboot`: Reboot an instance - `POST /public/v1/instances/{instance_id}/rename`: Rename an instance - `POST /public/v1/instances/{instance_id}/resize`: Resize an instance - `POST /public/v1/instances/{instance_id}/rebuild`: Rebuild an instance - `POST /public/v1/instances/{instance_id}/restore`: Restore an instance from a snapshot - `POST /public/v1/instances/{instance_id}/change-ssh-key`: Change an instance's SSH key - `PUT /public/v1/instances/{instance_id}/security-group`: Change an instance's security group - `POST /public/v1/instances/{instance_id}/console`: Open the browser console - `POST /public/v1/instances/{instance_id}/volume-key-credential`: Issue a volume key credential - `POST /public/v1/instances/{instance_id}/ssh-access`: Grant temporary SSH access - `GET /public/v1/instances/{instance_id}/ssh-access/{grant_id}`: Get an SSH access grant - `DELETE /public/v1/instances/{instance_id}/ssh-access/{grant_id}`: Revoke SSH access - `DELETE /public/v1/instances/{instance_id}`: Delete an instance - `GET /public/v1/instances/{instance_id}/metrics`: Get instance metrics - `POST /public/v1/instances/{instance_id}/private-network`: Add a private database network interface to an instance - `DELETE /public/v1/instances/{instance_id}/private-network`: Remove an instance's private database network interface ## The instance object - `id` (string, required): e.g. `vm_06gf...`. - `name` (string, required): Its name, unique among your instances. - `plan_id` (string, required): The plan's id. - `plan_slug` (string or null): The plan's slug, as `plan_slug` takes it (e.g. `starter`); null if the plan has since been retired. - `image_id` (string, required): The image's id. - `image_slug` (string or null): The image's slug, as `image_slug` takes it (e.g. `ubuntu-24-04`); null if the image has since been retired. - `encrypted` (boolean): True when the instance runs an encrypted image: its root disk is encrypted on our storage, with its own key. - `region` (string or null): e.g. `af-abj`. - `zone` (string or null): e.g. `af-abj-1`. - `network_id` (string or null): The VPC, for an instance in one. - `subnet_id` (string or null): Its VPC subnet; null for a standard instance. - `security_group_id` (string or null): The security group, for a standard instance. - `public_ipv4` (string or null): The instance's own public address (standard instances). A VPC instance is reached through a public IP: see `GET /public/v1/public-ips`. - `private_ipv4` (string or null): Its private address: in its VPC subnet, or on the zone's guest network. - `private_network_state` (string): Its interface on the zone's private database network: `none`, `attaching`, `attached` or `detaching`. See *Add a private database network interface*. One of `none`, `attaching`, `attached`, `detaching`. - `deletion_protection` (boolean): Platform VM deletion is refused while true. - `private_network_ip` (string or null): That interface's address once attached (kept while it is being removed); null otherwise. Allow it on a managed database as a `/32` access rule. - `desired_state` (string, required): What you asked for: `running`, `stopped` or `deleted`. One of `running`, `stopped`, `deleted`. - `observed_state` (string, required): What the platform last observed. A change is complete when it matches `desired_state`; `failed` means the last change could not be made (see the failure fields), and `unknown` means the platform could not check. One of `pending`, `provisioning`, `running`, `stopping`, `stopped`, `deleting`, `deleted`, `failed`, `unknown`. - `spec` (object or null): The capacity this instance was created with. - `vcpu` (integer, required) - `memory_mb` (integer, required) - `disk_gb` (integer, required) - `tags` (object, required): Your own labels, as sent at create. - `failure` (Failure): Why the last change failed, when it did; otherwise null. - `code` (string, required): A stable failure code to branch on, e.g. `INSTANCE_BUSY`. Provisioning failures use `PROVISIONING_CAPACITY_UNAVAILABLE` (no capacity in the zone right now: try another plan or zone, or try later), `PROVISIONING_LIMIT_EXCEEDED` (an account limit was reached: delete unused resources or contact support), `PROVISIONING_ADDRESSES_EXHAUSTED` (no free addresses in the zone), `PROVISIONING_IMAGE_UNAVAILABLE` (the image is not ready), `PROVISIONING_RESOURCE_BUSY` (the resource was busy: try again once it settles), `PROVISIONING_RESOURCE_CONFLICT`, `PROVISIONING_RESOURCE_NOT_FOUND`, `PROVISIONING_INVALID_REQUEST`, `PROVISIONING_UNAVAILABLE` (temporarily unavailable: retry), `PROVISIONING_INTERNAL_ERROR` (a fault on our side: our team is alerted, and retrying will not help) and `PROVISIONING_FAILED` (anything else). - `reason` (string, required): A readable sentence saying what went wrong and what to do; safe to show to your users. Its wording may change: branch on `code`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## List instances ```http GET /public/v1/instances ``` Your instances, newest first, with addresses and state. Filter by name with `q`, or by state. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `running`, `stopped` or `deleted`. One of `running`, `stopped`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `running`, `stopping`, `stopped`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "plan_id": "plan_06gk3x9tq7m2v8r4y1c5d0zp8w", "plan_slug": "starter", "image_id": "img_06gk3x9tq7m2v8r4y1c5d0zp8w", "image_slug": "ubuntu-24-04", "encrypted": true, "region": "af-abj", "zone": "af-abj-1", "network_id": null, "subnet_id": null, "security_group_id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ipv4": "102.211.122.80", "private_ipv4": "10.0.1.12", "private_network_state": "attached", "deletion_protection": true, "private_network_ip": "10.250.4.23", "desired_state": "running", "observed_state": "running", "spec": { "vcpu": 2, "memory_mb": 4096, "disk_gb": 80 }, "tags": { "env": "production" }, "failure": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create an instance ```http POST /public/v1/instances ``` Orders a new instance (VPS). Choose a plan (`GET /public/v1/plans`), an image (`GET /public/v1/images`) and an SSH key (`GET /public/v1/ssh-keys`), which is required: images have no password login. **Placement.** Leave out `subnet_id` for a standard instance with its own public IPv4, protected by a security group (your organization's default group unless you send `security_group_id`). Send `subnet_id` (and optionally its `network_id`) to place it in a VPC subnet, with a private address only; reach it through a public IP. **Paid first.** Returns `202 Accepted` with an order (`order_id`, also the `Location` header). The initial payment is taken from credit, or else from the organization's default card, and then the instance is provisioned. Follow `GET /public/v1/instance-orders/{order_id}` until `status` is `provisioned`, then read the instance; `payment_failed` and `failed` are final, and any payment taken is returned to your credit. **Idempotency.** Send an `Idempotency-Key`. A retry with the same key returns the original order instead of ordering a second instance. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 255 characters of lowercase letters a-z, digits 0-9 and hyphens (-), starting and ending with a letter or digit, unique among your instances. Anything else is refused with 422 INVALID_INSTANCE_NAME. The guest hostname is derived from it (at most 63 characters). At most 255 characters. - `plan_slug` (string, required): The size, from `GET /public/v1/plans`. - `image_slug` (string, required): The operating system, from `GET /public/v1/images`. It must run in the instance's zone. - `encrypted` (boolean): Optional. `true` creates the encrypted version of `image_slug`: the disk is encrypted on first boot with a key held by Pantech's key servers, and the instance needs outbound access to them at every boot (the platform keeps that open). Refused with `ENCRYPTION_NOT_SUPPORTED` when the image has no encrypted version available to your organization. `false` or omitted creates the image as named. Naming an encrypted image's own slug already creates an encrypted instance; sending `false` with that slug is refused as `ENCRYPTION_CONFLICT`. - `ssh_key_id` (string, required): An SSH key from `GET /public/v1/ssh-keys`, installed for the default user. Required: images have no password login, so this key is how you log in. - `region` (string): A region from `GET /public/v1/regions`, e.g. `af-abj`. Defaults to the platform's region. - `subnet_id` (string): Place the instance in this VPC subnet, with a private address only. Leave out for a standard instance with its own public IPv4. - `network_id` (string): The subnet's VPC. Optional when `subnet_id` is set; if sent, the subnet must belong to it. - `security_group_id` (string): Standard instances only: one of your security groups. Defaults to the organization's default group, which starts with one rule: ICMP (ping) from anywhere. - `tags` (object): Your own labels, as string keys and values. Not interpreted by the platform. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "web-1", "plan_slug": "starter", "image_slug": "ubuntu-24-04", "ssh_key_id": "sshk_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "order_id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "awaiting_payment", "operation_id": null, "failure_code": null, "amount_minor": 1059200, "currency": "NGN" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `402`: `INSUFFICIENT_CREDIT`: the initial payment is more than your available credit and the organization has no default card; `detail` gives both amounts. Buy credit (`POST /public/v1/billing/credit/purchases`) or add a card in the console, then retry. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: `ssh_key_id` is not one of your SSH keys. Check it with `GET /public/v1/ssh-keys`. - `409`: `INSTANCE_NAME_TAKEN`: you already have an instance with this name. Choose a different name. `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `ACCOUNT_NOT_ACTIVE`: the organization's billing account is not active yet. Finish billing setup in the console (verify the organization and add a payment method), then retry. `PROVIDER_ACCOUNT_NOT_READY`: your organization's cloud account is still being set up, which takes a few minutes after sign-up. Retry shortly. `SECURITY_GROUPS_NOT_SUPPORTED_IN_ZONE`: a VPC instance uses its subnet's firewall rules, not a security group. Leave out `security_group_id`. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `ssh_key_id` `REQUIRED`: no SSH key was sent. Send the id of one of your SSH keys (`GET /public/v1/ssh-keys`): images have no password login. `plan_slug` `PLAN_NOT_FOUND`: no active plan has this slug. Pick one from `GET /public/v1/plans`. `image_slug` `IMAGE_NOT_FOUND`: no active image has this slug. Pick one from `GET /public/v1/images`. `image_slug` `IMAGE_NOT_AVAILABLE_IN_ZONE`: the image does not run in the zone this instance goes to. Pick an image whose `zones` include it. `encrypted` `ENCRYPTION_NOT_SUPPORTED`: `encrypted` is `true` but this image has no encrypted version available to your organization. Choose an image that offers one, or send `false`. `encrypted` `ENCRYPTION_CONFLICT`: `encrypted` is `false` but `image_slug` is an encrypted image. Send `true`, or choose the plain image. `security_group_id` `SECURITY_GROUP_NOT_FOUND`: not one of your security groups. Check it with `GET /public/v1/security-groups`. `subnet_id` `SUBNET_REQUIRED`: `network_id` was sent without a subnet. Send the subnet too. `subnet_id` `SUBNET_NOT_IN_NETWORK`: the subnet is not in `network_id`. Send a subnet of that VPC, or leave out `network_id`. `subnet_id` `SUBNET_NOT_FOUND`: no subnet with this id in your organization. `subnet_id` `SUBNET_NOT_USABLE`: the subnet is being deleted or has failed. Use another subnet. `region` `REGION_NOT_AVAILABLE`: this region cannot take this instance. Pick one from `GET /public/v1/regions`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: the platform refused a value, for example a malformed CIDR or a name longer than 255 characters. Check the request against this page. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get an instance ```http GET /public/v1/instances/{instance_id} ``` One instance: its plan, image, addresses (including `private_network_ip` once a private database network interface is attached), and current state. A change is complete when `observed_state` matches `desired_state`. Scope: `read`. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "plan_id": "plan_06gk3x9tq7m2v8r4y1c5d0zp8w", "plan_slug": "starter", "image_id": "img_06gk3x9tq7m2v8r4y1c5d0zp8w", "image_slug": "ubuntu-24-04", "encrypted": true, "region": "af-abj", "zone": "af-abj-1", "network_id": null, "subnet_id": null, "security_group_id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ipv4": "102.211.122.80", "private_ipv4": "10.0.1.12", "private_network_state": "attached", "deletion_protection": true, "private_network_ip": "10.250.4.23", "desired_state": "running", "observed_state": "running", "spec": { "vcpu": 2, "memory_mb": 4096, "disk_gb": 80 }, "tags": { "env": "production" }, "failure": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Start an instance ```http POST /public/v1/instances/{instance_id}/start ``` Starts a stopped instance. A running instance is billed for its plan again. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/start" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the instance has been deleted. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Stop an instance ```http POST /public/v1/instances/{instance_id}/stop ``` Shuts the instance down. A stopped instance keeps its disks and addresses and is billed for storage only. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. For the first 5 minutes after a new instance is created, this returns `409` with code `INSTANCE_POWER_LOCKED` while first boot finishes. Wait the `Retry-After` header's seconds, then try again. Pending, provisioning, or uncertain instances also return this 409 before the first run is recorded, without `Retry-After`; wait for the instance to reach running first. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/stop" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the instance has been deleted. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Reboot an instance ```http POST /public/v1/instances/{instance_id}/reboot ``` Restarts the instance (a clean reboot of the operating system). It must be running. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. For the first 5 minutes after a new instance is created, this returns `409` with code `INSTANCE_POWER_LOCKED` while first boot finishes. Wait the `Retry-After` header's seconds, then try again. Pending, provisioning, or uncertain instances also return this 409 before the first run is recorded, without `Retry-After`; wait for the instance to reach running first. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/reboot" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the instance has been deleted. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Rename an instance ```http POST /public/v1/instances/{instance_id}/rename ``` Changes the instance's name (1 to 255 characters, unique among your instances). Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `name` (string, required): The new name: 1 to 255 characters of lowercase letters a-z, digits 0-9 and hyphens (-), starting and ending with a letter or digit, unique among your instances. Anything else is refused with 422 INVALID_INSTANCE_NAME. At most 255 characters. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/rename" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "web-2" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `INSTANCE_NAME_TAKEN`: you already have an instance with this name. Choose a different name. `ACCOUNT_NOT_ACTIVE`: the organization's billing account is not active yet. Finish billing setup in the console (verify the organization and add a payment method), then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Resize an instance ```http POST /public/v1/instances/{instance_id}/resize ``` Moves the instance to a bigger plan. The instance is stopped, resized and started again. Instances only grow: the new plan must be bigger than the current one. No upfront charge: the bigger plan is billed hourly from the change. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `plan_slug` (string, required): A bigger plan than the instance's current one, from `GET /public/v1/plans`. Instances only grow. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/resize" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "plan_slug": "starter" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `plan_slug` `PLAN_NOT_FOUND`: no active plan has this slug. Pick one from `GET /public/v1/plans`. `plan_slug` `PLAN_NOT_BIGGER`: the plan is not bigger than the instance's current one. Choose a bigger plan. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Rebuild an instance ```http POST /public/v1/instances/{instance_id}/rebuild ``` Reinstalls the operating system from `image_slug`, or from the instance's current image when it is left out. **Everything on the root disk is erased**; attached volumes, addresses and the plan are kept. Take a snapshot first if you may need the data. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `image_slug` (string): The image to install, from `GET /public/v1/images`. Leave out to reinstall the current image. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/rebuild" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `ACCOUNT_NOT_ACTIVE`: the organization's billing account is not active yet. Finish billing setup in the console (verify the organization and add a payment method), then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `image_slug` `IMAGE_NOT_FOUND`: no active image has this slug. Pick one from `GET /public/v1/images`. `image_slug` `IMAGE_NOT_AVAILABLE_IN_ZONE`: the image does not run in this instance's zone. Pick an image whose `zones` include it. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Restore an instance from a snapshot ```http POST /public/v1/instances/{instance_id}/restore ``` Rolls the instance's root disk back to one of its own snapshots and reboots it. **Everything written to the root disk since the snapshot is lost**, so `confirm_name` must repeat the instance's current name. Attached volumes are not changed. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `snapshot_id` (string, required): A snapshot of this instance's root disk. - `confirm_name` (string, required): The instance's current name, exactly, to confirm: everything written since the snapshot is lost. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/restore" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "snapshot_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "confirm_name": "web-1" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. `RESOURCE_NOT_FOUND` can also mean `snapshot_id` is not a snapshot of this instance. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `ACCOUNT_NOT_ACTIVE`: the organization's billing account is not active yet. Finish billing setup in the console (verify the organization and add a payment method), then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `snapshot_id` `REQUIRED`: send a snapshot of this instance. `confirm_name` `REQUIRED` or `CONFIRM_NAME_MISMATCH`: repeat the instance's current name exactly. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Change an instance's SSH key ```http POST /public/v1/instances/{instance_id}/change-ssh-key ``` Replaces the SSH key you log in to the instance with by another key on your account, for example after losing the private key. The instance must be running: it is stopped, given the new key and started again, which takes about two minutes. The previous key stops working once the instance is back. Keys you added to the server yourself are left as they are. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `ssh_key_id` (string, required): The SSH key the instance should accept from now on, from `GET /public/v1/ssh-keys`. It replaces the instance's current key. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/change-ssh-key" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "ssh_key_id": "sshk_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state: it is not running, or another change such as a resize is in progress. Start it, or wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `ssh_key_id` `REQUIRED`: the field is missing. Send the id of one of your SSH keys. `ssh_key_id` `SSH_KEY_NOT_FOUND`: no SSH key with this id on your account. Pick one from `GET /public/v1/ssh-keys`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Change an instance's security group ```http PUT /public/v1/instances/{instance_id}/security-group ``` Moves a standard instance to another of your security groups. The instance must be stopped; the new group applies when it starts again. If the instance has a private database network interface, the new group must allow nothing from the private network's range (`10.250.0.0/20` in `af-abj-1`): a group with any ingress rule whose source overlaps it, `0.0.0.0/0` included, is refused. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `security_group_id` (string, required): One of your security groups in the instance's zone, e.g. `sg_…`. If the instance has a private database network interface, the group must allow nothing from `10.250.0.0/20`. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/security-group" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "security_group_id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `INSTANCE_MUST_BE_STOPPED`: stop the instance first; the new group is applied when it starts again. `SECURITY_GROUPS_NOT_SUPPORTED_IN_ZONE`: a VPC instance uses its subnet's firewall rules, not a security group. `ACCOUNT_NOT_ACTIVE`: the organization's billing account is not active yet. Finish billing setup in the console (verify the organization and add a payment method), then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `security_group_id` `REQUIRED` or `SECURITY_GROUP_NOT_FOUND`: send the id of one of your security groups. `security_group_id` `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK`: the group allows inbound traffic from the private database network, which this instance is attached to; the message names the range and the rules. Replace `0.0.0.0/0` (including the default group's ICMP rule) with narrower ranges that leave out the private range, then retry. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Open the browser console ```http POST /public/v1/instances/{instance_id}/console ``` Returns a short-lived URL to the instance's browser console. **The console gives a logged-in shell; treat the URL as a password** and never log or share it. The instance must be running. **Idempotency:** each request opens a new console URL; a retry is not replayed. Opening one changes nothing else, so retrying is safe. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/console" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `201`: ```json { "url": "https://x.serial-console.pantechdynamics.com/…" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `CONSOLE_INSTANCE_NOT_RUNNING`: the instance must be running to open its console. Start it, wait for the operation to succeed, then retry. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `CONSOLE_UNAVAILABLE`: the console is not available right now. Retry in a minute. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Issue a volume key credential ```http POST /public/v1/instances/{instance_id}/volume-key-credential ``` Gives an instance the **credential** it uses to ask Pantech for the key of an encrypted data volume. Put the credential on the instance, in the volume agent's configuration, and attach the volume to any instance of your organization: that instance can unlock the volume, whether it runs an encrypted image or a plain one. A volume's key is released only to an instance of the same organization that the volume is attached to. **The credential is a secret: it is shown once.** Pantech keeps only a hash of it, so it cannot be shown again. Treat it like a password and never log or share it. Each request issues a new credential and **ends the previous one** for that instance, so use it to rotate a credential too, and update the agent on that instance. The credential also ends when the instance is deleted. **Idempotency:** each request issues a new credential; a retry is not replayed. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/volume-key-credential" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `201`: ```json { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "credential": "credential" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `INSTANCE_NOT_LIVE`: the instance is being deleted or has failed. Use another instance. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `ENCRYPTED_VOLUMES_UNAVAILABLE`: encrypted volumes are not available right now. Retry in a minute. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Grant temporary SSH access ```http POST /public/v1/instances/{instance_id}/ssh-access ``` Opens port 22 of a standard instance to the address this request comes from, for 15 minutes, so you can SSH in without leaving the port open. The instance must be running and be the only one using its security group. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Then read the grant (`GET /public/v1/instances/{instance_id}/ssh-access/{grant_id}`, the operation's `resource_id` is the grant id) for the `host` and `port` to connect to once `status` is `active`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/ssh-access" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "sshg_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_CALLER_ADDRESS`: the address this request comes from could not be used to scope access. Retry from a normal internet connection. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `SSH_ACCESS_INSTANCE_NOT_RUNNING`: the instance must be running. Start it, then retry. `SSH_ACCESS_SECURITY_GROUP_SHARED`: the instance's security group is used by other instances too, so opening it would open them all. Move the instance to its own security group, then retry. `SSH_ACCESS_NOT_SUPPORTED_IN_ZONE`: temporary SSH access is not available for VPC instances yet. Use a public IP with a port forwarding rule instead. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get an SSH access grant ```http GET /public/v1/instances/{instance_id}/ssh-access/{grant_id} ``` A temporary SSH access grant: its `status`, and the `host` and `port` to connect to once it is `active`. It expires by itself at `expires_at`. Scope: `read`. Path parameters: - `instance_id`: The resource's id. - `grant_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/ssh-access/sshg_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "sshg_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "active", "host": "102.211.122.80", "port": 22, "expires_at": "2026-10-03T09:30:00Z", "revoked_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no grant with this id on this instance, or the instance is not in your organization. Check both ids. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Revoke SSH access ```http DELETE /public/v1/instances/{instance_id}/ssh-access/{grant_id} ``` Closes the port opened by a grant before it expires. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. - `grant_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/ssh-access/sshg_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "sshg_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no grant with this id on this instance, or the instance is not in your organization. Check both ids. - `409`: `INVALID_RESOURCE_STATE`: the grant is already revoked or expired. Nothing to do. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Delete an instance ```http DELETE /public/v1/instances/{instance_id} ``` Deletes the instance. It is stopped, a safety snapshot of its root disk is kept for 24 hours, and then it is removed. Attached volumes are detached, not deleted. Release its public IP and delete port forwarding rules that point at it first. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `INSTANCE_HAS_PUBLIC_IP`: a VPC public IP still maps to this instance. Detach it (`POST /public/v1/public-ips/{public_ip_id}/detach`) to keep the address, or release it (`DELETE /public/v1/public-ips/{public_ip_id}`), then retry. `INSTANCE_HAS_PORT_FORWARDS`: port forwarding rules still point at this instance. Delete them, then retry. `INSTANCE_BEHIND_LOAD_BALANCER`: the instance is still a load balancer target. Remove it from its load balancers (`PATCH /public/v1/load-balancers/{load_balancer_id}`), then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the instance has already been deleted. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get instance metrics ```http GET /public/v1/instances/{instance_id}/metrics ``` CPU, memory, network and disk activity over `interval` (`1h`, `24h`, `7d` or `30d`), one point per bucket. `available` is false when no samples exist yet, for example just after creation. Scope: `read`. Path parameters: - `instance_id`: The resource's id. Query parameters: - `interval` (string): One of `1h`, `24h`, `7d`, `30d`. Defaults to `24h`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/metrics" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "interval": "1h", "available": true, "sampled_at": "2026-10-03T09:30:00Z", "points": [ { "bucket_start": "2026-10-03T09:30:00Z", "bucket_end": "2026-10-03T09:30:00Z", "cpu_percent": 1, "memory_percent": 1, "network_in_bytes": 1, "network_out_bytes": 1, "disk_read_peak_bytes_per_second": 1, "disk_write_peak_bytes_per_second": 1 } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `interval` `INVALID_METRIC_INTERVAL`: use `1h`, `24h`, `7d` or `30d`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Add a private database network interface to an instance ```http POST /public/v1/instances/{instance_id}/private-network ``` Adds a second network interface on the zone's private database network to a standard instance, so it can reach your managed databases there by their private address. Only in a zone with a private database network (`af-abj-1`); a VPC instance already reaches databases in its own VPC. The interface is free. When the operation succeeds, the instance's `private_network_state` is `attached` and `private_network_ip` is the interface's address: allow it on each database as a `/32` access rule (*Replace access rules*). **The instance's security group must allow nothing from the private network's range** (`10.250.0.0/20`; the zone's `private_network_cidr` in `GET /public/v1/regions`). A security group applies to every interface of an instance, so any ingress rule whose source overlaps that range, whatever its protocol or ports, would let every database and every other customer's private interface on the network reach this instance. That includes `0.0.0.0/0` and `10.0.0.0/8`, and the ICMP-from-anywhere rule of your organization's default security group. Such a group is refused, naming the rules to change: replace `0.0.0.0/0` with the addresses you actually need, or with ranges that leave out `10.250.0.0/20`, or move the instance to a group without such rules. While the instance has the interface, rule changes and group moves that would allow the range are refused too. The instance must be running or stopped with no other change in progress. A running instance gets the interface without a restart. **In the guest** the interface stays down until you configure it. On Ubuntu add it to netplan with DHCP only, never the default route or DNS: `dhcp4: true` with `dhcp4-overrides: {use-routes: false, use-dns: false}` for the new interface (often `ens4` or `eth1`), then run `netplan apply`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. If the operation fails, its `failure.code` says why: `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` (a rule allowing the range was added meanwhile; nothing was attached), `INSTANCE_BUSY` (the instance kept changing; retry when it has settled), or `INSTANCE_PRIVATE_NETWORK_ATTACH_FAILED` (retry later, or contact support). The instance itself is never marked failed for it. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/private-network" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. `NOT_FOUND`: private database network interfaces are not offered on this platform yet. - `409`: `PRIVATE_NETWORK_NOT_AVAILABLE`: this instance's zone has no private database network for instances (VPC instances reach databases in their own VPC). Nothing to attach. `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK`: the instance's security group allows inbound traffic from the private network's range; `detail` names the range and each offending rule. Replace `0.0.0.0/0` in those rules (including the default group's ICMP rule) with narrower ranges that leave out `10.250.0.0/20`, or move the instance to another group, then retry. `PRIVATE_NETWORK_ALREADY_ATTACHED`: the instance already has the interface, or it is being added. Read the instance for `private_network_ip`. `PRIVATE_NETWORK_CHANGE_IN_PROGRESS`: the interface is being added or removed. Wait for that operation to finish. `INSTANCE_NOT_SETTLED`: the instance is starting, stopping, resizing, restoring or being deleted. Wait until it is running or stopped, then retry. `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Remove an instance's private database network interface ```http DELETE /public/v1/instances/{instance_id}/private-network ``` Removes the instance's interface on the private database network. The instance must be running or stopped with no other change in progress; a running instance keeps running. `private_network_ip` stays readable until the operation succeeds; then remove any database access rule for it, and the interface from the guest's network configuration. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. A detach that cannot complete fails with `INSTANCE_PRIVATE_NETWORK_DETACH_FAILED`; retry later. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/private-network" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. `NOT_FOUND`: private database network interfaces are not offered on this platform yet. - `409`: `PRIVATE_NETWORK_NOT_ATTACHED`: the instance has no private network interface to remove. `PRIVATE_NETWORK_CHANGE_IN_PROGRESS`: the interface is being added or removed. Wait for that operation to finish. `INSTANCE_NOT_SETTLED`: the instance is starting, stopping, resizing, restoring or being deleted. Wait until it is running or stopped, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Instance orders Source: https://docs.pantechdynamics.com/api/instance-orders Follow a new instance from payment to provisioning. Creating an instance places an order. The order takes the initial payment, from credit or else the organization's default card, and then provisions the instance. Poll the order until its `status` is `provisioned` (the instance is ready to read), `failed` or `payment_failed`. Endpoints: - `GET /public/v1/instance-orders`: List instance orders - `GET /public/v1/instance-orders/{order_id}`: Get an instance order ## The instance order object - `id` (string, required): e.g. `ord_...`. - `instance_id` (string, required): The id the instance has, or will have once provisioned. - `status` (string, required): `awaiting_payment`, then `paid`, `provisioning` and `provisioned` (final: the instance exists). `payment_failed` and `failed` are final too; any payment taken is returned to your credit. One of `awaiting_payment`, `paid`, `provisioning`, `provisioned`, `payment_failed`, `failed`. - `operation_id` (string or null): The instance's create operation, set once provisioning starts. - `failure_code` (string or null): Why the order failed, e.g. `insufficient_credit`, `card_declined`, `payment_expired`; null otherwise. `organization_deleted`: the order was cancelled because its organization was deleted; any payment taken is returned to credit. - `amount_minor` (integer, required): The initial payment in minor units of `currency`. - `currency` (string, required): `NGN` or `USD`. One of `NGN`, `USD`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## List instance orders ```http GET /public/v1/instance-orders ``` Your instance orders, newest first, one per *Create an instance* call. An order shows whether the initial payment was taken and whether provisioning started. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instance-orders" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "provisioned", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "failure_code": null, "amount_minor": 1059200, "currency": "NGN", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get an instance order ```http GET /public/v1/instance-orders/{order_id} ``` Payment and provisioning progress for one new instance. Poll it after *Create an instance* until `status` is `provisioned` (then read the instance), or `payment_failed` or `failed` (final: any payment taken is returned to your credit, and `failure_code` says why). Scope: `read`. Path parameters: - `order_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instance-orders/ord_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "provisioned", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "failure_code": null, "amount_minor": 1059200, "currency": "NGN", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance order with this id in your organization, or the id is not an order id (`ord_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # SSH keys Source: https://docs.pantechdynamics.com/api/ssh-keys The keys installed on new instances. Images have no password login, so give every new instance an SSH key. Register your own public key, or have a key pair generated. Its private key is returned once and never again. Endpoints: - `GET /public/v1/ssh-keys`: List SSH keys - `POST /public/v1/ssh-keys`: Add or generate an SSH key - `GET /public/v1/ssh-keys/{ssh_key_id}`: Get an SSH key - `DELETE /public/v1/ssh-keys/{ssh_key_id}`: Delete an SSH key ## The SSH key object - `id` (string, required): e.g. `sshk_...`. - `name` (string, required): Its name, unique among your SSH keys. - `fingerprint` (string, required): The key's SHA256 fingerprint. - `public_key` (string, required): The OpenSSH public key. - `created_at` (string or null): When it was created (RFC 3339, UTC). ## List SSH keys ```http GET /public/v1/ssh-keys ``` The SSH keys you can install on new instances. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/ssh-keys" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "sshk_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "fingerprint": "SHA256:Qm8vWv3n7rC1bK0pXcFZ9yS2t4uJ6hLdA5gE8iRqTzo", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample deploy@ci", "created_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Add or generate an SSH key ```http POST /public/v1/ssh-keys ``` Send `public_key` to register your own OpenSSH public key. Leave it out to have a key pair generated: the private key is returned in this response only and never stored. Returns `201` at once; there is no operation to follow. **Idempotency:** a retry with the same `Idempotency-Key` is not replayed yet. If a request may have gone through, list your SSH keys before retrying: a second attempt with the same name returns `409 SSH_KEY_NAME_TAKEN`, and a generated private key is never shown again. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 255 characters, unique among your SSH keys. At most 255 characters. - `public_key` (string): One OpenSSH public key line, e.g. `ssh-ed25519 AAAA… deploy@ci`. Leave out to have a key pair generated; its private key is then returned once. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/ssh-keys" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "deploy" }' ``` Response `201`: ```json { "id": "sshk_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "fingerprint": "SHA256:Qm8vWv3n7rC1bK0pXcFZ9yS2t4uJ6hLdA5gE8iRqTzo", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample deploy@ci", "created_at": "2026-10-03T09:30:00Z", "private_key": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `409`: `SSH_KEY_NAME_TAKEN`: you already have an SSH key with this name. Choose a different name. `SSH_KEY_ALREADY_EXISTS`: this public key is already registered. Use the existing key. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `INVALID_NAME`: send a name of 1 to 255 characters. `public_key` `INVALID_SSH_PUBLIC_KEY`: send one OpenSSH public key line, such as `ssh-ed25519 AAAA… comment`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get an SSH key ```http GET /public/v1/ssh-keys/{ssh_key_id} ``` One SSH key. Scope: `read`. Path parameters: - `ssh_key_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/ssh-keys/sshk_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "sshk_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "fingerprint": "SHA256:Qm8vWv3n7rC1bK0pXcFZ9yS2t4uJ6hLdA5gE8iRqTzo", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample deploy@ci", "created_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no SSH key with this id in your organization, or the id is not a SSH key id (`sshk_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete an SSH key ```http DELETE /public/v1/ssh-keys/{ssh_key_id} ``` Deletes an SSH key. Instances that already have it keep it in their `authorized_keys`; remove it there yourself. **Idempotency:** a retry is not replayed. Once the key is deleted, a repeat returns `404`; treat that as done. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `ssh_key_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/ssh-keys/sshk_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `204`: Deleted. Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no SSH key with this id in your organization, or the id is not a SSH key id (`sshk_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # VPC networks Source: https://docs.pantechdynamics.com/api/networks Private networks, their subnets, and the firewall rules on each subnet. A VPC is a private network of your own. Create one, add subnets inside its CIDR, then create instances with a `subnet_id`. Each subnet has numbered firewall rules, evaluated lowest number first; the first match decides. To reach a VPC instance from the internet, give it a [public IP](https://docs.pantechdynamics.com/api/public-ips). Endpoints: - `GET /public/v1/networks`: List VPC networks - `POST /public/v1/networks`: Create a VPC network - `GET /public/v1/networks/quote`: Price a VPC network - `GET /public/v1/networks/{network_id}`: Get a VPC network - `DELETE /public/v1/networks/{network_id}`: Delete a VPC network - `GET /public/v1/networks/{network_id}/subnets`: List subnets - `POST /public/v1/networks/{network_id}/subnets`: Create a subnet - `GET /public/v1/subnets/{subnet_id}`: Get a subnet - `DELETE /public/v1/subnets/{subnet_id}`: Delete a subnet - `GET /public/v1/subnets/{subnet_id}/firewall-rules`: List a subnet's firewall rules - `POST /public/v1/subnets/{subnet_id}/firewall-rules`: Add a firewall rule - `GET /public/v1/subnets/{subnet_id}/firewall-rules/{rule_id}`: Get a firewall rule - `DELETE /public/v1/firewall-rules/{rule_id}`: Delete a firewall rule ## The network object A VPC: a private network of your own. - `id` (string, required): e.g. `net_...`. - `name` (string, required): Its name, unique among your VPC networks. - `cidr` (string, required): e.g. `10.0.0.0/16`. - `region` (string or null): e.g. `af-abj`. - `zone` (string or null): e.g. `af-abj-2`. - `instance_count` (integer or null): Instances in this VPC; null when it could not be counted. - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## The subnet object - `id` (string, required): e.g. `snet_...`. - `network_id` (string, required) - `name` (string, required): Its name. - `cidr` (string, required): Inside the VPC's CIDR. - `region` (string or null): e.g. `af-abj`. - `zone` (string or null): e.g. `af-abj-2`. - `instance_count` (integer or null): Instances in this subnet; null when they could not be counted. - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## The firewall rule object - `id` (string, required): e.g. `aclr_...`. - `subnet_id` (string, required): The subnet the rule applies to, as a whole. - `number` (integer, required): Evaluation order, 100-9999; lowest first. From 100 to 9999. - `direction` (string, required): `ingress` (into the subnet) or `egress` (out of it). One of `ingress`, `egress`. - `protocol` (string, required): `tcp`, `udp`, `icmp` or `all`. One of `tcp`, `udp`, `icmp`, `all`. - `port_start` (integer or null): First port, for `tcp`/`udp`; null otherwise. - `port_end` (integer or null): Last port, for `tcp`/`udp`; null otherwise. - `icmp_type` (integer or null): ICMP type, for `icmp`; null for every type. - `icmp_code` (integer or null): ICMP code, for `icmp`; null for every code. - `cidr` (string, required): Source (ingress) or destination (egress), e.g. `0.0.0.0/0`. - `action` (string, required): `allow` or `deny`. One of `allow`, `deny`. - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## List VPC networks ```http GET /public/v1/networks ``` Your VPC networks, newest first. Filter by name with `q`, or by state. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/networks" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "prod", "cidr": "10.0.0.0/16", "region": "af-abj", "zone": "af-abj-1", "instance_count": 2, "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a VPC network ```http POST /public/v1/networks ``` Creates a VPC: a private network of your own, with a CIDR such as `10.0.0.0/16`. Then add subnets to it, and instances or databases to the subnets. A VPC is billed monthly: see *Price a VPC network*. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The network's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 255 characters, unique among your VPC networks. At most 255 characters. - `cidr` (string, required): The VPC's private IPv4 range, e.g. `10.0.0.0/16`. Subnets are carved from it. - `region` (string): A region from `GET /public/v1/regions` that offers VPC networks. Defaults to the platform's region. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/networks" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "prod", "cidr": "10.0.0.0/16" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `409`: `NETWORK_NAME_TAKEN`: you already have a VPC with this name. Choose a different name. `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `PROVIDER_ACCOUNT_NOT_READY`: your organization's cloud account is still being set up, which takes a few minutes after sign-up. Retry shortly. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `cidr` `REQUIRED`: send an IPv4 CIDR such as `10.0.0.0/16`. `region` `REGION_NOT_AVAILABLE`: this region cannot take VPC networks now. Pick one from `GET /public/v1/regions`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: `cidr` is not a valid IPv4 CIDR. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Price a VPC network ```http GET /public/v1/networks/quote ``` What one more VPC network costs per month, in your organization's currency. `priced` is false, with an `unpriced_reason`, when no price applies. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/networks/quote" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "currency": "NGN", "monthly_minor": 1700000, "nominal_month_hours": 730 } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `NOT_FOUND`: cost quotes are not available on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a VPC network ```http GET /public/v1/networks/{network_id} ``` One VPC network, with the number of instances in it. Scope: `read`. Path parameters: - `network_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/networks/net_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "prod", "cidr": "10.0.0.0/16", "region": "af-abj", "zone": "af-abj-1", "instance_count": 2, "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no VPC network with this id in your organization, or the id is not a VPC network id (`net_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a VPC network ```http DELETE /public/v1/networks/{network_id} ``` Deletes a VPC network. It must hold no instances (in any of its subnets) and no public IPs. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `network_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/networks/net_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no VPC network with this id in your organization, or the id is not a VPC network id (`net_…`). Check the id. - `409`: `NETWORK_HAS_INSTANCES`: a subnet of this network still has instances. Delete or move them first. `NETWORK_HAS_PUBLIC_IPS`: the network still holds public IPs. Release them first. `INVALID_RESOURCE_STATE`: the network cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused a value, for example a malformed CIDR or a name longer than 255 characters. Check the request against this page. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## List subnets ```http GET /public/v1/networks/{network_id}/subnets ``` The VPC's subnets. Scope: `read`. Path parameters: - `network_id`: The resource's id. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/networks/net_06gk3x9tq7m2v8r4y1c5d0zp8w/subnets" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "app", "cidr": "10.0.1.0/24", "region": "af-abj", "zone": "af-abj-1", "instance_count": 2, "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no VPC network with this id in your organization, or the id is not a VPC network id (`net_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a subnet ```http POST /public/v1/networks/{network_id}/subnets ``` Creates a subnet inside the VPC's CIDR, such as `10.0.1.0/24`. Instances and databases are placed in subnets; each subnet has its own firewall rules. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The subnet's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `network_id`: The resource's id. Body: - `name` (string, required): 1 to 255 characters. At most 255 characters. - `cidr` (string, required): An IPv4 range inside the VPC's CIDR that does not overlap its other subnets, e.g. `10.0.1.0/24`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/networks/net_06gk3x9tq7m2v8r4y1c5d0zp8w/subnets" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "app", "cidr": "10.0.1.0/24" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no VPC network with this id in your organization, or the id is not a VPC network id (`net_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the network cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `cidr` `REQUIRED`: send an IPv4 CIDR inside the VPC's CIDR. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: `cidr` is not a valid IPv4 CIDR. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a subnet ```http GET /public/v1/subnets/{subnet_id} ``` One subnet, with the number of instances in it. Scope: `read`. Path parameters: - `subnet_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/subnets/snet_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "app", "cidr": "10.0.1.0/24", "region": "af-abj", "zone": "af-abj-1", "instance_count": 2, "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no subnet with this id in your organization, or the id is not a subnet id (`snet_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a subnet ```http DELETE /public/v1/subnets/{subnet_id} ``` Deletes a subnet. It must hold no instances or databases. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `subnet_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/subnets/snet_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no subnet with this id in your organization, or the id is not a subnet id (`snet_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the subnet still has instances or databases, or is already being deleted. Delete them first. `SUBNET_HAS_KUBERNETES_CLUSTERS`: Kubernetes clusters still run in this subnet. Delete them first. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused a value, for example a malformed CIDR or a name longer than 255 characters. Check the request against this page. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## List a subnet's firewall rules ```http GET /public/v1/subnets/{subnet_id}/firewall-rules ``` The subnet's firewall rules, in evaluation order, and the platform's fixed rules around them (`system_rules`), which always apply. Scope: `read`. Path parameters: - `subnet_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/subnets/snet_06gk3x9tq7m2v8r4y1c5d0zp8w/firewall-rules" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "aclr_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "number": 100, "direction": "ingress", "protocol": "tcp", "port_start": 443, "port_end": 443, "icmp_type": 1, "icmp_code": 1, "cidr": "0.0.0.0/0", "action": "allow", "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null, "system_rules": [ { "number": 100, "direction": "ingress", "protocol": "tcp", "port_start": 443, "port_end": 443, "icmp_type": 1, "icmp_code": 1, "cidr": "0.0.0.0/0", "action": "allow" } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no subnet with this id in your organization, or the id is not a subnet id (`snet_…`). Check the id. `NOT_FOUND`: firewall rules are not offered on this platform yet. - `409`: `FIREWALL_RULES_NOT_SUPPORTED`: firewall rules apply to VPC subnets only. Standard instances use security groups. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Add a firewall rule ```http POST /public/v1/subnets/{subnet_id}/firewall-rules ``` Adds a rule to the subnet's firewall. Rules are evaluated by `number`, lowest first, and the first match decides; traffic no rule allows is dropped. A rule applies to the whole subnet. Defaults: `direction` `ingress`, `action` `allow`, `port_end` equal to `port_start`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The rule's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `subnet_id`: The resource's id. Body: - `number` (integer, required): Evaluation order, 100 to 9999, unique on the subnet; lower numbers are evaluated first and the first match decides. From 100 to 9999. - `direction` (string): `ingress` (traffic into the subnet) or `egress` (traffic out of it). Defaults to `ingress`. One of `ingress`, `egress`. Defaults to `ingress`. - `protocol` (string, required): `tcp`, `udp`, `icmp` or `all`. One of `tcp`, `udp`, `icmp`, `all`. - `port_start` (integer): First port, 1 to 65535. For `tcp` and `udp` only; leave out for `icmp` and `all`. - `port_end` (integer): Last port, 1 to 65535, at least `port_start`. Defaults to `port_start` (one port). - `icmp_type` (integer): ICMP type, for `icmp` only, e.g. `8` for echo request. Leave out for every type. - `icmp_code` (integer): ICMP code, for `icmp` only. Leave out for every code. - `cidr` (string, required): One IPv4 CIDR: the source for `ingress`, the destination for `egress`. `0.0.0.0/0` means anywhere. - `action` (string): `allow` or `deny`. Defaults to `allow`. One of `allow`, `deny`. Defaults to `allow`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/subnets/snet_06gk3x9tq7m2v8r4y1c5d0zp8w/firewall-rules" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "number": 100, "protocol": "tcp", "cidr": "203.0.113.0/24" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "aclr_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no subnet with this id in your organization, or the id is not a subnet id (`snet_…`). Check the id. `NOT_FOUND`: firewall rules are not offered on this platform yet. - `409`: `FIREWALL_RULES_NOT_SUPPORTED`: firewall rules apply to VPC subnets only. Standard instances use security groups. `INVALID_RESOURCE_STATE`: the subnet cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `protocol` or `cidr` `REQUIRED`: send both. `number` `INVALID_RULE_NUMBER`: use 100 to 9999. `number` `RULE_NUMBER_TAKEN`: another rule on this subnet has this number. Choose another. `port_start` `INVALID_PORTS`: send ports 1 to 65535 for `tcp` or `udp`, and leave them out for `icmp` or `all`. `protocol` `INVALID_FIREWALL_RULE`: `direction`, `protocol`, `action` or the ICMP fields are not valid values. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a firewall rule ```http GET /public/v1/subnets/{subnet_id}/firewall-rules/{rule_id} ``` One firewall rule on the subnet. Scope: `read`. Path parameters: - `subnet_id`: The resource's id. - `rule_id`: The rule's id (`aclr_...` for a firewall rule, `pfr_...` for a port forwarding rule). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/subnets/snet_06gk3x9tq7m2v8r4y1c5d0zp8w/firewall-rules/aclr_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "aclr_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "number": 100, "direction": "ingress", "protocol": "tcp", "port_start": 443, "port_end": 443, "icmp_type": 1, "icmp_code": 1, "cidr": "0.0.0.0/0", "action": "allow", "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no rule with this id on this subnet, or no such subnet in your organization. Check both ids. `NOT_FOUND`: firewall rules are not offered on this platform yet. - `409`: `FIREWALL_RULES_NOT_SUPPORTED`: firewall rules apply to VPC subnets only. Standard instances use security groups. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a firewall rule ```http DELETE /public/v1/firewall-rules/{rule_id} ``` Deletes one subnet firewall rule. Traffic it allowed is dropped once the operation succeeds, unless another rule allows it. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `rule_id`: The rule's id (`aclr_...` for a firewall rule, `pfr_...` for a port forwarding rule). Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/firewall-rules/aclr_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "aclr_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no firewall rule with this id in your organization, or the id is not a firewall rule id (`aclr_…`). Check the id. `NOT_FOUND`: firewall rules are not offered on this platform yet. - `409`: `INVALID_RESOURCE_STATE`: the firewall rule cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Public IPs Source: https://docs.pantechdynamics.com/api/public-ips Public addresses for VPC instances, with static NAT or port forwarding. A public IP belongs to a VPC. With purpose `static_nat` every port of the address maps to one instance. With `port_forwarding` the address carries rules, each forwarding a port or range to an instance in the VPC. With `load_balancer` it carries [load balancers](https://docs.pantechdynamics.com/api/load-balancers), one per public port. Endpoints: - `GET /public/v1/public-ips`: List public IPs - `POST /public/v1/public-ips`: Allocate a public IP - `GET /public/v1/public-ips/quote`: Price a public IP - `GET /public/v1/public-ips/{public_ip_id}`: Get a public IP - `DELETE /public/v1/public-ips/{public_ip_id}`: Release a public IP - `POST /public/v1/public-ips/{public_ip_id}/attach`: Attach a public IP to an instance - `GET /public/v1/public-ips/{public_ip_id}/port-forwarding-rules`: List port forwarding rules - `POST /public/v1/public-ips/{public_ip_id}/detach`: Detach a public IP from its instance - `POST /public/v1/public-ips/{public_ip_id}/port-forwarding-rules`: Add a port forwarding rule - `GET /public/v1/public-ips/{public_ip_id}/port-forwarding-rules/{rule_id}`: Get a port forwarding rule - `DELETE /public/v1/port-forwarding-rules/{rule_id}`: Delete a port forwarding rule ## The public IP object - `id` (string, required): e.g. `pip_...`. - `network_id` (string, required) - `network_name` (string or null): The VPC's name. - `purpose` (string, required): `static_nat` maps every port to one instance; `port_forwarding` carries per-port rules. One of `static_nat`, `port_forwarding`, `load_balancer`. - `address` (string or null): The public IPv4 address; null until allocated. - `instance_id` (string or null): static_nat: the instance it maps to; null while it is detached (still yours, and billed once). - `instance_name` (string or null): The mapped instance's name; null when none. - `region` (string or null) - `zone` (string or null) - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `in_sync` (boolean, required): False while an attach or detach has not reached the address yet. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## The port forwarding rule object - `id` (string, required): e.g. `pfr_...`. - `public_ip_id` (string, required) - `instance_id` (string, required) - `instance_name` (string or null): The target instance's name; null when it is gone. - `protocol` (string, required): `tcp` or `udp`. One of `tcp`, `udp`. - `public_port_start` (integer, required) - `public_port_end` (integer, required) - `private_port_start` (integer, required) - `private_port_end` (integer, required) - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## List public IPs ```http GET /public/v1/public-ips ``` Your VPC public IPs, or one VPC's with `network_id`. Scope: `read`. Query parameters: - `network_id` (string) Example: ```sh curl "https://api.pantechdynamics.com/public/v1/public-ips" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "network_name": "prod", "purpose": "static_nat", "address": "102.211.122.80", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_name": "web-1", "region": "af-abj", "zone": "af-abj-1", "desired_state": "present", "observed_state": "active", "in_sync": true, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `NOT_FOUND`: public IPs are not offered on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Allocate a public IP ```http POST /public/v1/public-ips ``` Allocates a public IPv4 address for a VPC. `static_nat` (the default) maps every port of the address to one instance in `network_id`, sent as `instance_id`; `port_forwarding`, where offered, carries per-port rules instead (add them with *Add a port forwarding rule*). The network must be active. A public IP is billed monthly: see *Price a public IP*. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The address is in the public IP's `address` once it succeeds. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Body: - `network_id` (string, required): The VPC network to allocate the address in. It must be active. - `purpose` (string): `static_nat` maps every port to one instance (`instance_id`). `port_forwarding` carries per-port rules instead, and `load_balancer` carries load balancers. Defaults to `static_nat`. One of `static_nat`, `port_forwarding`, `load_balancer`. Defaults to `static_nat`. - `instance_id` (string): For `static_nat` only: an instance in `network_id` without a public IP. Leave it out to reserve the address now and attach it later (`POST /public/v1/public-ips/{public_ip_id}/attach`). Leave out for `port_forwarding` and `load_balancer`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/public-ips" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: this API key has only the `read` scope. Use a key with `write` access. `FORBIDDEN`: the member who created this key may not do this in the organization, or has left it. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server. An owner can turn it on in the console under Organization › AI apps. `STATIC_NAT_LIMIT_EXCEEDED`: your organization has reached its limit of static NAT public IPs. Release one you no longer need, or contact support for a higher limit. `PUBLIC_IP_LIMIT_EXCEEDED`: your organization has reached its limit of public IPs of any purpose (20 by default). Release one you no longer need, or contact support for a higher limit. - `404`: `NOT_FOUND`: public IPs are not offered on this platform yet. `RESOURCE_NOT_FOUND`: `network_id` is not one of your VPC networks. - `409`: `NETWORK_NOT_READY`: public IPs can only be added to an active VPC network. Wait for the network to be active. `INSTANCE_ALREADY_HAS_PUBLIC_IP`: the instance already has a public IP. Release it before adding another. `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `network_id` `REQUIRED`: send the VPC network. `purpose` `PURPOSE_NOT_AVAILABLE`: use `static_nat`, `port_forwarding` or `load_balancer`. `instance_id` `INSTANCE_NOT_IN_NETWORK`: the instance is not in `network_id`. `instance_id` `NOT_ALLOWED`: a `port_forwarding` address takes no instance; each rule names its own. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Price a public IP ```http GET /public/v1/public-ips/quote ``` What one more public IP costs per month, in your organization's currency. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/public-ips/quote" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "currency": "NGN", "monthly_minor": 1700000, "nominal_month_hours": 730 } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `NOT_FOUND`: cost quotes are not available on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a public IP ```http GET /public/v1/public-ips/{public_ip_id} ``` One public IP, with the instance it maps to. Scope: `read`. Path parameters: - `public_ip_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "network_name": "prod", "purpose": "static_nat", "address": "102.211.122.80", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_name": "web-1", "region": "af-abj", "zone": "af-abj-1", "desired_state": "present", "observed_state": "active", "in_sync": true, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no public IP with this id in your organization, or the id is not a public IP id (`pip_…`). Check the id. `NOT_FOUND`: public IPs are not offered on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Release a public IP ```http DELETE /public/v1/public-ips/{public_ip_id} ``` Releases a public IP. The address is returned to the pool and may be given to someone else; billing stops when the operation succeeds. Delete its port forwarding rules first. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `public_ip_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no public IP with this id in your organization, or the id is not a public IP id (`pip_…`). Check the id. `NOT_FOUND`: public IPs are not offered on this platform yet. - `409`: `PUBLIC_IP_HAS_PORT_FORWARDS`: delete this address's port forwarding rules first. `PUBLIC_IP_HAS_LOAD_BALANCERS`: delete this address's load balancers first. `INVALID_RESOURCE_STATE`: the public IP cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Attach a public IP to an instance ```http POST /public/v1/public-ips/{public_ip_id}/attach ``` Points a static NAT public IP at an instance in the same private network (VPC), keeping the address. Use it to move an address from one instance to another without releasing it: you keep the same IP and pay for it once. If the address is attached elsewhere it moves; the instance must not already have a static NAT address of its own. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). Poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`. While it runs the address shows `in_sync: false`. Send an `Idempotency-Key`; a change that changes nothing returns an empty `operation_id`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `public_ip_id`: The resource's id. Body: - `instance_id` (string, required): An instance in the address's private network. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w/attach" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no public IP with this id in your organization, or the id is not a public IP id (`pip_…`). Check the id. `NOT_FOUND`: public IPs are not offered on this platform yet. - `409`: `PUBLIC_IP_NOT_STATIC_NAT`: only a static NAT address can be attached or detached; port forwarding and load balancer addresses use their rules. `INSTANCE_ALREADY_HAS_PUBLIC_IP`: the instance already has a static NAT address. Detach or release that one first. `INVALID_RESOURCE_STATE`: the public IP cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED`: `instance_id` `REQUIRED`, or `INSTANCE_NOT_IN_NETWORK` (the instance must be in the address's private network). With no `errors` entry: the platform refused the request, for example because the instance is deleted. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## List port forwarding rules ```http GET /public/v1/public-ips/{public_ip_id}/port-forwarding-rules ``` The address's port forwarding rules. Scope: `read`. Path parameters: - `public_ip_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w/port-forwarding-rules" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "pfr_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ip_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_name": "web-1", "protocol": "tcp", "public_port_start": 443, "public_port_end": 443, "private_port_start": 8443, "private_port_end": 8443, "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no public IP with this id in your organization, or the id is not a public IP id (`pip_…`). Check the id. `NOT_FOUND`: port forwarding is not offered on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Detach a public IP from its instance ```http POST /public/v1/public-ips/{public_ip_id}/detach ``` Unmaps a static NAT public IP from its instance. The address stays yours (and is billed once, as before) until you attach it to another instance or release it. The instance keeps running and can then be deleted. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). Poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`. While it runs the address shows `in_sync: false`. Send an `Idempotency-Key`; a change that changes nothing returns an empty `operation_id`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `public_ip_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w/detach" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no public IP with this id in your organization, or the id is not a public IP id (`pip_…`). Check the id. `NOT_FOUND`: public IPs are not offered on this platform yet. - `409`: `PUBLIC_IP_NOT_STATIC_NAT`: only a static NAT address can be attached or detached; port forwarding and load balancer addresses use their rules. `INVALID_RESOURCE_STATE`: the public IP cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Add a port forwarding rule ```http POST /public/v1/public-ips/{public_ip_id}/port-forwarding-rules ``` Forwards a port or range of a `port_forwarding` public IP to an instance in its VPC. Defaults: `public_port_end` equals `public_port_start`, and the private range equals the public one (or starts at `private_port_start` with the same width). Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The rule's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `public_ip_id`: The resource's id. Body: - `instance_id` (string, required): An instance in the public IP's VPC. - `protocol` (string, required): `tcp` or `udp`. One of `tcp`, `udp`. - `public_port_start` (integer, required): First public port, 1 to 65535. - `public_port_end` (integer): Last public port. Defaults to `public_port_start` (one port). - `private_port_start` (integer): First port on the instance. Defaults to `public_port_start`. - `private_port_end` (integer): Last port on the instance; the private range must be as wide as the public one. Defaults to match it. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w/port-forwarding-rules" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "protocol": "tcp", "public_port_start": 443 }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "pfr_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no public IP with this id in your organization, or the id is not a public IP id (`pip_…`). Check the id. `NOT_FOUND`: port forwarding is not offered on this platform yet. - `409`: `PUBLIC_IP_NOT_FOR_PORT_FORWARDING`: use an active public IP with `purpose` `port_forwarding`. `PORTS_IN_USE`: another rule on this address already forwards some of these ports for this protocol. Choose other ports, or delete that rule. `INVALID_RESOURCE_STATE`: the public IP cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `instance_id` or `protocol` `REQUIRED`: send both. `instance_id` `INSTANCE_NOT_IN_NETWORK`: the instance is not in the address's VPC. `public_port_start` `INVALID_PORTS`: use `tcp` or `udp`, ports 1 to 65535, and public and private ranges of the same width. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a port forwarding rule ```http GET /public/v1/public-ips/{public_ip_id}/port-forwarding-rules/{rule_id} ``` One port forwarding rule on the address. Scope: `read`. Path parameters: - `public_ip_id`: The resource's id. - `rule_id`: The rule's id (`aclr_...` for a firewall rule, `pfr_...` for a port forwarding rule). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/public-ips/pip_06gk3x9tq7m2v8r4y1c5d0zp8w/port-forwarding-rules/pfr_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "pfr_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ip_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_name": "web-1", "protocol": "tcp", "public_port_start": 443, "public_port_end": 443, "private_port_start": 8443, "private_port_end": 8443, "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no rule with this id on this address, or no such address in your organization. Check both ids. `NOT_FOUND`: port forwarding is not offered on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a port forwarding rule ```http DELETE /public/v1/port-forwarding-rules/{rule_id} ``` Deletes one port forwarding rule. Traffic to those ports stops once the operation succeeds. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `rule_id`: The rule's id (`aclr_...` for a firewall rule, `pfr_...` for a port forwarding rule). Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/port-forwarding-rules/pfr_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "pfr_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no port forwarding rule with this id in your organization, or the id is not a port forwarding rule id (`pfr_…`). Check the id. `NOT_FOUND`: port forwarding is not offered on this platform yet. - `409`: `INVALID_RESOURCE_STATE`: the port forwarding rule cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Load balancers Source: https://docs.pantechdynamics.com/api/load-balancers Spread incoming traffic across instances using VPC-native or appliance load balancers. A load balancer distributes incoming network connections across multiple instances. Pantech supports two kinds of load balancers: `vpc_native` for instances in a private VPC subnet (using a reserved public IP with purpose `load_balancer`), and `appliance` for standard security group zones (with dedicated public IP allocation and HTTP/HTTPS/TCP support). The ports, zone, kind, and network settings are fixed once created; the name, algorithm, and backend targets can change. See the [load balancers guide](https://docs.pantechdynamics.com/networking/load-balancers). Endpoints: - `GET /public/v1/load-balancers`: List load balancers - `POST /public/v1/load-balancers`: Create a load balancer - `GET /public/v1/load-balancers/{load_balancer_id}`: Get a load balancer - `PATCH /public/v1/load-balancers/{load_balancer_id}`: Change a load balancer - `DELETE /public/v1/load-balancers/{load_balancer_id}`: Delete a load balancer ## The load balancer object - `id` (string, required): e.g. `lb_...`. - `name` (string, required) - `zone_id` (string, required) - `kind` (string, required): One of `vpc_native`, `appliance`. - `public_ip_id` (string, required) - `public_ip_address` (string or null, required): The address clients connect to; null until it is known. - `network_id` (string or null, required) - `subnet_id` (string, required) - `protocol` (string, required): One of `tcp`, `http`, `https`. - `algorithm` (string, required): One of `roundrobin`, `leastconn`, `source`. - `public_port` (integer, required) - `private_port` (integer, required) - `cidr_list` (array of string, required): Source addresses allowed to connect; empty is anywhere. - `members` (array of LoadBalancerMember, required) - `instance_id` (string, required) - `instance_name` (string or null): The instance's name; null when it is gone. - `desired_state` (string, required): `present` while it should receive traffic; `deleted` once you removed it and that has not finished. One of `present`, `deleted`. - `observed_state` (string, required): `active` once it receives traffic. One of `pending`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): `active` once it serves traffic. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `in_sync` (boolean, required): False while a change you made has not reached the load balancer yet. - `created_at` (string or null, required) - `updated_at` (string or null, required) ## List load balancers ```http GET /public/v1/load-balancers ``` Your load balancers, newest first, each with its targets. Deleted and failed ones are left out. Narrow it with `network_id` or `public_ip_id`. Scope: `read`. Query parameters: - `network_id` (string): Only load balancers in this private network (`net_…`). - `public_ip_id` (string): Only load balancers on this public IP (`pip_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/load-balancers" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "lb_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web", "zone_id": "af-abj-2", "kind": "vpc_native", "public_ip_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ip_address": "102.211.122.80", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "protocol": "tcp", "algorithm": "roundrobin", "public_port": 443, "private_port": 8443, "cidr_list": [], "members": [ { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_name": "web-1", "desired_state": "present", "observed_state": "active" } ], "desired_state": "present", "observed_state": "active", "in_sync": true, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `NOT_FOUND`: load balancers are not offered on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a load balancer ```http POST /public/v1/load-balancers ``` Spreads one TCP port of a `load_balancer` public IP across instances in one subnet of that IP's private network (VPC). The public IP must be active and have `purpose` `load_balancer`; one public IP can carry several load balancers, one per public port. `private_port` defaults to `public_port`, and `algorithm` to `roundrobin`. `cidr_list` limits which source addresses may connect (default: anywhere). The subnet's firewall rules must allow the port. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. The load balancer's id is the operation's `resource_id`. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 63 characters. - `zone_id` (string): The zone to place the load balancer in. - `kind` (string): `vpc_native` (a VPC zone: needs `public_ip_id` and `subnet_id`) or `appliance` (a standard zone: needs `zone_id`; a load balancer VM with its own public address, billed as that VM). Left out, it is `vpc_native` when `subnet_id` is given and `appliance` when only `zone_id` is. Anything else is `422 INVALID_LOAD_BALANCER_KIND`; an appliance in a zone that does not offer one fails its operation with `LOAD_BALANCER_APPLIANCE_UNAVAILABLE`. One of `vpc_native`, `appliance`. - `public_ip_id` (string): An active public IP with `purpose` `load_balancer` (required for `vpc_native`). - `subnet_id` (string): A subnet of the public IP's private network. Every target must be in it (required for `vpc_native`). - `algorithm` (string): `roundrobin` takes turns, `leastconn` picks the target with the fewest connections, `source` keeps a client on one target. One of `roundrobin`, `leastconn`, `source`. Defaults to `roundrobin`. - `public_port` (integer, required): The TCP port clients connect to, 1 to 65535. - `private_port` (integer): The port on each target. Defaults to `public_port`. - `cidr_list` (array of string): Source addresses allowed to connect, as IPv4 CIDRs (at most 20). Leave out for anywhere. - `instance_ids` (array of string): Instances to spread traffic across (at most 50). You can add them later. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/load-balancers" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "web", "public_port": 443, "private_port": 8443, "instance_ids": [ "vm_06gk3x9tq7m2v8r4y1c5d0zp8w" ] }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "lb_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: the public IP, subnet or an instance does not exist in your organization. Check the ids. `NOT_FOUND`: load balancers are not offered on this platform yet. - `409`: `PUBLIC_IP_NOT_FOR_LOAD_BALANCER`: use an active public IP with `purpose` `load_balancer`. `PORT_IN_USE`: another load balancer on this public IP already uses this port. Choose another port, or delete that load balancer. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name`, `public_ip_id`, `subnet_id` or `public_port` `REQUIRED`: send them. `public_port` `INVALID_LOAD_BALANCER`: `name` 1 to 63 characters, `algorithm` `roundrobin`, `leastconn` or `source`, ports 1 to 65535, and at most 20 IPv4 CIDRs in `cidr_list`. `subnet_id` `SUBNET_NOT_IN_NETWORK`: the subnet is not in the public IP's private network. `instance_ids` `INSTANCE_NOT_IN_SUBNET`: every instance must be in the load balancer's subnet. `instance_ids` `TOO_MANY`: at most 50 instances. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a load balancer ```http GET /public/v1/load-balancers/{load_balancer_id} ``` One load balancer, with its targets. `in_sync` is false while a change you made has not reached it yet. Scope: `read`. Path parameters: - `load_balancer_id`: The load balancer's id (`lb_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/load-balancers/lb_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "lb_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web", "zone_id": "af-abj-2", "kind": "vpc_native", "public_ip_id": "pip_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ip_address": "102.211.122.80", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "protocol": "tcp", "algorithm": "roundrobin", "public_port": 443, "private_port": 8443, "cidr_list": [], "members": [ { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "instance_name": "web-1", "desired_state": "present", "observed_state": "active" } ], "desired_state": "present", "observed_state": "active", "in_sync": true, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no load balancer with this id in your organization, or the id is not a load balancer id (`lb_…`). Check the id. `NOT_FOUND`: load balancers are not offered on this platform yet. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Change a load balancer ```http PATCH /public/v1/load-balancers/{load_balancer_id} ``` Renames it, changes its `algorithm`, or replaces its targets: `instance_ids`, when sent (even empty), is the whole new set. Fields you leave out stay as they are. The ports, the public IP, the subnet and `cidr_list` cannot change: create a new load balancer for those. A change that changes nothing returns an empty `operation_id`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `load_balancer_id`: The load balancer's id (`lb_…`). Body: - `name` (string): A new name, 1 to 63 characters. - `algorithm` (string): One of `roundrobin`, `leastconn`, `source`. - `instance_ids` (array of string): The whole new set of targets (at most 50). Send `[]` to remove them all. Example: ```sh curl -X PATCH "https://api.pantechdynamics.com/public/v1/load-balancers/lb_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "instance_ids": [ "vm_06gk3x9tq7m2v8r4y1c5d0zp8w" ] }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "lb_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no load balancer with this id in your organization, or the id is not a load balancer id (`lb_…`). Check the id. `NOT_FOUND`: load balancers are not offered on this platform yet. - `409`: `LOAD_BALANCER_NOT_CHANGEABLE`: the load balancer is being deleted or has failed. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `public_port` `INVALID_LOAD_BALANCER`: `name` 1 to 63 characters and `algorithm` `roundrobin`, `leastconn` or `source`. `instance_ids` `INSTANCE_NOT_IN_SUBNET`: every instance must be in the load balancer's subnet. `instance_ids` `TOO_MANY`: at most 50 instances. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Delete a load balancer ```http DELETE /public/v1/load-balancers/{load_balancer_id} ``` Deletes the load balancer. Traffic to its port stops once the operation succeeds; the instances and the public IP are kept. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `load_balancer_id`: The load balancer's id (`lb_…`). Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/load-balancers/lb_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "lb_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no load balancer with this id in your organization, or the id is not a load balancer id (`lb_…`). Check the id. `NOT_FOUND`: load balancers are not offered on this platform yet. - `409`: `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Security groups Source: https://docs.pantechdynamics.com/api/security-groups Reusable firewalls for standard instances. A security group is a set of rules shared by every standard instance that uses it. Inbound traffic no rule allows is dropped. Each organization has a default group, used when an instance names none. VPC instances use their subnet's [firewall rules](https://docs.pantechdynamics.com/api/networks) instead. Endpoints: - `GET /public/v1/security-groups`: List security groups - `POST /public/v1/security-groups`: Create a security group - `GET /public/v1/security-groups/{security_group_id}`: Get a security group - `PUT /public/v1/security-groups/{security_group_id}/rules`: Replace a security group's rules - `DELETE /public/v1/security-groups/{security_group_id}`: Delete a security group ## The security group object A reusable firewall for standard instances. - `id` (string, required): e.g. `sg_...`. - `name` (string, required): Its name, unique among your live security groups. The organization's default group is called `default`. - `rules` (array of SecurityGroupRule, required): Its rules. Inbound traffic no rule allows is dropped. - `direction` (string, required): `ingress` (into the instance) or `egress` (out of it). One of `ingress`, `egress`. - `protocol` (string, required): `tcp`, `udp`, `icmp` or `all`. One of `tcp`, `udp`, `icmp`, `all`. - `port_range` (string): For `tcp`/`udp`: one port (`22`) or an inclusive range (`8000-8100`). Empty or left out for `icmp` and `all`, meaning every port. - `cidr` (string, required): One IPv4 CIDR: the source for `ingress`, the destination for `egress`, e.g. `203.0.113.4/32`. Avoid `0.0.0.0/0` on ingress where you can: it blocks private database network interfaces on instances using the group. - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## List security groups ```http GET /public/v1/security-groups ``` Your security groups, newest first, with their rules. Filter by name with `q`, or by state. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/security-groups" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web", "rules": [ { "direction": "ingress", "protocol": "tcp", "port_range": "22", "cidr": "203.0.113.4/32" } ], "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a security group ```http POST /public/v1/security-groups ``` Creates a reusable firewall for standard instances in a security-group zone (`af-abj-1`). Each rule has a `direction`, a `protocol` (`tcp`, `udp`, `icmp` or `all`), a `port_range` for `tcp`/`udp` and one IPv4 `cidr`. Inbound traffic no rule allows is dropped. A group needs at least one rule. The name is unique among your live security groups; a deleted group frees its name. Attach the group to an instance at create (`security_group_id`) or with *Change an instance's security group*, or to a database as a source of allowed address ranges. Prefer narrow sources: a rule from `0.0.0.0/0` stops an instance from attaching a private database network interface. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The group's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 255 characters, unique among your live security groups. At most 255 characters. - `rules` (array of SecurityGroupRule): The group's rules; at least one. Inbound traffic no rule allows is dropped. - `direction` (string, required): `ingress` (into the instance) or `egress` (out of it). One of `ingress`, `egress`. - `protocol` (string, required): `tcp`, `udp`, `icmp` or `all`. One of `tcp`, `udp`, `icmp`, `all`. - `port_range` (string): For `tcp`/`udp`: one port (`22`) or an inclusive range (`8000-8100`). Empty or left out for `icmp` and `all`, meaning every port. - `cidr` (string, required): One IPv4 CIDR: the source for `ingress`, the destination for `egress`, e.g. `203.0.113.4/32`. Avoid `0.0.0.0/0` on ingress where you can: it blocks private database network interfaces on instances using the group. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/security-groups" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "web", "rules": [ { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "0.0.0.0/0" }, { "direction": "ingress", "protocol": "tcp", "port_range": "22", "cidr": "203.0.113.4/32" } ] }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `409`: `SECURITY_GROUP_NAME_TAKEN`: you already have a security group with this name. Choose a different name. `SECURITY_GROUPS_NOT_SUPPORTED_IN_ZONE`: this zone uses VPC firewall rules, not security groups. `PROVIDER_ACCOUNT_NOT_READY`: your organization's cloud account is still being set up, which takes a few minutes after sign-up. Retry shortly. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: the rule list is empty, or a rule has an unknown `direction` or `protocol` or an invalid IPv4 `cidr`. Send at least one valid rule. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a security group ```http GET /public/v1/security-groups/{security_group_id} ``` One security group and its rules. Scope: `read`. Path parameters: - `security_group_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/security-groups/sg_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web", "rules": [ { "direction": "ingress", "protocol": "tcp", "port_range": "22", "cidr": "203.0.113.4/32" } ], "desired_state": "present", "observed_state": "active", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no security group with this id in your organization, or the id is not a security group id (`sg_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Replace a security group's rules ```http PUT /public/v1/security-groups/{security_group_id}/rules ``` Replaces the group's complete rule list, for every instance using it. Each rule has a `direction`, a `protocol` (`tcp`, `udp`, `icmp` or `all`), a `port_range` for `tcp`/`udp` and one IPv4 `cidr`. Inbound traffic no rule allows is dropped. A group needs at least one rule. Databases the group is attached to are updated automatically; a list that would give one of them more than 1024 address ranges is refused. While an instance using the group has a private database network interface, an ingress rule whose source overlaps the private network's range (`10.250.0.0/20`), `0.0.0.0/0` included, is refused. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `security_group_id`: The resource's id. Body: - `rules` (array of SecurityGroupRule, required): The complete new rule list; at least one rule. It replaces every existing rule. - `direction` (string, required): `ingress` (into the instance) or `egress` (out of it). One of `ingress`, `egress`. - `protocol` (string, required): `tcp`, `udp`, `icmp` or `all`. One of `tcp`, `udp`, `icmp`, `all`. - `port_range` (string): For `tcp`/`udp`: one port (`22`) or an inclusive range (`8000-8100`). Empty or left out for `icmp` and `all`, meaning every port. - `cidr` (string, required): One IPv4 CIDR: the source for `ingress`, the destination for `egress`, e.g. `203.0.113.4/32`. Avoid `0.0.0.0/0` on ingress where you can: it blocks private database network interfaces on instances using the group. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/security-groups/sg_06gk3x9tq7m2v8r4y1c5d0zp8w/rules" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "rules": [ { "direction": "ingress", "protocol": "tcp", "port_range": "22", "cidr": "203.0.113.4/32" }, { "direction": "ingress", "protocol": "tcp", "port_range": "5432", "cidr": "10.0.1.0/24" } ] }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no security group with this id in your organization, or the id is not a security group id (`sg_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the security group cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `rules` `REQUIRED`: send the complete rule list. `rules` `RULE_ALLOWS_PRIVATE_NETWORK`: an instance using this group has a private database network interface, and a rule's source overlaps the private network's range; the message names the range and the rules. Replace `0.0.0.0/0` (and any other overlapping source) with narrower ranges that leave out `10.250.0.0/20`. `rules` `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED`: a database this group is attached to would get more than 1024 address ranges. Use fewer or wider (but at most /8) ranges, or detach the group from that database. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: the list is empty, or a rule has an unknown `direction` or `protocol` or an invalid IPv4 `cidr`. Send at least one valid rule. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Delete a security group ```http DELETE /public/v1/security-groups/{security_group_id} ``` Deletes a security group. It must not be used by any instance. The organization's default group cannot be deleted. A group attached to a database cannot be deleted either: detach it with `PUT /public/v1/databases/{database_id}/security-groups` first. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `security_group_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/security-groups/sg_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "sg_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no security group with this id in your organization, or the id is not a security group id (`sg_…`). Check the id. - `409`: `DEFAULT_SECURITY_GROUP_UNDELETABLE`: the organization's default group cannot be deleted. You can change its rules instead. `SECURITY_GROUP_ATTACHED_TO_DATABASE`: a database uses this group; `detail` names it. Detach the group from that database, then retry. `INVALID_RESOURCE_STATE`: instances still use this group, or it is already being deleted. Move those instances to another group first. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Volumes Source: https://docs.pantechdynamics.com/api/volumes Data disks: create, attach, detach, grow and delete. A volume is a data disk, separate from an instance's root disk. Attach it to a running instance in the same zone, then format and mount it from the instance. Volumes only grow. Endpoints: - `GET /public/v1/instances/{instance_id}/volumes`: List an instance's volumes - `GET /public/v1/volumes`: List volumes - `POST /public/v1/volumes`: Create a volume - `GET /public/v1/volumes/{volume_id}`: Get a volume - `DELETE /public/v1/volumes/{volume_id}`: Delete a volume - `POST /public/v1/volumes/{volume_id}/attach`: Attach a volume - `POST /public/v1/volumes/{volume_id}/detach`: Detach a volume - `POST /public/v1/volumes/{volume_id}/resize`: Grow a volume ## The volume object - `id` (string, required): e.g. `vol_...`. - `name` (string, required): Its name. - `size_gb` (integer, required): Its size in GB. - `disk_offering_slug` (string, required): See `GET /public/v1/disk-offerings`. - `storage_type` (string or null): The storage class, e.g. `shared`; null when not stated. - `region` (string or null) - `zone` (string or null) - `attached_instance_id` (string or null): The instance it is attached to; null when detached. - `attached_instance_name` (string or null): That instance's name; null when detached. - `desired_instance_id` (string or null): Where an attach in progress is moving it. - `mount_point` (string or null): Where it is mounted, when mounted at create; null otherwise. - `source_snapshot_id` (string or null): Set when restored from a snapshot. - `monthly_cost` (object or null): What it costs per month at its size; null when it cannot be priced. - `currency` (string, required) - `amount_minor` (integer, required) - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `encryption` (object): What the platform can truthfully say about this volume's encryption. `status` is `none` for a plain volume, `requested` when you asked for encryption at creation and the disk has not been formatted yet, and `encrypted` once the platform has confirmed the disk was formatted with a key from the volume key service. `encrypted` is set only by the platform, never from a claim. - `status` (string, required): One of `none`, `requested`, `encrypted`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## List an instance's volumes ```http GET /public/v1/instances/{instance_id}/volumes ``` The volumes attached to this instance. Scope: `read`. Path parameters: - `instance_id`: The resource's id. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/volumes" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "data", "size_gb": 100, "disk_offering_slug": "medium-20gb", "storage_type": "shared", "region": "af-abj", "zone": "af-abj-1", "attached_instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "attached_instance_name": "web-1", "desired_instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "mount_point": "/data", "source_snapshot_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "monthly_cost": { "currency": "NGN", "amount_minor": 1059200 }, "desired_state": "present", "observed_state": "active", "encryption": { "status": "none" }, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List volumes ```http GET /public/v1/volumes ``` Your volumes, newest first, with what each costs per month. Filter by name with `q`, or by state. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/volumes" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "data", "size_gb": 100, "disk_offering_slug": "medium-20gb", "storage_type": "shared", "region": "af-abj", "zone": "af-abj-1", "attached_instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "attached_instance_name": "web-1", "desired_instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "mount_point": "/data", "source_snapshot_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "monthly_cost": { "currency": "NGN", "amount_minor": 1059200 }, "desired_state": "present", "observed_state": "active", "encryption": { "status": "none" }, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a volume ```http POST /public/v1/volumes ``` Creates a data disk of `disk_offering_slug` (see `GET /public/v1/disk-offerings`; send `size_gb` for a custom-size offering). Send `instance_id` to attach it at once to a running instance in the same zone, and `mount_point` to have it mounted there. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The volume's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 255 characters. At most 255 characters. - `disk_offering_slug` (string, required): The volume type, from `GET /public/v1/disk-offerings`. - `size_gb` (integer): Size in GB. Required for an offering with `custom_size: true`; ignored otherwise. - `region` (string): A region from `GET /public/v1/regions`. Defaults to the platform's region. - `instance_id` (string): Attach the new volume at once to this running instance in the same zone. - `mount_point` (string): With `instance_id`: an absolute path to mount the volume at, e.g. `/data`. - `encrypted` (boolean): Optional. `true` asks for an **encrypted data volume**. It is a request, not encryption yet: the volume reports `encryption.status` `requested` until its disk has been formatted with a key from Pantech, and `encrypted` only once the platform has confirmed that. The format happens inside the instance, by the Pantech volume agent (`pantech-vkey`): attach the volume to an instance that runs the agent and it formats the new, empty disk for you as soon as it appears, so you only create a filesystem on `/dev/mapper/pvk-...` and mount it. Creating or attaching alone encrypts nothing, an instance without the agent cannot encrypt it, and a disk that already holds data is never formatted. Its key is released only to the instance of your organization the volume is attached to, so the volume can be used with any instance that runs the agent, an encrypted VM or a plain one. Refused with `ENCRYPTION_NOT_SUPPORTED` when encrypted volumes are not enabled for your organization, and `ENCRYPTED_VOLUMES_UNAVAILABLE` while the service is off. `false` or omitted creates a plain volume, as before. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/volumes" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "data", "disk_offering_slug": "medium-20gb", "size_gb": 100, "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `PROVIDER_ACCOUNT_NOT_READY`: your organization's cloud account is still being set up, which takes a few minutes after sign-up. Retry shortly. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` or `disk_offering_slug` `REQUIRED`: send both. `name` `NAME_TOO_LONG`: at most 255 characters. `disk_offering_slug` `DISK_OFFERING_NOT_FOUND`: no active disk offering has this slug. Pick one from `GET /public/v1/disk-offerings`. `size_gb` `SIZE_REQUIRED`: a custom-size offering needs `size_gb`. `instance_id` `INSTANCE_NOT_FOUND`: not one of your instances. `instance_id` `INSTANCE_NOT_RUNNING`: the instance must be running to attach a volume at create. Start it, or attach the volume later. `instance_id` `INSTANCE_WRONG_ZONE`: the instance is in another zone than the volume. `mount_point` `INVALID_MOUNT_POINT`: send an absolute path such as `/data`. `region` `REGION_NOT_AVAILABLE`: pick a region from `GET /public/v1/regions`. `encrypted` `ENCRYPTION_NOT_SUPPORTED`: encrypted volumes are not enabled for your organization yet. Create a plain volume, or send `encrypted` as `false`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `ENCRYPTED_VOLUMES_UNAVAILABLE`: you asked for an encrypted volume but the encrypted volume service is not available right now. Retry later, or create a plain volume. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a volume ```http GET /public/v1/volumes/{volume_id} ``` One volume, with where it is attached and what it costs. Scope: `read`. Path parameters: - `volume_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "data", "size_gb": 100, "disk_offering_slug": "medium-20gb", "storage_type": "shared", "region": "af-abj", "zone": "af-abj-1", "attached_instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "attached_instance_name": "web-1", "desired_instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "mount_point": "/data", "source_snapshot_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "monthly_cost": { "currency": "NGN", "amount_minor": 1059200 }, "desired_state": "present", "observed_state": "active", "encryption": { "status": "none" }, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a volume ```http DELETE /public/v1/volumes/{volume_id} ``` Deletes a volume and its data. Detach it first. Its snapshots are kept and stay billed until you delete them. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `volume_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the volume is still attached, or is already being deleted. Detach it first. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused a value, for example a malformed CIDR or a name longer than 255 characters. Check the request against this page. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Attach a volume ```http POST /public/v1/volumes/{volume_id}/attach ``` Attaches a detached volume to a running instance in the same zone. Mount it inside the instance afterwards. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `volume_id`: The resource's id. Body: - `instance_id` (string, required): A running instance in the volume's zone, e.g. `vm_…`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/attach" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the volume is already attached or changing, or the instance is not running. Detach it first, or start the instance. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `instance_id` `REQUIRED` or `INSTANCE_NOT_FOUND`: send one of your instances. `instance_id` `INSTANCE_NOT_RUNNING`: the instance must be running. `instance_id` `INSTANCE_WRONG_ZONE`: the instance is in another zone than the volume. Volumes attach only within their zone. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Detach a volume ```http POST /public/v1/volumes/{volume_id}/detach ``` Detaches a volume from its instance. Unmount it inside the instance first, or data being written may be lost. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `volume_id`: The resource's id. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/detach" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the volume is not attached, or is changing. Wait for its current operation to finish. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Grow a volume ```http POST /public/v1/volumes/{volume_id}/resize ``` Grows a volume to a bigger offering, or to a bigger `size_gb` of a custom-size offering. Volumes only grow. Extend the file system from inside the instance afterwards. No upfront charge: the larger size is billed hourly. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `volume_id`: The resource's id. Body: - `disk_offering_slug` (string, required): The volume type after the resize, from `GET /public/v1/disk-offerings`. - `size_gb` (integer): The new size in GB, for a custom-size offering: larger than the current size. Volumes only grow. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/resize" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "disk_offering_slug": "medium-20gb", "size_gb": 20 }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the new size is not larger than the current one, or the volume is changing. Send a larger size, or wait. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `disk_offering_slug` `REQUIRED` or `DISK_OFFERING_NOT_FOUND`: send an active offering's slug. `size_gb` `SIZE_REQUIRED`: a custom-size offering needs `size_gb`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Snapshots Source: https://docs.pantechdynamics.com/api/snapshots Point-in-time copies of root disks, volumes and database data disks, and automatic schedules. A snapshot copies an instance's root disk, a volume or a [database's data disk](https://docs.pantechdynamics.com/databases/snapshots) at the moment it is taken; exactly one of `instance_id`, `volume_id` and `database_id` is set. Restore an instance's own snapshot with `POST /instances/{instance_id}/restore`, or turn any snapshot into a new volume with `POST /snapshots/{snapshot_id}/restore`. A schedule takes snapshots automatically and keeps the newest `retention_count`. Endpoints: - `GET /public/v1/instances/{instance_id}/snapshots`: List an instance's snapshots - `POST /public/v1/instances/{instance_id}/snapshots`: Snapshot an instance - `GET /public/v1/instances/{instance_id}/snapshot-schedule`: Get an instance's snapshot schedule - `PUT /public/v1/instances/{instance_id}/snapshot-schedule`: Set an instance's snapshot schedule - `GET /public/v1/volumes/{volume_id}/snapshots`: List a volume's snapshots - `POST /public/v1/volumes/{volume_id}/snapshots`: Snapshot a volume - `GET /public/v1/volumes/{volume_id}/snapshot-schedule`: Get a volume's snapshot schedule - `PUT /public/v1/volumes/{volume_id}/snapshot-schedule`: Set a volume's snapshot schedule - `GET /public/v1/snapshots`: List snapshots - `GET /public/v1/snapshots/quote`: Price a snapshot - `GET /public/v1/snapshots/{snapshot_id}`: Get a snapshot - `DELETE /public/v1/snapshots/{snapshot_id}`: Delete a snapshot - `POST /public/v1/snapshots/{snapshot_id}/restore`: Restore a snapshot to a new volume ## The snapshot object - `id` (string, required): e.g. `snap_...`. - `name` (string, required): Its name, unique among its source's snapshots. - `instance_id` (string or null): Set for a snapshot of an instance's root disk. - `source_instance_name` (string or null): The source instance's name; null when it is gone or not an instance snapshot. - `volume_id` (string or null): Set for a snapshot of a volume. Exactly one of instance_id, volume_id and database_id is set. - `volume_name` (string or null): The source volume's name; null when it is gone or not a volume snapshot. - `database_id` (string or null): Set for a snapshot of a managed database's data disk. Exactly one of `instance_id`, `volume_id` and `database_id` is set. - `region` (string or null): The source's region, e.g. `af-abj`. - `trigger` (string, required): `automatic` when taken by a snapshot schedule. One of `manual`, `automatic`. - `size_bytes` (integer, required): Storage used; 0 until complete. - `desired_state` (string, required): What you asked for: `present`, or `deleted` once you deleted it. One of `present`, `deleted`. - `observed_state` (string, required): What the platform last observed: `pending` and `provisioning` while it is being created, `active` once ready, `deleting`/`deleted` on the way out, `failed` if it could not be made, `unknown` when it could not be checked. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. - `completed_at` (string or null): When the snapshot finished; null until then. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## The snapshot schedule object - `instance_id` (string or null): The instance whose root disk is snapshotted; null for a volume's schedule. - `volume_id` (string or null): The volume snapshotted; null for an instance's schedule. - `frequency` (string, required): `daily`, `weekly` or `monthly`. One of `daily`, `weekly`, `monthly`. - `retention_count` (integer, required): Successful copies kept; older ones are deleted. From 1 to 168. - `enabled` (boolean, required): False while paused. - `next_run_at` (string or null): When the next automatic snapshot is due; null while paused. ## List an instance's snapshots ```http GET /public/v1/instances/{instance_id}/snapshots ``` Snapshots of this instance's root disk, oldest first. Scope: `read`. Path parameters: - `instance_id`: The resource's id. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "pre-upgrade", "instance_id": null, "source_instance_name": null, "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_name": "data", "database_id": null, "region": "af-abj", "trigger": "manual", "size_bytes": 1610612736, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Snapshot an instance ```http POST /public/v1/instances/{instance_id}/snapshots ``` Takes a snapshot of the instance's root disk, named `name` (unique among this instance's snapshots). A snapshot of a running instance is crash-consistent: what the disk held at that instant. Billed on its physical size, hourly, until deleted. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The new snapshot's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `name` (string, required): 1 to 255 characters, unique among this resource's snapshots, e.g. `pre-upgrade`. At most 255 characters. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "pre-upgrade" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `SNAPSHOT_NAME_TAKEN`: this instance already has a snapshot with that name. Choose a different name. `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the instance cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get an instance's snapshot schedule ```http GET /public/v1/instances/{instance_id}/snapshot-schedule ``` The instance's automatic snapshot schedule: how often a snapshot of its root disk is taken, how many are kept, and when the next one runs. Scope: `read`. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshot-schedule" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "frequency": "daily", "retention_count": 7, "enabled": true, "next_run_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. `SNAPSHOT_SCHEDULE_NOT_FOUND`: no schedule has been set for this resource. Set one with the `PUT` on this path. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Set an instance's snapshot schedule ```http PUT /public/v1/instances/{instance_id}/snapshot-schedule ``` Turns automatic snapshots of the root disk on, changes them, or pauses them with `enabled: false`. `frequency` is `daily`, `weekly` or `monthly`; `retention_count` (1 to 168, default 7) successful snapshots are kept and older automatic ones are deleted. Automatic snapshots are billed like manual ones. Returns the schedule at once (`200`); no operation to follow. Send an `Idempotency-Key`: a retry with the same key and settings returns the same schedule. **Scope:** `write`. Lowering `retention_count` deletes the oldest copies, so an AI app's key also needs `delete` for that (`403 INSUFFICIENT_SCOPE` otherwise). Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `instance_id`: The resource's id. Body: - `frequency` (string, required): How often: `daily`, `weekly` or `monthly`. One of `daily`, `weekly`, `monthly`. - `retention_count` (integer): How many successful automatic snapshots to keep, 1 to 168; older ones are deleted. Defaults to 7. From 1 to 168. Defaults to `7`. - `enabled` (boolean): False pauses the schedule without forgetting it. Defaults to true. Defaults to `true`. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshot-schedule" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "frequency": "daily", "retention_count": 7 }' ``` Response `200`: ```json { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "frequency": "daily", "retention_count": 7, "enabled": true, "next_run_at": "2026-10-03T09:30:00Z" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `409`: `IDEMPOTENCY_CONFLICT`: this `Idempotency-Key` was already used with different settings. Use a new key for a different request. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `frequency` `REQUIRED`: send `daily`, `weekly` or `monthly`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: `frequency` is not `daily`, `weekly` or `monthly`, or `retention_count` is outside 1 to 168. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List a volume's snapshots ```http GET /public/v1/volumes/{volume_id}/snapshots ``` Snapshots of this volume, oldest first. Scope: `read`. Path parameters: - `volume_id`: The resource's id. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "pre-upgrade", "instance_id": null, "source_instance_name": null, "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_name": "data", "database_id": null, "region": "af-abj", "trigger": "manual", "size_bytes": 1610612736, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Snapshot a volume ```http POST /public/v1/volumes/{volume_id}/snapshots ``` Takes a snapshot of the volume, named `name`. A snapshot of an attached volume is crash-consistent. Billed on its physical size, hourly, until deleted. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The new snapshot's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `volume_id`: The resource's id. Body: - `name` (string, required): 1 to 255 characters, unique among this resource's snapshots, e.g. `pre-upgrade`. At most 255 characters. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "pre-upgrade" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `409`: `SNAPSHOT_NAME_TAKEN`: this volume already has a snapshot with that name. Choose a different name. `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the volume cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `REQUIRED` or `NAME_TOO_LONG`: send a name of 1 to 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a volume's snapshot schedule ```http GET /public/v1/volumes/{volume_id}/snapshot-schedule ``` The volume's automatic snapshot schedule: how often a snapshot of its volume is taken, how many are kept, and when the next one runs. Scope: `read`. Path parameters: - `volume_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshot-schedule" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "frequency": "daily", "retention_count": 7, "enabled": true, "next_run_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. `SNAPSHOT_SCHEDULE_NOT_FOUND`: no schedule has been set for this resource. Set one with the `PUT` on this path. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Set a volume's snapshot schedule ```http PUT /public/v1/volumes/{volume_id}/snapshot-schedule ``` Turns automatic snapshots of the volume on, changes them, or pauses them with `enabled: false`. `frequency` is `daily`, `weekly` or `monthly`; `retention_count` (1 to 168, default 7) successful snapshots are kept and older automatic ones are deleted. Automatic snapshots are billed like manual ones. Returns the schedule at once (`200`); no operation to follow. Send an `Idempotency-Key`: a retry with the same key and settings returns the same schedule. **Scope:** `write`. Lowering `retention_count` deletes the oldest copies, so an AI app's key also needs `delete` for that (`403 INSUFFICIENT_SCOPE` otherwise). Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `volume_id`: The resource's id. Body: - `frequency` (string, required): How often: `daily`, `weekly` or `monthly`. One of `daily`, `weekly`, `monthly`. - `retention_count` (integer): How many successful automatic snapshots to keep, 1 to 168; older ones are deleted. Defaults to 7. From 1 to 168. Defaults to `7`. - `enabled` (boolean): False pauses the schedule without forgetting it. Defaults to true. Defaults to `true`. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshot-schedule" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "frequency": "daily", "retention_count": 7 }' ``` Response `200`: ```json { "instance_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "frequency": "daily", "retention_count": 7, "enabled": true, "next_run_at": "2026-10-03T09:30:00Z" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `409`: `IDEMPOTENCY_CONFLICT`: this `Idempotency-Key` was already used with different settings. Use a new key for a different request. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `frequency` `REQUIRED`: send `daily`, `weekly` or `monthly`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `VALIDATION_FAILED` with no `errors` entry: `frequency` is not `daily`, `weekly` or `monthly`, or `retention_count` is outside 1 to 168. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List snapshots ```http GET /public/v1/snapshots ``` Every snapshot in your organization: of instances' root disks, of volumes, and of databases' data disks. `instance_id`, `volume_id` or `database_id` says which. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "pre-upgrade", "instance_id": null, "source_instance_name": null, "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_name": "data", "database_id": null, "region": "af-abj", "trigger": "manual", "size_bytes": 1610612736, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Price a snapshot ```http GET /public/v1/snapshots/quote ``` What a snapshot of `size_gb` GB costs per month, in your organization's currency. A snapshot is billed on its physical size. Scope: `read`. Query parameters: - `size_gb` (integer, required): At least 1. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/snapshots/quote?size_gb=20" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "priced": true, "unpriced_reason": null, "currency": "NGN", "monthly_minor": 1700000, "nominal_month_hours": 730 } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `NOT_FOUND`: cost quotes are not available on this platform yet. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `size_gb` `INVALID_SIZE`: send a whole number of GB, at least 1. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a snapshot ```http GET /public/v1/snapshots/{snapshot_id} ``` One snapshot. Scope: `read`. Path parameters: - `snapshot_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/snapshots/snap_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "pre-upgrade", "instance_id": null, "source_instance_name": null, "volume_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "volume_name": "data", "database_id": null, "region": "af-abj", "trigger": "manual", "size_bytes": 1610612736, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no snapshot with this id in your organization, or the id is not a snapshot id (`snap_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a snapshot ```http DELETE /public/v1/snapshots/{snapshot_id} ``` Deletes a snapshot of an instance, volume or database. It is billed until the deletion completes. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `snapshot_id`: The resource's id. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/snapshots/snap_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no snapshot with this id in your organization, or the id is not a snapshot id (`snap_…`). Check the id. - `409`: `INVALID_RESOURCE_STATE`: the snapshot cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Restore a snapshot to a new volume ```http POST /public/v1/snapshots/{snapshot_id}/restore ``` Creates a new volume from an instance or volume snapshot, of `disk_offering_slug` and at least the snapshot's size. A database snapshot cannot be restored yet. To roll an instance's root disk back to its snapshot instead, use *Restore an instance from a snapshot*. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The new volume's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `snapshot_id`: The resource's id. Body: - `name` (string, required): Name of the new volume, 1 to 255 characters. - `disk_offering_slug` (string, required): The new volume's type, from `GET /public/v1/disk-offerings`. - `size_gb` (integer): For a custom-size offering: at least the snapshot's size in GB. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/snapshots/snap_06gk3x9tq7m2v8r4y1c5d0zp8w/restore" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "data-restored", "disk_offering_slug": "medium-20gb", "size_gb": 20 }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no snapshot with this id in your organization, or the id is not a snapshot id (`snap_…`). Check the id. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the snapshot cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` or `disk_offering_slug` `REQUIRED`: send both. `disk_offering_slug` `DISK_OFFERING_NOT_FOUND`: no active disk offering has this slug. Pick one from `GET /public/v1/disk-offerings`. `size_gb` `SIZE_REQUIRED`: a custom-size offering needs `size_gb`, at least the snapshot's size. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Databases Source: https://docs.pantechdynamics.com/api/databases Managed PostgreSQL, MySQL and MariaDB: create, power, secure, grow, snapshot and delete databases. A database is a single-node PostgreSQL, MySQL or MariaDB server that the platform runs and patches for you, on its own VM sized by a [plan](https://docs.pantechdynamics.com/api/catalogue). It never has a public address: connect to its `hostname` from your own network, through the CIDRs its access rules allow. See [Managed databases](https://docs.pantechdynamics.com/databases/overview) for the guide. Creating one places an [order](https://docs.pantechdynamics.com/api/database-orders), paid for like an instance. Every other change returns an [operation](https://docs.pantechdynamics.com/api/operations) to follow. The admin password is never stored or returned, except once: in the response that generated it. The data disk starts at the plan's size, or `storage_gb`, and [grows later](https://docs.pantechdynamics.com/databases/storage) without a restart. [Snapshots](https://docs.pantechdynamics.com/databases/snapshots) of it are crash-consistent copies, listed under the database and in the account's [snapshots](https://docs.pantechdynamics.com/api/snapshots). [Database error codes](https://docs.pantechdynamics.com/databases/errors) explains every refusal. Endpoints: - `GET /public/v1/database-engines`: List database engines - `GET /public/v1/databases`: List databases - `POST /public/v1/databases`: Create a database - `GET /public/v1/databases/{database_id}`: Get a database - `DELETE /public/v1/databases/{database_id}`: Delete a database - `POST /public/v1/databases/{database_id}/start`: Start a database - `POST /public/v1/databases/{database_id}/stop`: Stop a database - `PUT /public/v1/databases/{database_id}/access-rules`: Replace access rules - `PUT /public/v1/databases/{database_id}/password`: Set the password - `POST /public/v1/databases/{database_id}/reset-password`: Reset the password - `PUT /public/v1/databases/{database_id}/security-groups`: Attach security groups - `POST /public/v1/databases/{database_id}/resize-storage`: Grow a database's storage - `GET /public/v1/databases/{database_id}/snapshots`: List a database's snapshots - `POST /public/v1/databases/{database_id}/snapshots`: Snapshot a database - `GET /public/v1/databases/{database_id}/snapshots/{snapshot_id}`: Get a database's snapshot - `DELETE /public/v1/databases/{database_id}/snapshots/{snapshot_id}`: Delete a database's snapshot ## The database object - `admin_username` (string, required): The admin login to connect as: the one chosen at create, or `dbadmin`. `pantech_admin` for a database created before the login could be chosen. - `security_group_ids` (array of string, required): Your security groups attached to the database, in the order attached (at most 5). `[]` when none. - `effective_access_rules` (array of DatabaseEffectiveAccessRule, required): The allow-list as the database applies it: its own `access_rules` plus the ranges its security groups add, each range once under its first source. In effect once `observed_generation` equals `generation`. - `cidr` (string, required): An allowed IPv4 range, in canonical form. - `protocol` (string, required): Always `tcp`. One of `tcp`. - `port` (integer, required): The engine's port: 5432 or 3306. - `source` (string, required): Where the range comes from: `access_rule`, or `security_group:` followed by the group's id (e.g. `security_group:sg_…`). - `ignored_security_group_rules` (array of DatabaseIgnoredSecurityGroupRule, required): Rules of the attached security groups that add nothing to the database, and why. `[]` when none. - `security_group_id` (string, required): The attached group the rule belongs to. - `direction` (string, required): The rule's direction. One of `ingress`, `egress`. - `protocol` (string, required): The rule's protocol. One of `tcp`, `udp`, `icmp`, `all`. - `port_range` (string or null, required): `80`, `3000-4000`, or null for all ports. - `cidr` (string, required): The rule's CIDR. - `reason` (string, required): Why it adds nothing: `egress_rule`, `protocol_not_tcp` (udp or icmp), `port_not_covered` (its ports exclude the engine's), `cidr_too_wide` (wider than /8, including `0.0.0.0/0`) or `cidr_not_ipv4`. One of `egress_rule`, `protocol_not_tcp`, `port_not_covered`, `cidr_too_wide`, `cidr_not_ipv4`. - `id` (string, required): The database's id, `db_` followed by 26 characters. - `name` (string, required): Its name, unique among your databases. - `engine` (string, required): `postgresql`, `mysql` or `mariadb`. One of `postgresql`, `mysql`, `mariadb`. - `version` (string, required): The engine's version line, e.g. `18` or `8.4`. Minor updates within the line are applied by the platform. - `port` (integer, required): The TCP port to connect to: 5432 for PostgreSQL, 3306 for MySQL and MariaDB. - `plan_id` (string, required): The plan setting its vCPU and memory. - `data_volume_size_gb` (integer, required): The data disk's size in GB, as in place and billed. - `pending_data_volume_size_gb` (integer or null, required): The size a storage resize in flight is growing the data disk to; null when none. - `zone_id` (string, required): The zone it runs in, e.g. `af-abj-2`. - `subnet_id` (string or null, required): Its VPC subnet in a VPC zone; null in a standard zone. - `hostname` (string or null, required): The name to connect to, e.g. `db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com`. It resolves to `private_ip` and works only from where that address is reachable. Null until assigned. - `private_ip` (string or null, required): The database's private address, never public. Null until assigned. - `desired_state` (string, required): What you asked for: `running`, `stopped` or `deleted`. One of `running`, `stopped`, `deleted`. - `observed_state` (string, required): What the platform last observed. A change is complete when it matches `desired_state`; `failed` means the last change could not be made (see the failure fields), and `unknown` means the platform could not check. One of `pending`, `provisioning`, `running`, `stopping`, `stopped`, `deleting`, `deleted`, `failed`, `unknown`. - `generation` (integer, required): Increases by one with every change you request. - `observed_generation` (integer, required): The `generation` last applied: equal to `generation` once the last change (access rules, security groups, password) is in effect. - `access_rules` (array of DatabaseAccessRule, required): The database's own allow-list, as set at create or with *Replace access rules*. - `cidr` (string, required): An allowed IPv4 range, in canonical form. - `protocol` (string, required): Always `tcp`. One of `tcp`. - `port` (integer, required): The engine's port: 5432 or 3306. - `failure_code` (string or null, required): A stable code for why the last change failed; null when nothing failed. The failed operation's `failure.reason` explains it. - `created_at` (string or null, required): When it was created (RFC 3339, UTC). - `updated_at` (string or null, required): When it last changed (RFC 3339, UTC). ## The database engine object - `engine` (string, required): The value `engine` takes in *Create a database*: `postgresql`, `mysql` or `mariadb`. - `display_name` (string, required): The engine's name for display. - `port` (integer, required): The port its databases listen on: 5432 or 3306. - `versions` (array of object, required): Version lines offered for new databases, long-term-support lines only. Empty when none is offered right now. - `version` (string, required) - `eol_date` (string or null, required) - `zones` (array of string, required) - `storage` (array of object, required): The data disk sizes a new database of this engine may have, per zone: use them for `storage_gb` in *Create a database* and *Grow a database's storage*. One entry per zone where a version of this engine can be created and database storage is available. Read the allowed sizes from here rather than hard-coding them. - `zone_id` (string, required): The zone, e.g. `af-abj-2`. - `min_gb` (integer, required): The zone's minimum data disk in GB. 0 means the plan's `disk_gb` is the minimum; otherwise the minimum is the larger of the plan's `disk_gb` and this value. At least 0. - `min_is_plan_disk` (boolean, required): True when `min_gb` is 0, so the chosen plan's `disk_gb` is the minimum. - `max_gb` (integer, required): The largest data disk in GB. - `step_gb` (integer, required): Above the base size (the minimum), `storage_gb` must be a multiple of this. - `price_per_gb_month_minor` (integer or null, required): Price of one GB of data disk for a 730-hour month, in minor units of `currency`; null when it could not be priced. - `currency` (string or null, required): `NGN` or `USD`; null when not priced. ## List database engines ```http GET /public/v1/database-engines ``` The engines and version lines you can create a managed database with: PostgreSQL, MySQL and MariaDB, long-term-support lines only. A version appears only while it is offered for new databases, and `zones` lists the zones it can be created in. An engine with no offered version has an empty `versions` list. Use `engine` and a `version` from here, and a zone from that version's `zones`, in *Create a database*. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/database-engines" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "engine": "postgresql", "display_name": "PostgreSQL", "port": 5432, "versions": [ { "version": "18", "eol_date": "2030-11-14", "zones": [ "af-abj-2" ] } ], "storage": [ { "zone_id": "af-abj-2", "min_gb": 0, "min_is_plan_disk": true, "max_gb": 2000, "step_gb": 10, "price_per_gb_month_minor": 15000, "currency": "NGN" } ] } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List databases ```http GET /public/v1/databases ``` Your managed databases, oldest first, with each one's hostname, state and access rules. Filter by name with `q`, or by state. Never includes a password. Databases whose create failed are hidden by default; list them with `observed_state=failed` (their failure is in `failure_code`), and delete them to clean up. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `running`, `stopped` or `deleted`. One of `running`, `stopped`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `running`, `stopping`, `stopped`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/databases" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "admin_username": "dbadmin", "security_group_ids": [], "effective_access_rules": [ { "cidr": "10.0.1.0/24", "protocol": "tcp", "port": 5432, "source": "access_rule" } ], "ignored_security_group_rules": [], "id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "orders-db", "engine": "postgresql", "version": "18", "port": 5432, "plan_id": "starter", "data_volume_size_gb": 80, "pending_data_volume_size_gb": null, "zone_id": "af-abj-2", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "hostname": "db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com", "private_ip": "10.0.1.12", "desired_state": "running", "observed_state": "running", "generation": 1, "observed_generation": 1, "access_rules": [ { "cidr": "10.0.1.0/24", "protocol": "tcp", "port": 5432 } ], "failure_code": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a database ```http POST /public/v1/databases ``` Creates a single-node PostgreSQL, MySQL or MariaDB database on its own server, sized by a plan (vCPU and memory), with its own data disk, and never on a public address. You connect to it by its `hostname` from your own network. **Placement.** Pick a zone where your engine version is offered (`GET /public/v1/database-engines`). In a VPC zone (`af-abj-2`) `subnet_id` is required, and unless you send `access_rules` the database starts allowing the subnet's VPC CIDR. In a standard zone (`af-abj-1`) leave out `subnet_id`; the database starts allowing nobody until you add access rules or attach security groups, and your instances reach it after *Add a private database network interface*. **Paid first, like an instance.** The request records an order and returns `202 Accepted` with it (`order_id`, also the `Location` header). The initial payment is then taken from credit, or else from the organization's default card, and the database is provisioned. It costs exactly what an instance of the same plan with no public address costs, with `storage_gb` as its disk (`amount_minor` is the upfront payment). Follow `GET /public/v1/database-orders/{order_id}` until `status` is `provisioned`, then read the database; `payment_failed` and `failed` are final, and any payment taken is returned to your credit. **Admin login.** Send `admin_username` to name the database's admin login, or leave it out for `dbadmin`. It is not a superuser: on PostgreSQL and MySQL it can create further users and databases; on MariaDB it is the only login. **Admin password.** Send `password` to choose it (16 to 128 printable ASCII characters, with no spaces, quotes or backslashes); it is never returned. Leave it out and a 32-character password is generated and **returned once, in this response** (`password`). Until provisioning starts the password is held only encrypted, then erased: it cannot be read again (use *Reset the password* for a new one). Every response is `Cache-Control: no-store`. Infrastructure-as-code tools should keep the password out of state (for Terraform, a write-only attribute). **Storage.** Leave out `storage_gb` for the plan's `disk_gb`, or ask for more: at most 2000 GB, and above the plan's size a multiple of 10 GB. Read each zone's allowed sizes and price from `storage` in `GET /public/v1/database-engines` rather than hard-coding them. It can be grown later with *Grow a database's storage*, never shrunk. **Idempotency.** Send an `Idempotency-Key`. A retry with the same key returns the original order with `password_returned: false` and a null `password`: a generated password is shown only on the first response. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 255 characters, unique among your databases. At most 255 characters. - `engine` (string, required): `postgresql`, `mysql` or `mariadb`; see `GET /public/v1/database-engines`. One of `postgresql`, `mysql`, `mariadb`. - `version` (string, required): A version line offered for `engine` in `zone_id`, from `GET /public/v1/database-engines`, e.g. `18` for PostgreSQL or `8.4` for MySQL. - `plan_slug` (string, required): The size, from `GET /public/v1/plans`: its vCPU and memory, and its `disk_gb` as the smallest data disk. - `zone_id` (string, required): Where to create it: a zone from the version's `zones`, e.g. `af-abj-1` (standard) or `af-abj-2` (VPC). - `subnet_id` (string): Required in a VPC zone (`af-abj-2`): one of your subnets there, which the database joins. Leave out in a standard zone. - `access_rules` (array of string): The initial allow-list: IPv4 CIDRs with a prefix of `/8` or longer, never `0.0.0.0/0`, at most 50, no range twice. Leave out for the zone's default (the subnet's VPC CIDR in a VPC zone, nobody in a standard zone); send `[]` for nobody. At most 50 items. - `admin_username` (string): The admin login's name: 3 to 32 characters, lowercase letters, digits and underscores, starting with a letter. Reserved, and refused with `INVALID_DATABASE_ADMIN_USERNAME`: `root`, `postgres`, `public`, `none`, `all`, `user`, `sys`, `system`, `replication`, `current_user`, `current_role`, `session_user`, and any name starting with `pg_`, `mysql`, `mariadb` or `pantech`. Not secret. Defaults to `dbadmin`. From 3 to 32 characters. Defaults to `dbadmin`. - `password` (string): Optional admin password: 16 to 128 printable ASCII characters, with no spaces, single quotes (`'`), double quotes (`"`) or backslashes (`\`). Write-only: never stored in plain text, never returned, never logged. Leave it out to have a 32-character password generated and returned once in the response. From 16 to 128 characters. - `storage_gb` (integer): Data disk size in GB. Leave out for the plan's `disk_gb`. Otherwise at least the plan's `disk_gb` (or the zone's `min_gb` when larger), at most the zone's `max_gb` (2000 today), and above that base a multiple of the zone's `step_gb` (10 today); the base itself is always allowed. Read the zone's limits and price from `storage` in `GET /public/v1/database-engines` rather than hard-coding them. The upfront payment and the hourly storage price use this size. It can be grown later, never shrunk. At least 1. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/databases" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "orders-db", "engine": "postgresql", "version": "18", "plan_slug": "starter", "zone_id": "af-abj-2", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w" }' ``` Response `202`: ```json { "order_id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "database_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "awaiting_payment", "operation_id": null, "failure_code": null, "amount_minor": 1504000, "currency": "NGN", "password_returned": true, "password": "q8Vn3TzK1mXw7RbLc4Hs9JdP2fYa6GeU", "admin_username": "dbadmin" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `402`: `INSUFFICIENT_CREDIT`: the initial payment is more than your available credit and the organization has no default card; `detail` gives both amounts. Buy credit (`POST /public/v1/billing/credit/purchases`) or add a card in the console, then retry. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `ACCOUNT_NOT_ACTIVE`: the organization's billing account is not active yet. Finish billing setup in the console (verify the organization and add a payment method), then retry. `DATABASE_NAME_TAKEN`: you already have a database with this name. Choose a different name. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `INVALID_DATABASE_NAME`: the name is empty or longer than 255 characters. `engine` `INVALID_DATABASE_ENGINE`: use `postgresql`, `mysql` or `mariadb`. `version` `INVALID_DATABASE_VERSION`: the version line is not offered for new databases. Pick one from `GET /public/v1/database-engines`. `zone_id` `INVALID_DATABASE_ZONE`: the zone is missing or not one your organization can use. Pick one from `GET /public/v1/regions`. `zone_id` `DATABASE_VERSION_NOT_AVAILABLE_IN_ZONE`: this version is not offered in this zone yet. Pick a zone from the version's `zones`. `plan_slug` `PLAN_NOT_FOUND`: no active plan has this slug. Pick one from `GET /public/v1/plans`. `subnet_id` `DATABASE_SUBNET_REQUIRED`: a VPC zone needs a subnet. Send the `subnet_id` of one of your subnets in that zone. `subnet_id` `DATABASE_SUBNET_NOT_ALLOWED`: a standard zone takes no subnet. Leave out `subnet_id`. `subnet_id` `DATABASE_SUBNET_NOT_FOUND`: no subnet with this id in your organization. Check the id. `subnet_id` `DATABASE_SUBNET_WRONG_ZONE`: the subnet is in another zone. Use a subnet in `zone_id`. `subnet_id` `DATABASE_SUBNET_NOT_USABLE`: the subnet is being deleted or has failed. Use another subnet. `access_rules` `INVALID_ACCESS_RULE`: each rule must be an IPv4 CIDR with a prefix of /8 or longer, such as `10.0.1.0/24` or `10.250.3.17/32`; `0.0.0.0/0` is refused. `access_rules` `TOO_MANY_ACCESS_RULES`: at most 50 rules. Merge neighbouring ranges, or attach a security group. `access_rules` `DUPLICATE_ACCESS_RULE`: the same range appears twice (after masking host bits). Remove the repeat. `admin_username` `INVALID_DATABASE_ADMIN_USERNAME`: use 3 to 32 lowercase letters, digits or underscores, starting with a letter, and not a reserved name (`root`, `postgres`, `public`, `none`, `all`, `user`, `sys`, `system`, `replication`, `current_user`, `current_role`, `session_user`, or a name starting with `pg_`, `mysql`, `mariadb` or `pantech`). `password` `INVALID_DATABASE_PASSWORD`: use 16 to 128 printable ASCII characters with no spaces, single or double quotes, or backslashes; or leave it out to have one generated. `storage_gb` `INVALID_DATABASE_STORAGE`: use at least the plan's `disk_gb`, at most 2000, and above the plan's size a multiple of 10. Leave it out for the plan's size. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `DATABASES_UNAVAILABLE`: managed databases are not available on this platform yet. Try again later, or contact support. `DATABASE_STORAGE_UNAVAILABLE`: database storage is not offered in this zone yet. Choose another zone, or try again later. `DATABASE_HOSTNAME_UNAVAILABLE`: a hostname could not be reserved for the database. Retry with a new `Idempotency-Key`. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a database ```http GET /public/v1/databases/{database_id} ``` One database: its connection `hostname` and `port`, `admin_username`, state, data disk size, access rules, attached security groups and the allow-list it applies (`effective_access_rules`). Never includes a password. A change you requested is in effect once `observed_generation` equals `generation`. Connect as `admin_username` to `hostname`:`port` from an address its allow-list includes. Scope: `read`. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "admin_username": "dbadmin", "security_group_ids": [], "effective_access_rules": [ { "cidr": "10.0.1.0/24", "protocol": "tcp", "port": 5432, "source": "access_rule" } ], "ignored_security_group_rules": [], "id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "orders-db", "engine": "postgresql", "version": "18", "port": 5432, "plan_id": "starter", "data_volume_size_gb": 80, "pending_data_volume_size_gb": null, "zone_id": "af-abj-2", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "hostname": "db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com", "private_ip": "10.0.1.12", "desired_state": "running", "observed_state": "running", "generation": 1, "observed_generation": 1, "access_rules": [ { "cidr": "10.0.1.0/24", "protocol": "tcp", "port": 5432 } ], "failure_code": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a database ```http DELETE /public/v1/databases/{database_id} ``` Deletes the database. Its hostname is removed first, then its server is retired. Its data disk and a safety snapshot of it are kept for a short retention period (12 and 24 hours) and then destroyed; nothing is billed for them during retention. Snapshots you took yourself are kept and stay billed until you delete them. Once the delete succeeds, the part of the database's upfront payment that no invoice has drawn yet goes back to your organization's credit balance, never to the card. Usage up to the delete is invoiced as usual. A delete that fails returns nothing. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused a value, for example a malformed CIDR or a name longer than 255 characters. Check the request against this page. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Start a database ```http POST /public/v1/databases/{database_id}/start ``` Starts a stopped database. Its hostname, data and access rules are unchanged. A running database is billed for its plan again. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/start" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Stop a database ```http POST /public/v1/databases/{database_id}/stop ``` Shuts the database down cleanly. A stopped database keeps its data, hostname and access rules, refuses connections, and is billed for its storage only. Start it again with *Start a database*. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/stop" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Replace access rules ```http PUT /public/v1/databases/{database_id}/access-rules ``` Replaces the database's whole allow-list. Each rule allows TCP to the engine's port (5432 for PostgreSQL, 3306 for MySQL and MariaDB) from one IPv4 CIDR. Send `[]` to remove every rule. To let one instance in, allow its address as a `/32`: in a standard zone, use the instance's `private_network_ip` after *Add a private database network interface*. Limits: an IPv4 CIDR with a prefix of `/8` or longer (host bits are masked, so `10.0.1.7/24` becomes `10.0.1.0/24`); never `0.0.0.0/0`; at most 50 rules; no range twice. These rules plus the ranges added by attached security groups may total at most 1024. Removing a rule also ends connections already open from that range. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The new list is in effect once the database's `observed_generation` equals its `generation`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Body: - `rules` (array of object, required): The complete new allow-list, at most 50 rules with no range twice. `[]` removes every rule. At most 50 items. - `cidr` (string, required): One IPv4 CIDR to allow, with a prefix of `/8` to `/32`; never `0.0.0.0/0`. Host bits are masked (`10.0.1.7/24` is stored as `10.0.1.0/24`). Use `/32` for one address, such as an instance's `private_network_ip`. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/access-rules" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "rules": [ { "cidr": "10.0.1.0/24" }, { "cidr": "10.250.3.17/32" } ] }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `rules` `REQUIRED`: send the complete list; `[]` removes every rule. `rules` `INVALID_ACCESS_RULE`: each `cidr` must be an IPv4 CIDR with a prefix of /8 or longer, such as `10.0.1.0/24`; `0.0.0.0/0` is refused. `rules` `TOO_MANY_ACCESS_RULES`: at most 50 rules. Merge neighbouring ranges, or attach a security group instead. `rules` `DUPLICATE_ACCESS_RULE`: the same range appears twice (after masking host bits). Remove the repeat. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED`: the database's own access rules plus the address ranges its security groups add would exceed 1024. Remove access rules, detach a security group, or narrow the groups' rules, then retry. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Set the password ```http PUT /public/v1/databases/{database_id}/password ``` Sets the admin login's password to one you choose: 16 to 128 printable ASCII characters, with no spaces, single or double quotes, or backslashes. It is applied inside the database and never stored or returned. The database must be running. Existing connections stay open; new ones need the new password once the operation succeeds. Use this from infrastructure-as-code to rotate a password you manage; use *Reset the password* to have one generated instead. The response is `Cache-Control: no-store`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Body: - `password` (string, required): The new admin password: 16 to 128 printable ASCII characters, with no spaces, single quotes (`'`), double quotes (`"`) or backslashes (`\`). Write-only: never stored in plain text, never returned, never logged. From 16 to 128 characters. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/password" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "password": "c0rrect-H0rse-battery-St4ple" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `DATABASE_NOT_RUNNING`: the database must be running for this change. Start it (`POST /public/v1/databases/{database_id}/start`), wait for the operation to succeed, then retry. `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `password` `REQUIRED`: send the new password. `password` `INVALID_DATABASE_PASSWORD`: use 16 to 128 printable ASCII characters with no spaces, single or double quotes, or backslashes. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Reset the password ```http POST /public/v1/databases/{database_id}/reset-password ``` Replaces the admin login's password with a generated one (32 letters and digits), returned once in this response (`password`) with `Cache-Control: no-store`, and never stored. The database must be running. Use this when the password is lost: support cannot recover it. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The new password works once the operation succeeds. **Idempotency.** Send an `Idempotency-Key`. A retry with the same key returns the original operation with `password_returned: false` and a null `password`: if the first response was lost, reset again with a new key. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/reset-password" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting", "password_returned": true, "password": "q8Vn3TzK1mXw7RbLc4Hs9JdP2fYa6GeU" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `DATABASE_NOT_RUNNING`: the database must be running for this change. Start it (`POST /public/v1/databases/{database_id}/start`), wait for the operation to succeed, then retry. `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `422`: `VALIDATION_FAILED` with no `errors` entry: the platform refused the request, for example because the resource has already been deleted. Read the resource to check its state. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Attach security groups ```http PUT /public/v1/databases/{database_id}/security-groups ``` Replaces the set of your security groups attached to the database; `[]` detaches every group. At most 5, each one of your own security groups in the database's zone that is not being deleted or failed. Another organization's group is reported as not found. A group is only a **source of address ranges** for the database: each of its **ingress** rules whose protocol is `tcp` or `all`, whose ports include the engine's port (5432 or 3306; a range such as `3000-4000`, or all ports, counts) and whose CIDR is IPv4 with a prefix of `/8` or longer adds its CIDR to the database's allow-list, beside its own access rules. Every other rule (other ports, egress, `udp`, `icmp`, `0.0.0.0/0` and anything wider than `/8`, IPv6) is ignored for the database and listed in `ignored_security_group_rules` with the reason. The group itself is never applied to the database server, and SSH to it is never opened. The database's access rules plus the ranges its groups add may total at most 1024. When an attached group's rules change, the database is updated automatically. A group cannot be deleted while a database uses it (`409 SECURITY_GROUP_ATTACHED_TO_DATABASE`); detach it here first. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Body: - `security_group_ids` (array of string, required): The complete set to attach: at most 5 distinct ids of your own security groups in the database's zone, e.g. `sg_…`. `[]` detaches every group. At most 5 items. Example: ```sh curl -X PUT "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/security-groups" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "security_group_ids": [ "sg_06gk3x9tq7m2v8r4y1c5d0zp8w" ] }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `security_group_ids` `REQUIRED`: send the complete set; `[]` detaches every group. `security_group_ids` `INVALID_DATABASE_SECURITY_GROUPS`: more than 5 ids, or an id repeated. Send at most 5 distinct ids. `security_group_ids` `DATABASE_SECURITY_GROUP_NOT_FOUND`: an id is not one of your security groups. Check the ids with `GET /public/v1/security-groups`. `security_group_ids` `DATABASE_SECURITY_GROUP_NOT_USABLE`: a group is being deleted or has failed. Remove it from the list. `security_group_ids` `DATABASE_SECURITY_GROUP_WRONG_ZONE`: a group is in another zone than the database. Use groups in the database's zone. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED`: the database's own access rules plus the address ranges its security groups add would exceed 1024. Remove access rules, detach a security group, or narrow the groups' rules, then retry. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Grow a database's storage ```http POST /public/v1/databases/{database_id}/resize-storage ``` Grows the database's data disk to `storage_gb`. vCPU and memory stay the plan's. The disk only grows: `storage_gb` must be larger than the current `data_volume_size_gb`, at most 2000 GB and a multiple of 10 GB (read the zone's limits from `storage` in `GET /public/v1/database-engines` rather than hard-coding them). The database must be running; the filesystem grows online, without a restart. One resize at a time. No upfront charge, as for an instance resize: storage is billed hourly, at the new size only once the resize has completed. While it runs, `pending_data_volume_size_gb` shows the size in flight; `data_volume_size_gb` changes when it succeeds. A failed resize leaves the size and the bill unchanged. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Body: - `storage_gb` (integer, required): The new data disk size in GB: larger than the current `data_volume_size_gb`, at most the zone's `max_gb` (2000 today) and a multiple of its `step_gb` (10 today); see `storage` in `GET /public/v1/database-engines`. It only grows. At least 1. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/resize-storage" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "storage_gb": 200 }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `DATABASE_NOT_RUNNING`: the database must be running for this change. Start it (`POST /public/v1/databases/{database_id}/start`), wait for the operation to succeed, then retry. `DATABASE_STORAGE_RESIZE_IN_PROGRESS`: a storage resize of this database is still running. Wait for its operation to finish, then retry. `INVALID_RESOURCE_STATE`: the database is being deleted or has been deleted. Nothing more can be changed on it. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `storage_gb` `REQUIRED`: send the new size in GB. `storage_gb` `INVALID_DATABASE_STORAGE`: the size must be larger than the current `data_volume_size_gb`, at most 2000, and a multiple of 10. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `DATABASE_STORAGE_UNAVAILABLE`: database storage is not offered in this database's zone right now. Try again later. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## List a database's snapshots ```http GET /public/v1/databases/{database_id}/snapshots ``` Snapshots of the database's data disk, oldest first. Each is also listed in `GET /public/v1/snapshots`, with `database_id` set. Scope: `read`. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. - `q` (string): Only items whose name contains this text (case-insensitive), at most 255 characters. At most 255 characters. - `desired_state` (string): Only items with this `desired_state`: `present` or `deleted`. One of `present`, `deleted`. - `observed_state` (string): Only items with this `observed_state`. One of `pending`, `provisioning`, `active`, `deleting`, `deleted`, `failed`, `unknown`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "pre-upgrade", "instance_id": null, "source_instance_name": null, "volume_id": null, "volume_name": null, "database_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "region": "af-abj", "trigger": "manual", "size_bytes": 1610612736, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-03T09:31:12Z", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:31:12Z" } ], "next_cursor": null } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. `INVALID_FILTER`: `q` is longer than 255 characters, or `desired_state` or `observed_state` is not one of its listed values. Fix the parameter named in `detail`. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Snapshot a database ```http POST /public/v1/databases/{database_id}/snapshots ``` Takes a snapshot of the database's data disk. The engine keeps running, so the snapshot is **crash-consistent only**: what the disk held at that instant, as after a power cut, which the engine recovers from on start. It is not an engine-level backup; stop the database first for a quiesced copy. The database must be running or stopped. Billed like every snapshot: the storage rate on its physical size, for every started hour until it is deleted. A snapshot outlives its database and stays billed until you delete it. Restoring a database from a snapshot is not available yet. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. The new snapshot's id is the operation's `resource_id`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. Body: - `name` (string, required): 1 to 255 characters, unique among this resource's snapshots, e.g. `pre-upgrade`. At most 255 characters. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "pre-upgrade" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. - `409`: `ACCOUNT_SUSPENDED`: an invoice is overdue, or the organization was suspended for non-payment. Pay the outstanding invoice in the console, then retry. `DATABASE_SNAPSHOT_NOT_READY`: the database is not running or stopped (it is starting, stopping, being created or deleted). Wait until it is running or stopped, then retry. `DATABASE_SNAPSHOT_NAME_TAKEN`: this database already has a snapshot with that name. Choose a different name. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name` `INVALID_DATABASE_SNAPSHOT_NAME`: the name is empty or longer than 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `DATABASES_UNAVAILABLE`: managed databases are not available on this platform yet. Try again later, or contact support. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a database's snapshot ```http GET /public/v1/databases/{database_id}/snapshots/{snapshot_id} ``` One snapshot of the database's data disk. Scope: `read`. Path parameters: - `database_id`: The database's id, e.g. `db_...`. - `snapshot_id`: The snapshot's id, e.g. `snap_...`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots/snap_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "before-migration", "instance_id": null, "source_instance_name": null, "volume_id": null, "volume_name": null, "database_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "region": "af-abj", "trigger": "manual", "size_bytes": 1610612736, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. `DATABASE_SNAPSHOT_NOT_FOUND`: this database has no snapshot with this id (a snapshot of an instance or volume is reported the same way). Check the id, or use `GET /public/v1/snapshots/{snapshot_id}`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a database's snapshot ```http DELETE /public/v1/databases/{database_id}/snapshots/{snapshot_id} ``` Deletes one snapshot of the database's data disk. It is billed until the deletion completes. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `database_id`: The database's id, e.g. `db_...`. - `snapshot_id`: The snapshot's id, e.g. `snap_...`. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/databases/db_06gk3x9tq7m2v8r4y1c5d0zp8w/snapshots/snap_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "snap_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_NOT_FOUND`: no database with this id in your organization. Check the id: a database of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not a database id (`db_…`). Check the path. `DATABASE_SNAPSHOT_NOT_FOUND`: this database has no snapshot with this id. Check the id. - `409`: `INVALID_RESOURCE_STATE`: the snapshot cannot do this in its current state, for example while it is being created or deleted. Wait for its current operation to finish, then retry. `CONCURRENT_UPDATE`: the resource changed while this request was being processed. Retry the request; if it repeats, read the resource first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. --- # Database orders Source: https://docs.pantechdynamics.com/api/database-orders Follow a new database from payment to provisioning. Creating a database places an order. The order takes the initial payment, from credit or else the organization's default card, and then provisions the database. Poll the order until its `status` is `provisioned` (the database is ready to read), `failed` or `payment_failed`. An order never includes the password. Endpoints: - `GET /public/v1/database-orders`: List database orders - `GET /public/v1/database-orders/{order_id}`: Get a database order ## The database order object How a new database is paid for and provisioned. Never includes a password. The console API returns the same order, naming the id `order_id`. - `admin_username` (string, required): The admin login the database is created with. - `id` (string, required): The order's id, `ord_…`. - `order_id` (string, required): The same value as `id`. - `attempt_id` (string or null, required): The card payment attempt; null when the order was paid from credit or not charged yet. - `database_id` (string, required): The id the database has, or will have once provisioned. - `status` (string, required): `awaiting_payment`, then `paid`, `provisioning` and `provisioned` (final: the database exists). `payment_failed` (no payment could be taken; nothing charged, or it was returned) and `failed` (provisioning could not start; any payment taken is returned to your credit) are final too. One of `awaiting_payment`, `paid`, `provisioning`, `provisioned`, `payment_failed`, `failed`. - `operation_id` (string or null, required): The database's create operation, set once provisioning starts; null before. - `failure_code` (string or null, required): Why the order failed, when it did: e.g. `insufficient_credit`, `card_declined`, `payment_expired` (not paid within an hour), `database_name_taken`, `provisioning_failed`. Null otherwise. `organization_deleted`: the order was cancelled because its organization was deleted; any payment taken is returned to credit. - `amount_minor` (integer, required): The initial payment in minor units of `currency` (kobo or cents). - `currency` (string, required): `NGN` or `USD`: your organization's billing currency. One of `NGN`, `USD`. - `created_at` (string or null, required): When it was created (RFC 3339, UTC). - `updated_at` (string or null, required): When it last changed (RFC 3339, UTC). ## List database orders ```http GET /public/v1/database-orders ``` Your database orders, newest first, one per *Create a database* call. An order shows whether the initial payment was taken and whether provisioning started. Never includes a password. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/database-orders" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "admin_username": "dbadmin", "id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "order_id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "attempt_id": "attempt-id", "database_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "provisioned", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "failure_code": null, "amount_minor": 1059200, "currency": "NGN", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not a whole number from 1 to 100. Send a value in that range, or leave it out for 50. `INVALID_CURSOR`: `cursor` does not belong to this list with these filters. Start again without a cursor. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a database order ```http GET /public/v1/database-orders/{order_id} ``` Payment and provisioning progress for one new database. Poll it after *Create a database* until `status` is `provisioned` (then read the database at `GET /public/v1/databases/{database_id}`), or `payment_failed` or `failed` (final: any payment taken is returned to your credit, and `failure_code` says why). Once `operation_id` is set you can also follow that operation. Never includes a password. Scope: `read`. Path parameters: - `order_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/database-orders/ord_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "admin_username": "dbadmin", "id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "order_id": "ord_06gk3x9tq7m2v8r4y1c5d0zp8w", "attempt_id": "attempt-id", "database_id": "db_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "provisioned", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "failure_code": null, "amount_minor": 1059200, "currency": "NGN", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `DATABASE_ORDER_NOT_FOUND`: no database order with this id in your organization. Check the id; an order of another organization is reported the same way. `RESOURCE_NOT_FOUND`: the id is not an order id (`ord_…`). Check the path. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # Kubernetes Source: https://docs.pantechdynamics.com/api/kubernetes Managed Kubernetes clusters whose nodes are VMs in your account: create, scale, upgrade, stop, start and delete them, and download a kubeconfig. A cluster's nodes, control and worker, are VMs of one `node_plan` in your account, each billed as a VM of that plan; there is no separate control-plane fee. Pick a version from `GET /kubernetes-versions` in the zone you want. In a VPC zone the nodes go in a [subnet](https://docs.pantechdynamics.com/api/networks); in a standard zone leave `subnet_id` out. `control_nodes` is 1, or 3 where the version list's `ha_zone_ids` offers it. Every change returns an [operation](https://docs.pantechdynamics.com/api/operations) to follow. Scale to 1 to 10 workers with `PATCH`, and upgrade one step at a time to one of the cluster's `available_upgrades`; upgrades never happen on their own and never go down. Download the kubeconfig once the cluster is `running`: it is a `POST` that needs a `write` key, because it grants cluster-admin. A `503 KUBERNETES_UNAVAILABLE` means managed Kubernetes is not open on the platform yet. See the [Kubernetes guide](https://docs.pantechdynamics.com/kubernetes/overview). Endpoints: - `GET /public/v1/kubernetes-versions`: List Kubernetes versions - `GET /public/v1/kubernetes-clusters`: List Kubernetes clusters - `POST /public/v1/kubernetes-clusters`: Create a Kubernetes cluster - `GET /public/v1/kubernetes-clusters/{cluster_id}`: Get a Kubernetes cluster - `PATCH /public/v1/kubernetes-clusters/{cluster_id}`: Change a Kubernetes cluster - `DELETE /public/v1/kubernetes-clusters/{cluster_id}`: Delete a Kubernetes cluster - `POST /public/v1/kubernetes-clusters/{cluster_id}/upgrade`: Upgrade a Kubernetes cluster - `POST /public/v1/kubernetes-clusters/{cluster_id}/resize-plan`: Resize worker node compute plan - `POST /public/v1/kubernetes-clusters/{cluster_id}/start`: Start a Kubernetes cluster - `POST /public/v1/kubernetes-clusters/{cluster_id}/upgrade-network`: Upgrade Kubernetes cluster networking - `POST /public/v1/kubernetes-clusters/{cluster_id}/stop`: Stop a Kubernetes cluster - `POST /public/v1/kubernetes-clusters/{cluster_id}/kubeconfig`: Download a kubeconfig - `GET /public/v1/kubernetes-clusters/{cluster_id}/nodes`: List a cluster's nodes - `GET /public/v1/kubernetes-clusters/{cluster_id}/workloads`: Get a cluster's services and pods - `POST /public/v1/kubernetes-clusters/{cluster_id}/apply-manifest`: Apply a manifest to a cluster - `GET /public/v1/kubernetes-clusters/{cluster_id}/live-manifests`: Export a cluster's workloads as YAML - `POST /public/v1/kubernetes-clusters/{cluster_id}/expose-service`: Expose a Kubernetes service - `POST /public/v1/kubernetes-clusters/{cluster_id}/sync-routing`: Sync a cluster's public routing - `GET /public/v1/kubernetes-clusters/{cluster_id}/node-pools`: List node pools - `POST /public/v1/kubernetes-clusters/{cluster_id}/node-pools`: Create a node pool - `PATCH /public/v1/kubernetes-clusters/{cluster_id}/node-pools/{pool_id}`: Scale a node pool - `DELETE /public/v1/kubernetes-clusters/{cluster_id}/node-pools/{pool_id}`: Delete a node pool - `GET /public/v1/kubernetes-clusters/{cluster_id}/addons`: List cluster add-ons - `POST /public/v1/kubernetes-clusters/{cluster_id}/addons/{addon_name}/toggle`: Toggle a cluster add-on - `POST /public/v1/kubernetes-clusters/{cluster_id}/endpoint-access`: Configure API server endpoint access ## The Kubernetes cluster object - `id` (string, required): e.g. `k8s_...`. - `name` (string, required) - `zone_id` (string, required) - `kubernetes_version_id` (string, required) - `kubernetes_version` (string, required) - `network_id` (string or null, required): The private network (VPC) the nodes are in; null in a standard zone. - `subnet_id` (string or null, required) - `node_plan_id` (string, required) - `node` (KubernetesNode, required) - `vcpu` (integer, required) - `memory_mb` (integer, required) - `disk_gb` (integer, required) - `control_nodes` (integer, required) - `workers` (integer, required) - `nodes` (integer, required): control_nodes plus workers: the VMs billed. - `desired_state` (string, required): One of `running`, `stopped`, `deleted`. - `observed_state` (string, required): `updating` while a scale or upgrade runs. One of `pending`, `provisioning`, `running`, `updating`, `stopping`, `stopped`, `retired`, `deleted`, `failed`, `unknown`. - `in_sync` (boolean, required): False while a scale or upgrade has not finished. - `failure_code` (string or null, required) - `failure_reason` (string or null, required) - `available_upgrades` (array of KubernetesVersion, required): Versions this cluster can be upgraded to now. - `id` (string, required): e.g. `k8sv_...`. - `zone_id` (string, required) - `version` (string, required) - `status` (string, required): `withdrawn` versions take no new clusters. One of `available`, `withdrawn`. - `min_cpu` (integer, required): The fewest vCPUs a node plan needs for this version. - `min_memory_mb` (integer, required): The least memory, in MB, a node plan needs for this version. - `autoscaling` (KubernetesAutoscaling, required): The platform's autoscaler: about once a minute it adds a worker when a pod that a worker could run is waiting for room, and removes the newest worker once it has run nothing but DaemonSet pods for 10 minutes, one step at a time and always within min_workers and max_workers. You pay for the workers that run. - `enabled` (boolean, required): The cluster autoscaler adds and removes workers within the range as pods need them. - `min_workers` (integer, required): 1 to 10 when enabled; 0 when not. - `max_workers` (integer, required): min_workers to 10 when enabled; 0 when not. Billing and your account limits are checked for the maximum. - `api_allowed_cidrs` (array of string, required): Sources allowed to reach the API server; empty is any address. - `endpoint` (string or null, required): The API server's URL, once the cluster has one: the public address when the endpoint is public or dual and one is routed, else the private one. - `public_ip` (string or null): The public address the API server is reachable on; null until one is routed, and for a private endpoint. - `public_port` (integer or null): The public port the API server is reachable on; null until one is routed, and for a private endpoint. - `volume_storage_gb` (integer, required): Storage your workloads' persistent volume claims use, billed with the nodes' disks. - `kind` (string, required): Cluster architecture kind, e.g. `appliance`. - `cni_plugin` (string, required): CNI backend plugin (`flannel`, `wireguard`, or `calico`). - `pod_cidr` (string, required): IPv4 CIDR range allocated for cluster pods. - `service_cidr` (string, required): IPv4 CIDR range allocated for cluster services. - `endpoint_access` (string, required): API server endpoint network access mode (`public`, `private`, or `dual`). One of `public`, `private`, `dual`. - `created_at` (string or null, required) - `updated_at` (string or null, required) ## The Kubernetes version object - `id` (string, required): e.g. `k8sv_...`. - `zone_id` (string, required) - `version` (string, required) - `status` (string, required): `withdrawn` versions take no new clusters. One of `available`, `withdrawn`. - `min_cpu` (integer, required): The fewest vCPUs a node plan needs for this version. - `min_memory_mb` (integer, required): The least memory, in MB, a node plan needs for this version. ## List Kubernetes versions ```http GET /public/v1/kubernetes-versions ``` The Kubernetes versions you can create clusters with, newest first, each in one zone. `ha_zone_ids` are the zones where a highly available control plane (3 control nodes) can be created now. A version is withdrawn from new clusters before its upstream end of life; clusters already on it keep running it. Scope: `read`. Query parameters: - `zone_id` (string): Only versions in this zone. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-versions" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "k8sv_06gk3x9tq7m2v8r4y1c5d0zp8w", "zone_id": "af-abj-2", "version": "1.31.2", "status": "available", "min_cpu": 2, "min_memory_mb": 2048 } ], "ha_zone_ids": [ "af-abj-2" ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. ## List Kubernetes clusters ```http GET /public/v1/kubernetes-clusters ``` Your Kubernetes clusters, newest first. Deleted and failed clusters are left out. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "prod-web", "zone_id": "af-abj-2", "kubernetes_version_id": "k8sv_06gk3x9tq7m2v8r4y1c5d0zp8w", "kubernetes_version": "1.31.2", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "node_plan_id": "plan_06gk3x9tq7m2v8r4y1c5d0zp8w", "node": { "vcpu": 2, "memory_mb": 4096, "disk_gb": 80 }, "control_nodes": 1, "workers": 2, "nodes": 3, "desired_state": "running", "observed_state": "running", "in_sync": true, "failure_code": null, "failure_reason": null, "available_upgrades": [ { "id": "k8sv_07hm4y0ur8n3w9s5z2d6e1aq9x", "zone_id": "af-abj-2", "version": "1.32.0", "status": "available", "min_cpu": 2, "min_memory_mb": 2048 } ], "autoscaling": { "enabled": true, "min_workers": 2, "max_workers": 5 }, "api_allowed_cidrs": [ "api-allowed-cidrs" ], "endpoint": "endpoint", "public_ip": "203.0.113.20", "public_port": 6443, "volume_storage_gb": 1, "kind": "appliance", "cni_plugin": "flannel", "pod_cidr": "10.42.0.0/16", "service_cidr": "10.43.0.0/16", "endpoint_access": "public", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. ## Create a Kubernetes cluster ```http POST /public/v1/kubernetes-clusters ``` Creates a managed Kubernetes cluster. Every node, control and worker, is a VM of `node_plan` in your account and is billed as one; there is no separate control-plane fee. In a VPC zone the nodes go in `subnet_id`; in a standard zone leave `subnet_id` out. `control_nodes` is 1, or 3 where the zone offers a highly available control plane (see `ha_zone_ids` on the versions list). Persistent volumes work out of the box. Download the kubeconfig once the cluster is `running`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. The cluster's id is the operation's `resource_id`. Scope: `write`. Send an `Idempotency-Key` header. Body: - `name` (string, required): 1 to 40 lowercase letters, digits and hyphens, starting with a letter. - `zone_id` (string, required) - `kubernetes_version_id` (string, required): From the Kubernetes versions list, in `zone_id`. - `subnet_id` (string): Required in a VPC zone: the subnet the nodes go in. Leave out in a standard zone. - `node_plan` (string, required): The plan slug every node uses, from the plans list. In a zone where every node has one fixed size (af-abj-2: 2 vCPU, 4 GB), the plan must be that size: `422 KUBERNETES_PLAN_NOT_IN_ZONE` on `node_plan`. - `control_nodes` (integer): 1, or 3 for a highly available control plane where the zone offers it. One of `1`, `3`. Defaults to `1`. - `workers` (integer): Worker nodes, 1 to 10. Required unless `autoscaling` is enabled, when it defaults to `min_workers` and must lie within the range. From 1 to 10. - `autoscaling` (KubernetesAutoscaling): The platform's autoscaler: about once a minute it adds a worker when a pod that a worker could run is waiting for room, and removes the newest worker once it has run nothing but DaemonSet pods for 10 minutes, one step at a time and always within min_workers and max_workers. You pay for the workers that run. - `enabled` (boolean, required): The cluster autoscaler adds and removes workers within the range as pods need them. - `min_workers` (integer, required): 1 to 10 when enabled; 0 when not. - `max_workers` (integer, required): min_workers to 10 when enabled; 0 when not. Billing and your account limits are checked for the maximum. - `api_allowed_cidrs` (array of string): VPC zone only: the IPv4 CIDRs allowed to reach the API server (at most 20). Leave out for any address. - `kind` (string): Cluster architecture. Only `appliance` (the default) is offered: nodes are VMs in your account running k3s. Any other value is `422 INVALID_KUBERNETES_KIND`. One of `appliance`. - `cni_plugin` (string): CNI backend plugin (`flannel`, `wireguard`, or `calico`). Defaults to `flannel`. - `pod_cidr` (string): IPv4 CIDR range allocated for cluster pods. Defaults to `10.42.0.0/16`. - `service_cidr` (string): IPv4 CIDR range allocated for cluster services. Defaults to `10.43.0.0/16`. - `endpoint_access` (string): Who can reach the API server. `private` (the default) is reachable only from inside the VPC. `public` and `dual` give the API server a public address of its own, billed as a VPC public IP for every hour it is held. One of `private`, `public`, `dual`. Defaults to `private`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "prod-web", "zone_id": "af-abj-2", "kubernetes_version_id": "k8sv_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "node_plan": "s-2vcpu-4gb", "control_nodes": 1 }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `402`: `INSUFFICIENT_CREDIT`: your credit does not cover the first payment for every node. Top up or add a card, then try again. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `409`: `KUBERNETES_CLUSTER_NAME_TAKEN`: you already have a cluster with this name. `KUBERNETES_CLUSTER_LIMIT`: your organization has reached its cluster limit. Delete a cluster, or contact support to raise it. `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `name`, `zone_id`, `kubernetes_version_id`, `node_plan` or `workers` `REQUIRED`: send them. `name` `INVALID_KUBERNETES_CLUSTER_NAME`: 1 to 40 lowercase letters, digits and hyphens, starting with a letter. `zone_id` `INVALID_KUBERNETES_ZONE`: a zone your organization can use. `kubernetes_version_id` `INVALID_KUBERNETES_VERSION`: a version offered in this zone. `node_plan` `INVALID_KUBERNETES_NODE_PLAN`: an active plan with at least the version's minimum vCPU and memory. `workers` `INVALID_KUBERNETES_WORKER_COUNT`: 1 to 10. `control_nodes` `INVALID_KUBERNETES_CONTROL_NODES` or `KUBERNETES_HA_NOT_AVAILABLE`: 1, or 3 where offered. `autoscaling` `INVALID_KUBERNETES_AUTOSCALING`: min_workers and max_workers 1 to 10, min no more than max, and workers within them. `api_allowed_cidrs` `INVALID_API_ALLOWED_CIDRS` (at most 20 IPv4 CIDRs) or `API_ALLOWED_CIDRS_VPC_ONLY`. `subnet_id` `KUBERNETES_SUBNET_REQUIRED`, `KUBERNETES_SUBNET_NOT_ALLOWED` or `KUBERNETES_SUBNET_NOT_FOUND`: an active subnet in a VPC zone, and none in a standard zone. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Get a Kubernetes cluster ```http GET /public/v1/kubernetes-clusters/{cluster_id} ``` One cluster. `in_sync` is false while a change you made has not reached it yet; `available_upgrades` lists the versions it can move to now. Scope: `read`. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "prod-web", "zone_id": "af-abj-2", "kubernetes_version_id": "k8sv_06gk3x9tq7m2v8r4y1c5d0zp8w", "kubernetes_version": "1.31.2", "network_id": "net_06gk3x9tq7m2v8r4y1c5d0zp8w", "subnet_id": "snet_06gk3x9tq7m2v8r4y1c5d0zp8w", "node_plan_id": "plan_06gk3x9tq7m2v8r4y1c5d0zp8w", "node": { "vcpu": 2, "memory_mb": 4096, "disk_gb": 80 }, "control_nodes": 1, "workers": 2, "nodes": 3, "desired_state": "running", "observed_state": "running", "in_sync": true, "failure_code": null, "failure_reason": null, "available_upgrades": [ { "id": "k8sv_07hm4y0ur8n3w9s5z2d6e1aq9x", "zone_id": "af-abj-2", "version": "1.32.0", "status": "available", "min_cpu": 2, "min_memory_mb": 2048 } ], "autoscaling": { "enabled": true, "min_workers": 2, "max_workers": 5 }, "api_allowed_cidrs": [ "api-allowed-cidrs" ], "endpoint": "endpoint", "public_ip": "203.0.113.20", "public_port": 6443, "volume_storage_gb": 1, "kind": "appliance", "cni_plugin": "flannel", "pod_cidr": "10.42.0.0/16", "service_cidr": "10.43.0.0/16", "endpoint_access": "public", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. ## Change a Kubernetes cluster ```http PATCH /public/v1/kubernetes-clusters/{cluster_id} ``` Sets a fixed number of worker nodes (1 to 10), turns the cluster autoscaler on or off and sets its range, or replaces the list of addresses allowed to reach the API server (VPC zone). Send any of them; the rest stay as they are. The cluster must be running. Added workers, and an autoscaler maximum above today's count, are checked against your credit and account limits; workers are billed as VMs of the node plan while they run. A change that changes nothing returns an empty `operation_id`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Body: - `workers` (integer): A fixed worker count, 1 to 10. Not with autoscaling enabled: the autoscaler sets it. From 1 to 10. - `autoscaling` (KubernetesAutoscaling): The platform's autoscaler: about once a minute it adds a worker when a pod that a worker could run is waiting for room, and removes the newest worker once it has run nothing but DaemonSet pods for 10 minutes, one step at a time and always within min_workers and max_workers. You pay for the workers that run. - `enabled` (boolean, required): The cluster autoscaler adds and removes workers within the range as pods need them. - `min_workers` (integer, required): 1 to 10 when enabled; 0 when not. - `max_workers` (integer, required): min_workers to 10 when enabled; 0 when not. Billing and your account limits are checked for the maximum. - `api_allowed_cidrs` (array of string): VPC zone only: the whole new list of IPv4 CIDRs allowed to reach the API server. Send `[]` to allow any address. Example: ```sh curl -X PATCH "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `402`: `INSUFFICIENT_CREDIT`: your credit does not cover the added nodes. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`: start the cluster first. `KUBERNETES_CLUSTER_BUSY`: the cluster is already changing. Wait until it is running or stopped. `KUBERNETES_CLUSTER_NOT_CHANGEABLE`: the cluster cannot make this change now (stopped, being deleted, or failed). `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: `workers` `REQUIRED` (send at least one field) or `INVALID_KUBERNETES_WORKER_COUNT` (1 to 10); `autoscaling` `INVALID_KUBERNETES_AUTOSCALING`; `api_allowed_cidrs` `INVALID_API_ALLOWED_CIDRS` or `API_ALLOWED_CIDRS_VPC_ONLY`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Delete a Kubernetes cluster ```http DELETE /public/v1/kubernetes-clusters/{cluster_id} ``` Deletes the cluster. It stops at once, disappears from your lists and stops billing; its nodes and disks are destroyed 12 hours later and cannot be recovered. Volumes your workloads created through persistent volume claims are deleted with it. Deleting a cluster already being deleted returns an empty `operation_id`. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Upgrade a Kubernetes cluster ```http POST /public/v1/kubernetes-clusters/{cluster_id}/upgrade ``` Moves the cluster to a newer Kubernetes version: one of its `available_upgrades` (the next patch, or the next minor version). Upgrades never happen on their own and never go down. The cluster must be running; nodes are replaced one at a time. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Body: - `kubernetes_version_id` (string, required): One of the cluster's `available_upgrades`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/upgrade" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "kubernetes_version_id": "k8sv_07hm4y0ur8n3w9s5z2d6e1aq9x" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`: start the cluster first. `KUBERNETES_CLUSTER_BUSY`: the cluster is already changing. Wait until it is running or stopped. `KUBERNETES_CLUSTER_NOT_CHANGEABLE`: the cluster cannot make this change now (stopped, being deleted, or failed). `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: `kubernetes_version_id` `REQUIRED`, `INVALID_KUBERNETES_VERSION` or `KUBERNETES_UPGRADE_NOT_ALLOWED` (pick one of `available_upgrades`). - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Resize worker node compute plan ```http POST /public/v1/kubernetes-clusters/{cluster_id}/resize-plan ``` Resizes the compute plan of worker nodes in the cluster via zero-downtime rolling replacement. A new worker node with the updated plan is provisioned, joins the cluster, workloads migrate, and the old node is retired. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Body: - `node_plan` (string, required): The new node plan slug, from the plans list. In a zone with one fixed node size the plan must be that size (`422 KUBERNETES_PLAN_NOT_IN_ZONE`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/resize-plan" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "node_plan": "s-2vcpu-4gb" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`: start the cluster first. `KUBERNETES_CLUSTER_BUSY`: the cluster is already changing. Wait until it is running or stopped. `KUBERNETES_CLUSTER_NOT_CHANGEABLE`: the cluster cannot make this change now (stopped, being deleted, or failed). `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: `node_plan` `REQUIRED` or `INVALID_KUBERNETES_NODE_PLAN`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Start a Kubernetes cluster ```http POST /public/v1/kubernetes-clusters/{cluster_id}/start ``` Starts a stopped cluster. Its nodes are billed for compute again once it runs. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/start" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBERNETES_CLUSTER_BUSY`: the cluster is already changing. Wait until it is running or stopped. `KUBERNETES_CLUSTER_NOT_CHANGEABLE`: the cluster cannot make this change now (stopped, being deleted, or failed). `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Upgrade Kubernetes cluster networking ```http POST /public/v1/kubernetes-clusters/{cluster_id}/upgrade-network ``` Reconfigures the CNI plugin (`flannel`, `wireguard`, `calico`), Pod CIDR, Service CIDR, or API allowed CIDR ranges for the cluster. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Body: - `cni_plugin` (string): The new CNI plugin (`flannel`, `wireguard`, or `calico`). - `pod_cidr` (string): The new IPv4 CIDR range allocated for cluster pods. - `service_cidr` (string): The new IPv4 CIDR range allocated for cluster services. - `api_allowed_cidrs` (array of string): The new allowed IPv4 CIDRs reaching the API server. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/upgrade-network" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`: start the cluster first. `KUBERNETES_CLUSTER_BUSY`: the cluster is already changing. Wait until it is running or stopped. `KUBERNETES_CLUSTER_NOT_CHANGEABLE`: the cluster cannot make this change now (stopped, being deleted, or failed). `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: `INVALID_KUBERNETES_CNI_PLUGIN` or `INVALID_KUBERNETES_CIDR`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Stop a Kubernetes cluster ```http POST /public/v1/kubernetes-clusters/{cluster_id}/stop ``` Stops a running cluster. While stopped only its disks are billed. Returns `202 Accepted` with an `operation_id` (also in the `Location` header). The change happens in the background: poll `GET /public/v1/operations/{operation_id}`, or stream `/events`, until `status` is `succeeded` or `failed`; a failed operation carries a stable `failure.code` and a readable `failure.reason`. Send an `Idempotency-Key`. A retry with the same key returns the original operation instead of making the change twice. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/stop" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBERNETES_CLUSTER_BUSY`: the cluster is already changing. Wait until it is running or stopped. `KUBERNETES_CLUSTER_NOT_CHANGEABLE`: the cluster cannot make this change now (stopped, being deleted, or failed). `CONCURRENT_UPDATE`: the cluster changed while this request was being processed. Retry the request; if it repeats, read the cluster first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. `PROVISIONING_UNAVAILABLE`: the provisioning service is temporarily unavailable. Wait a few seconds, then retry with the same `Idempotency-Key`. ## Download a kubeconfig ```http POST /public/v1/kubernetes-clusters/{cluster_id}/kubeconfig ``` The cluster's admin kubeconfig, for `kubectl`. It holds a long-lived cluster-admin credential: store it like a password. It is fetched fresh on each call and is not kept by the platform. Available while the cluster is running. It is a `POST` that needs a `write` key so that a read-only key can never fetch a cluster-admin credential; the response is `Cache-Control: no-store`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/kubeconfig" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `200`: ```json { "kubeconfig": "apiVersion: v1\nkind: Config\nclusters:\n- name: prod-web\n cluster:\n server: https://…:6443\n certificate-authority-data: …\n…" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`: no cluster with this id in your organization. Check the id. `RESOURCE_NOT_FOUND`: the id is not a cluster id (`k8s_…`). - `409`: `KUBECONFIG_UNAVAILABLE`: the cluster is not running yet. Wait until it is, then try again. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `KUBERNETES_UNAVAILABLE`: managed Kubernetes is not open on this platform yet. ## List a cluster's nodes ```http GET /public/v1/kubernetes-clusters/{cluster_id}/nodes ``` The cluster's nodes as Kubernetes reports them right now, read from the cluster's own API server. `vcpu` and `memory_mb` are each node's capacity; `disk_gb` is the cluster's node plan. Errors: `404 KUBERNETES_CLUSTER_NOT_FOUND`; `409 KUBERNETES_CLUSTER_NOT_RUNNING` (start the cluster first); `503 KUBERNETES_API_UNREACHABLE` (the API server did not answer: it may still be starting, or its endpoint may be private; retry). Scope: `read`. Path parameters: - `cluster_id` Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/nodes" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "nodes": [ { "id": "id", "name": "web-1", "role": "control", "status": "Ready", "ip_address": "ip-address", "vcpu": 2, "memory_mb": 4096, "disk_gb": 80, "kubelet_version": "v1.31.2+k3s1", "created_at": "2026-10-03T09:30:00Z" } ] } ``` Errors: - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`. - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`. - `503`: `KUBERNETES_API_UNREACHABLE`: retry. ## Get a cluster's services and pods ```http GET /public/v1/kubernetes-clusters/{cluster_id}/workloads ``` Every Service and pod in the cluster, in every namespace, read live from its API server. A cluster you have not deployed anything to shows only what Kubernetes itself runs. A service you exposed (see *Expose a Kubernetes service*) has `public_port` and `external_url`, the address it is reached on from the internet. Errors: `404 KUBERNETES_CLUSTER_NOT_FOUND`; `409 KUBERNETES_CLUSTER_NOT_RUNNING`; `503 KUBERNETES_API_UNREACHABLE` (retry). Scope: `read`. Path parameters: - `cluster_id` Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/workloads" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ip": "public-ip", "api_port": 1, "services": [ { "name": "web-1", "namespace": "namespace", "type": "ClusterIP", "cluster_ip": "cluster-ip", "external_ip": "external-ip", "ports": [ { "name": "web-1", "protocol": "protocol", "port": 22, "target_port": 1, "node_port": 1 } ], "public_port": 1, "external_url": "http://203.0.113.10:8088", "created_at": "2026-10-03T09:30:00Z" } ], "pods": [ { "name": "web-1", "namespace": "namespace", "status": "Running", "ready": "1/1", "restarts": 1, "node_name": "node-name", "pod_ip": "pod-ip", "age": "3h", "created_at": "2026-10-03T09:30:00Z" } ] } ``` Errors: - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`. - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`. - `503`: `KUBERNETES_API_UNREACHABLE`: retry. ## Apply a manifest to a cluster ```http POST /public/v1/kubernetes-clusters/{cluster_id}/apply-manifest ``` Applies a YAML manifest (one or more documents, up to 512 KB and 100 objects) to the cluster with server-side apply, as `kubectl apply --server-side --force-conflicts` would, in order. Namespaced objects without a namespace go in `namespace` (default `default`). Applying the same manifest again changes nothing, so a retry is safe. If the cluster refuses an object, the objects before it stay applied and the error names the one it refused. Errors: `422 VALIDATION_FAILED` with `errors[].field = manifest` and code `INVALID_KUBERNETES_MANIFEST`, whose `message` is the cluster's own reason (for example a missing field); `404 KUBERNETES_CLUSTER_NOT_FOUND`; `409 KUBERNETES_CLUSTER_NOT_RUNNING`; `503 KUBERNETES_API_UNREACHABLE` (retry). Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id` Body: - `manifest` (string, required): At most 524288 characters. - `namespace` (string): Where namespaced objects without one go; default `default`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/apply-manifest" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "manifest": "manifest" }' ``` Response `200`: ```json { "applied": true, "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "output": "deployment.apps/web created", "resources": [ { "kind": "create_instance", "name": "web-1", "namespace": "namespace", "status": "created" } ] } ``` Errors: - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`. - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`. - `422`: `VALIDATION_FAILED`: `INVALID_KUBERNETES_MANIFEST` on `manifest`, or `REQUIRED`. - `503`: `KUBERNETES_API_UNREACHABLE`: retry. ## Export a cluster's workloads as YAML ```http GET /public/v1/kubernetes-clusters/{cluster_id}/live-manifests ``` Your objects as they run in the cluster now, as one multi-document YAML stream you can edit and apply back: Namespaces, ConfigMaps, Services, Deployments, StatefulSets, DaemonSets, CronJobs and Ingresses outside the `kube-*` namespaces. Fields the cluster fills in (status, resource versions, cluster IPs) are left out. Objects a controller made (a Deployment's ReplicaSets and pods) are left out, since their owner is exported. Secrets are never exported. Errors: `404 KUBERNETES_CLUSTER_NOT_FOUND`; `409 KUBERNETES_CLUSTER_NOT_RUNNING`; `503 KUBERNETES_API_UNREACHABLE` (retry). Scope: `read`. Path parameters: - `cluster_id` Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/live-manifests" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "manifest": "manifest", "resource_count": 1, "retrieved_at": "2026-10-03T09:30:00Z" } ``` Errors: - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`. - `409`: `KUBERNETES_CLUSTER_NOT_RUNNING`. - `503`: `KUBERNETES_API_UNREACHABLE`: retry. ## Expose a Kubernetes service ```http POST /public/v1/kubernetes-clusters/{cluster_id}/expose-service ``` Routes `public_port` on the cluster's public address to a Service. The Service's port becomes a NodePort (`node_port`, or the one it has, or one the cluster picks), the Service records the public port in its `pantechdynamics.com/public-port` annotation, and routing is synced. The cluster is the record of what is exposed: removing the annotation with kubectl and syncing routing takes the route away. Your subnet's firewall rules must allow the public port. Public routing needs a cluster in a VPC subnet: it uses the VPC's public address. Port 22 and the cluster's API port are reserved. Errors: `404 KUBERNETES_CLUSTER_NOT_FOUND`; `404 KUBERNETES_SERVICE_NOT_FOUND` (apply the Service first); `409 PORT_IN_USE` (another service, cluster or resource uses that public port; choose another); `409 KUBERNETES_ROUTING_UNAVAILABLE` (a cluster with no VPC address); `409 KUBERNETES_CLUSTER_NOT_RUNNING`; `422 VALIDATION_FAILED` (`service_name`, `public_port` or `node_port`; or `INVALID_KUBERNETES_MANIFEST` when the cluster refused the change); `503 KUBERNETES_API_UNREACHABLE` (retry). Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id` Body: - `service_name` (string, required) - `namespace` (string): Default `default`. - `public_port` (integer, required): From 1 to 65535. - `node_port` (integer): Leave out to keep the Service's own NodePort, or let the cluster choose one. From 30000 to 32767. - `protocol` (string): One of `TCP`, `UDP`. Defaults to `TCP`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/expose-service" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "service_name": "service-name", "public_port": 1 }' ``` Response `200`: ```json { "success": true, "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "service_name": "service-name", "namespace": "namespace", "public_ip": "public-ip", "public_port": 1, "node_port": 1, "protocol": "protocol", "external_url": "http://203.0.113.10:8088", "status": "active" } ``` Errors: - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND` or `KUBERNETES_SERVICE_NOT_FOUND`. - `409`: `PORT_IN_USE`, `KUBERNETES_ROUTING_UNAVAILABLE` or `KUBERNETES_CLUSTER_NOT_RUNNING`. - `422`: `VALIDATION_FAILED`. - `503`: `KUBERNETES_API_UNREACHABLE`: retry. ## Sync a cluster's public routing ```http POST /public/v1/kubernetes-clusters/{cluster_id}/sync-routing ``` Reconciles the port forwards on the cluster's public address with what the cluster exposes now: its API server (unless its endpoint is private), on the cluster's own API port, and every Service carrying the `pantechdynamics.com/public-port` annotation. Routes to services no longer exposed are removed. Only the cluster's own routes are ever changed: other clusters and your own port forwards on the same address are left alone. Every cluster on a shared address gets its own API port: the first 6443, the next 6444, and so on. A new cluster's API port is routed automatically once it is running; call this after exposing or unexposing services with kubectl. If the API server does not answer, only its own route is ensured and no service route is removed. Errors: `404 KUBERNETES_CLUSTER_NOT_FOUND`; `409 PORT_IN_USE`; `409 KUBERNETES_ROUTING_UNAVAILABLE` (a cluster with no VPC address); `409 KUBERNETES_CLUSTER_NOT_RUNNING`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id` Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/sync-routing" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `200`: ```json { "success": true, "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "public_ip": "public-ip", "api_port": 1, "synced_ports": [ { "purpose": "kubernetes-api", "public_port": 1, "private_port": 1, "protocol": "protocol", "target": "target", "status": "active" } ], "message": "message" } ``` Errors: - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `KUBERNETES_CLUSTER_NOT_FOUND`. - `409`: `PORT_IN_USE`, `KUBERNETES_ROUTING_UNAVAILABLE` or `KUBERNETES_CLUSTER_NOT_RUNNING`. ## List node pools ```http GET /public/v1/kubernetes-clusters/{cluster_id}/node-pools ``` Lists the cluster's node pools. Scope: `read`. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/node-pools" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "status": "provisioning", "observed_workers": 1, "id": "id", "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "node_plan_id": "plan_06gk3x9tq7m2v8r4y1c5d0zp8w", "workers": 1, "autoscaling_enabled": true, "min_workers": 1, "max_workers": 1, "labels": {}, "taints": [ { "key": "key", "value": "value", "effect": "NoSchedule" } ], "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster not found. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. ## Create a node pool ```http POST /public/v1/kubernetes-clusters/{cluster_id}/node-pools ``` Adds a node pool: workers of their own plan, joined with the pool's labels, the label `pantech.io/node-pool=` and the pool's taints, so workloads choose it with a nodeSelector and tolerations. Each worker is billed as a VM of the pool's plan once it has joined (`observed_workers`). With autoscaling on, the platform adds a worker when a pod the pool could run is waiting for room, and removes the newest one after 10 idle minutes. A pool's name is unique in its cluster: sending the same pool again is answered as a replay, another pool under a taken name is `409 NODE_POOL_NAME_TAKEN`. In a zone with one fixed node size (af-abj-2), the plan must be that size (`422 KUBERNETES_PLAN_NOT_IN_ZONE`). Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Body: - `name` (string, required): Unique name for the node pool. - `plan_id` (string, required): Compute plan ID or slug for the worker VMs. - `workers` (integer): Worker nodes, 1 to 10. Required unless autoscaling_enabled, when it defaults to min_workers. - `autoscaling_enabled` (boolean): Defaults to `false`. - `min_workers` (integer): With autoscaling_enabled: fewest workers, 1 to 10. - `max_workers` (integer): With autoscaling_enabled: most workers, min_workers to 10. Your credit and limits are checked for this many. - `labels` (object) - `taints` (array of KubernetesNodePoolTaint) - `key` (string, required) - `value` (string, required) - `effect` (string, required): One of `NoSchedule`, `PreferNoSchedule`, `NoExecute`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/node-pools" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "cpu-workers", "plan_id": "standard-2" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: Bad request. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster not found. - `409`: Cluster busy or duplicate node pool name. - `422`: Validation error. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. ## Scale a node pool ```http PATCH /public/v1/kubernetes-clusters/{cluster_id}/node-pools/{pool_id} ``` Changes a node pool's worker count and autoscaling. The body is the pool's whole size. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). - `pool_id`: The node pool's id (`k8spool_…`). Body: - `workers` (integer): Worker nodes, 1 to 10. Always send the pool's whole size: autoscaling_enabled left out turns autoscaling off. - `autoscaling_enabled` (boolean) - `min_workers` (integer) - `max_workers` (integer) Example: ```sh curl -X PATCH "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/node-pools/id_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{}' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "id_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: Bad request. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster or node pool not found. - `409`: Cluster busy. - `422`: Validation error. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. ## Delete a node pool ```http DELETE /public/v1/kubernetes-clusters/{cluster_id}/node-pools/{pool_id} ``` Removes a node pool: its workers leave the cluster (their pods move to other nodes that can run them) and are destroyed, then the pool goes. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). - `pool_id`: The node pool's id (`k8spool_…`). Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/node-pools/id_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "id_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: Bad request. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster or node pool not found. - `409`: Cluster busy. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. ## List cluster add-ons ```http GET /public/v1/kubernetes-clusters/{cluster_id}/addons ``` Lists the cluster's managed add-ons and what the cluster runs of each: `status` is `active`, `installing`, `removing`, `disabled` or `unavailable`. `metrics-server` gives `kubectl top` and the Horizontal Pod Autoscaler; `csi-driver` is persistent volumes on the node's own disk (StorageClass `local-path`; the data stays on that node); `ingress-controller` is ingress-nginx, the default IngressClass, served on 80 and 443 of the cluster's public address in a VPC zone. A cluster starts with metrics-server and csi-driver on. Scope: `read`. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/addons" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "id", "cluster_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "name": "web-1", "version": "version", "status": "active", "enabled": true, "configuration": "configuration", "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster not found. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. ## Toggle a cluster add-on ```http POST /public/v1/kubernetes-clusters/{cluster_id}/addons/{addon_name}/toggle ``` Turns a managed add-on on or off: `metrics-server`, `csi-driver` or `ingress-controller`. The platform applies it within moments and keeps it that way (a change made with kubectl is put back); the add-on's `status` shows when it has taken effect. Asking for the state it is already in changes nothing (empty `operation_id`). Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). - `addon_name`: The add-on: `metrics-server`, `csi-driver` or `ingress-controller`. Any other name is `422 INVALID_CLUSTER_ADDON`. Body: - `enabled` (boolean, required): Whether the add-on should be enabled or disabled. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/addons/id_06gk3x9tq7m2v8r4y1c5d0zp8w/toggle" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "id_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: Bad request. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster or add-on not found. - `409`: Cluster busy. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. ## Configure API server endpoint access ```http POST /public/v1/kubernetes-clusters/{cluster_id}/endpoint-access ``` Updates the cluster API endpoint access mode (`public`, `private`, or `dual`) and authorized CIDRs. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `cluster_id`: The cluster's id (`k8s_…`). Body: - `endpoint_access` (string, required): Endpoint access mode. One of `public`, `private`, `dual`. - `api_allowed_cidrs` (array of string): IPv4 CIDRs allowed to connect to the cluster API. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/kubernetes-clusters/k8s_06gk3x9tq7m2v8r4y1c5d0zp8w/endpoint-access" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "endpoint_access": "public" }' ``` Response `202`: ```json { "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_id": "k8s_06gk3x9tq7m2v8r4y1c5d0zp8w", "status": "submitting" } ``` Errors: - `400`: Bad request. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: Cluster not found. - `409`: Cluster busy. - `422`: Validation error. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: Service unavailable. --- # Catalogue Source: https://docs.pantechdynamics.com/api/catalogue Regions, plans, images and disk offerings: what you can create, and what it costs. Read the catalogue to find the slugs you create resources with: a plan's `slug` is an instance's `plan_slug`, an image's `slug` its `image_slug`, a disk offering's `slug` a volume's `disk_offering_slug`. Prices are integers in minor units (kobo for NGN, cents for USD). Endpoints: - `GET /public/v1/regions`: List regions - `GET /public/v1/plans`: List plans - `GET /public/v1/images`: List images - `GET /public/v1/disk-offerings`: List disk offerings - `GET /public/v1/disk-offerings/{slug}/quote`: Price a volume - `POST /public/v1/pricing/estimate`: Estimate a monthly bill ## List regions ```http GET /public/v1/regions ``` Regions and their zones, and whether each zone can take standard instances or VPC networks right now (`available`, with an `unavailable_reason` when not). Use a zone as `zone_id` for a database, or a region as `region` elsewhere. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/regions" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "code": "af-abj", "name": "Abuja", "placements": [ { "kind": "standard", "zone": "af-abj-1", "available": true, "unavailable_reason": null, "private_network_cidr": "10.250.0.0/20" } ] } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List plans ```http GET /public/v1/plans ``` Instance sizes (vCPU, memory and disk), priced in your organization's currency. Use a `slug` as `plan_slug` for an instance or a database. Send `placement=vpc` to price a VPC instance (no public address of its own), or leave it out for a standard instance. Scope: `read`. Query parameters: - `placement` (string): Price for standard instances or VPC instances. One of `standard`, `vpc`. Defaults to `standard`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/plans" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "id", "slug": "slug", "name": "web-1", "vcpu": 2, "memory_mb": 4096, "disk_gb": 80, "price": { "currency": "NGN", "monthly_estimate_minor": 3760000, "storage_floor_minor": 460000, "initial_payment_minor": 1504000 }, "unpriced_reason": null } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `placement` `INVALID_PLACEMENT`: use `standard` or `vpc`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List images ```http GET /public/v1/images ``` Operating systems you can install on an instance, and the zones each runs in. Use a `slug` as `image_slug` in *Create an instance* or *Rebuild an instance*. Encrypted images (`encrypted: true`) appear only for organizations they are enabled for; elsewhere their slug is refused as `IMAGE_NOT_FOUND`. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/images" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "id", "slug": "slug", "name": "web-1", "version": "version", "zones": [ "af-abj-1" ], "encrypted": true, "encrypted_variant_of": "encrypted-variant-of" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List disk offerings ```http GET /public/v1/disk-offerings ``` Volume types and their hourly prices. Use a `slug` as `disk_offering_slug` in *Create a volume*; for an offering with `custom_size: true`, also send `size_gb`. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/disk-offerings" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "slug": "slug", "name": "web-1", "size_gb": 20, "custom_size": false, "storage_type": "shared", "currency": "NGN", "hourly_price_minor": 640 } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Price a volume ```http GET /public/v1/disk-offerings/{slug}/quote ``` The monthly price of one volume of this offering. `size_gb` is required for a custom-size offering and ignored otherwise. Use it to show a price before *Create a volume*. Scope: `read`. Path parameters: - `slug`: The disk offering's slug. Query parameters: - `size_gb` (integer): At least 1. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/disk-offerings/medium-20gb/quote" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "disk_offering_slug": "medium-20gb", "size_gb": 20, "currency": "NGN", "hourly_price_minor": 640, "monthly_price_minor": 467200 } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no active disk offering has this slug. Pick one from `GET /public/v1/disk-offerings`. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `size_gb` `INVALID_SIZE`: send a whole number of GB, at least 1. `size_gb` `SIZE_REQUIRED`: this offering is custom-size, so `size_gb` is required. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Estimate a monthly bill ```http POST /public/v1/pricing/estimate ``` Prices a whole configuration before you build it: machines by plan and running hours, extra storage, VPC networks and snapshot storage. Nothing is created and nothing is charged, so no `Idempotency-Key` is needed. `pricing_complete` is false when any line could not be priced; that line has `priced: false` and an `unpriced_reason`. Scope: `read`. Body: - `currency` (string, required): The currency to price in: `NGN` or `USD`. One of `NGN`, `USD`. - `machines` (array of object): Instances to price: up to 50 lines, each a plan, a quantity and running hours per month. At most 50 items. - `plan_slug` (string, required) - `quantity` (integer, required): At least 1. - `running_hours` (integer, required): From 0 to 730. - `placement` (string): One of `standard`, `vpc`. - `extra_storage_gb` (integer): Extra volume storage in GB. At least 0. - `network_count` (integer): Number of VPC networks. At least 0. - `snapshot_gb` (integer): Snapshot storage in GB. At least 0. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/pricing/estimate" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "currency": "NGN", "machines": [ { "plan_slug": "starter", "quantity": 2, "running_hours": 730, "placement": "standard" } ] }' ``` Response `200`: ```json { "currency": "NGN", "nominal_month_hours": 730, "machines": [ { "plan_slug": "starter", "quantity": 2, "running_hours": 730, "priced": true, "unpriced_reason": null, "unit_monthly_estimate_minor": 1, "total_monthly_estimate_minor": 1 } ], "extra_storage_gb": { "requested": 0, "priced": true, "unpriced_reason": null, "amount_minor": 1059200 }, "networks": { "requested": 0, "priced": true, "unpriced_reason": null, "amount_minor": 1059200 }, "snapshot_gb": { "requested": 0, "priced": true, "unpriced_reason": null, "amount_minor": 1059200 }, "pricing_complete": true, "priced_subtotal_minor": 1 } ``` Errors: - `400`: `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `currency` `UNSUPPORTED_CURRENCY`: use `NGN` or `USD`. `machines` `TOO_MANY_MACHINES`: at most 50 machine lines. `machines` `INVALID_MACHINE`: each machine needs a `plan_slug`, a `quantity` above 0 and 0 to 730 `running_hours`. `placement` `INVALID_PLACEMENT`: use `standard` or `vpc`. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # Operations Source: https://docs.pantechdynamics.com/api/operations Follow asynchronous changes until they finish. Every asynchronous write returns `202` with an `operation_id`. Poll the operation until its `status` is `succeeded` or `failed`, or open its event stream and wait. Polling every 2–5 seconds is plenty. Endpoints: - `GET /public/v1/operations/{operation_id}`: Get an operation - `GET /public/v1/operations/{operation_id}/events`: Stream an operation ## The operation object - `id` (string, required): e.g. `op_...`. - `resource_type` (string, required): e.g. `instance`, `volume`, `network`, `security_group`, `database`. One of `instance`, `network`, `subnet`, `security_group`, `snapshot`, `volume`, `ssh_access_grant`, `relay_vm`, `network_acl_rule`, `public_ip`, `port_forwarding_rule`, `database`. - `resource_id` (string, required): The resource the operation acts on, e.g. a `vm_…`, `db_…` or `snap_…` id. For a create, the new resource's id. - `kind` (string, required): What it does, e.g. `create_instance`, `stop_instance`. For a database: `create_database`, `start_database`, `stop_database`, `delete_database`, `apply_database_access_rules` (an access-rule change, attaching or detaching security groups, or the automatic re-apply after an attached group's rules changed), `change_database_password`, `reset_database_password`, `resize_database_storage`. New kinds may be added. - `status` (string, required): `submitting` and `submitted` while it runs; `succeeded` and `failed` are final. One of `submitting`, `submitted`, `succeeded`, `failed`. - `failure` (Failure): Why it failed, when `status` is `failed`; otherwise null. - `code` (string, required): A stable failure code to branch on, e.g. `INSTANCE_BUSY`. Provisioning failures use `PROVISIONING_CAPACITY_UNAVAILABLE` (no capacity in the zone right now: try another plan or zone, or try later), `PROVISIONING_LIMIT_EXCEEDED` (an account limit was reached: delete unused resources or contact support), `PROVISIONING_ADDRESSES_EXHAUSTED` (no free addresses in the zone), `PROVISIONING_IMAGE_UNAVAILABLE` (the image is not ready), `PROVISIONING_RESOURCE_BUSY` (the resource was busy: try again once it settles), `PROVISIONING_RESOURCE_CONFLICT`, `PROVISIONING_RESOURCE_NOT_FOUND`, `PROVISIONING_INVALID_REQUEST`, `PROVISIONING_UNAVAILABLE` (temporarily unavailable: retry), `PROVISIONING_INTERNAL_ERROR` (a fault on our side: our team is alerted, and retrying will not help) and `PROVISIONING_FAILED` (anything else). - `reason` (string, required): A readable sentence saying what went wrong and what to do; safe to show to your users. Its wording may change: branch on `code`. - `created_at` (string or null): When it was created (RFC 3339, UTC). - `updated_at` (string or null): When it last changed (RFC 3339, UTC). ## Get an operation ```http GET /public/v1/operations/{operation_id} ``` One asynchronous operation. Every write that returns `202` gives you its `operation_id`: poll this until `status` is `succeeded` or `failed` (wait a second or two between polls), or use *Stream an operation* instead. A failed operation has `failure.code`, a stable code to branch on, and `failure.reason`, a readable sentence safe to show to your users. Scope: `read`. Path parameters: - `operation_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/operations/op_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_type": "instance", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "kind": "create_instance", "status": "submitting", "failure": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no operation with this id in your organization, or the id is not a operation id (`op_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Stream an operation ```http GET /public/v1/operations/{operation_id}/events ``` Server-Sent Events for one operation: an `operation` event (data: the Operation) each time it changes, ending when it succeeds or fails. A `: keepalive` comment is sent while it is idle. Use it instead of polling. If the stream stops before the operation finishes, its last event is `error`, with data `{"code", "detail", "request_id"}`: `OPERATION_STREAM_UNAVAILABLE` after 30 seconds of failing to read the operation, `UNAUTHENTICATED` within 30 seconds of the key being revoked or expiring, or `OPERATION_STREAM_TIMEOUT` after 15 minutes open. Reconnect, or poll `GET /public/v1/operations/{operation_id}`. Scope: `read`. Path parameters: - `operation_id`: The resource's id. Example: ```sh curl -N "https://api.pantechdynamics.com/public/v1/operations/op_06gk3x9tq7m2v8r4y1c5d0zp8w/events" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: The event stream. Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no operation with this id in your organization, or the id is not a operation id (`op_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # Webhooks Source: https://docs.pantechdynamics.com/api/webhooks Have events sent to your own URL: resources finishing or failing a change, invoices, payments and credit. A webhook is an `https` URL that Pantech `POST`s a signed JSON event to whenever something it subscribes to happens: an [operation](https://docs.pantechdynamics.com/api/operations) finishing or failing, an invoice, a payment, credit running low. Subscribe to exact types from `GET /webhook-event-types`, to one resource with `.*`, or to everything with `*`. The signing secret (`whsec_…`) is returned only when the webhook is created and when its secret is rotated. Verify every delivery's `Pantech-Signature` before trusting it, and deduplicate on the event `id`: delivery is at least once and unordered. See the [webhooks guide](https://docs.pantechdynamics.com/webhooks/overview), [verifying signatures](https://docs.pantechdynamics.com/webhooks/verify-signatures) and the [event types](https://docs.pantechdynamics.com/webhooks/event-types). Endpoints: - `GET /public/v1/webhook-event-types`: List webhook event types - `GET /public/v1/webhooks`: List webhooks - `POST /public/v1/webhooks`: Create a webhook - `GET /public/v1/webhooks/{webhook_id}`: Get a webhook - `PATCH /public/v1/webhooks/{webhook_id}`: Change a webhook - `DELETE /public/v1/webhooks/{webhook_id}`: Delete a webhook - `POST /public/v1/webhooks/{webhook_id}/test`: Send a test event - `POST /public/v1/webhooks/{webhook_id}/rotate-secret`: Rotate a webhook's secret - `GET /public/v1/webhooks/{webhook_id}/deliveries`: List a webhook's deliveries - `GET /public/v1/webhooks/{webhook_id}/deliveries/{delivery_id}`: Get a delivery - `POST /public/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver`: Send a delivery again ## The webhook object A URL Pantech sends your organization's events to. - `id` (string, required): e.g. `whk_...`. - `url` (string, required): Where events are sent: an `https` URL on the public internet. - `description` (string, required): Your note about it; may be empty. - `events` (array of string, required): What it subscribes to: event types, `.*`, or `*`. - `enabled` (boolean, required): Whether events are sent to it. - `disabled_reason` (string or null, required): Why it is disabled: `disabled_by_user`, or `failing` after three days of failed deliveries. Null while enabled. One of `disabled_by_user`, `failing`, `null`. - `failing_since` (string or null, required): When its deliveries started failing with no success since (RFC 3339, UTC); null while they succeed. - `created_at` (string, required): When it was created (RFC 3339, UTC). - `updated_at` (string, required): When it last changed (RFC 3339, UTC). ## The webhook delivery object One event sent, or waiting to be sent, to one webhook. - `id` (string, required): e.g. `whd_...`. - `webhook_id` (string, required): The webhook it is for. - `event_id` (string, required): The event's `id`, the same in every delivery of it: deduplicate on it. - `event_type` (string, required): e.g. `instance.created`. - `status` (string, required): `pending` until it succeeds, fails eight times, or is cancelled because the webhook was disabled or deleted. One of `pending`, `succeeded`, `failed`, `canceled`. - `attempts` (integer, required): How many times it has been sent. - `next_attempt_at` (string or null, required): When it is next sent, while `pending`. - `last_attempt_at` (string or null, required): When it was last sent. - `last_status_code` (integer or null, required): The HTTP status your endpoint last answered; null if it did not answer. - `last_error` (string or null, required): Why the last attempt failed: `http_`, `timeout`, `connection_failed`, `dns_failed`, `tls_failed`, `blocked_address` (the URL resolved to an address that is not on the public internet), `invalid_url`, `endpoint_disabled` or `endpoint_deleted`. - `last_response` (string or null, required): The first 1,024 characters of your endpoint's last response body. - `last_duration_ms` (integer or null, required): How long the last attempt took, in milliseconds. - `delivered_at` (string or null, required): When it succeeded. - `created_at` (string, required): When the event was queued for this webhook. ## The webhook event object The body of every delivery. An infrastructure event's `data` names the resource and the change: `resource_type`, `resource_id`, `operation_id`, `action`, `status` (`succeeded` or `failed`) and, on failure, `error` with the same safe `code` and `message` the operations API returns. Read the resource through the API for more. A billing event's `data` carries its amounts in minor units. - `id` (string): The event's id (`evt_…`). Deduplicate on it. - `type` (string): e.g. `instance.created`, `invoice.paid`, `webhook.test`. - `api_version` (string): The shape of this body, as a date: `2026-10-22`. - `created_at` (string): When it happened (RFC 3339, UTC). - `organization_id` (string): Your organization. - `project_id` (string): The project (`acc_…`) the resource is in. - `data` (object): What happened; depends on `type`. ## List webhook event types ```http GET /public/v1/webhook-event-types ``` Every event type a webhook can subscribe to, with what it means. Subscribe to an exact type, to every type of one resource with `.*` (for example `instance.*`), or to everything with `*`. Infrastructure events are named `.` when a change finished (`instance.created`, `volume.attached`) and `._failed` when it failed (`instance.create_failed`). Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/webhook-event-types" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "type": "instance.created", "resource": "instance", "description": "Instance: create finished." } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List webhooks ```http GET /public/v1/webhooks ``` Your organization's webhooks: the URLs Pantech sends events to. The signing secret is never part of a webhook; it is shown once, when the webhook is created and when its secret is rotated. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/webhooks" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": [ "instance.*", "invoice.paid" ], "enabled": true, "disabled_reason": null, "failing_since": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ], "next_cursor": null } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Create a webhook ```http POST /public/v1/webhooks ``` Adds a URL Pantech sends events to. Every delivery is a JSON `POST` signed with the webhook's secret, which is in this response only: store it now. **Verifying a delivery.** Each request carries `Pantech-Signature: t=,v1=`. Compute HMAC-SHA256 of `.` with the secret and compare it, in constant time, with any `v1` value; reject a `t` more than five minutes old. During the 24 hours after a rotation there are two `v1` values, one per secret. `Pantech-Event-Id`, `Pantech-Event-Type` and `Pantech-Delivery-Id` name what was sent. **Delivery.** Any `2xx` within 10 seconds is success. Anything else, including a redirect, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then marked failed. A webhook that has failed for three days without a single success is disabled. Events can arrive more than once and out of order: deduplicate on the event `id`. At most 10 webhooks per organization. Owners and admins only. **Idempotency:** a retry with the same `Idempotency-Key` returns the webhook the first request made, with `secret: null`: the secret is never shown again. The same key with a different request returns `409 IDEMPOTENCY_CONFLICT`. Scope: `write`. Send an `Idempotency-Key` header. Body: - `url` (string, required): An `https` URL on the public internet, at most 2,048 characters, with no user name or password. - `description` (string): A note for yourself, at most 255 characters. - `events` (array of string, required): 1 to 100 entries: event types from `GET /webhook-event-types`, `.*`, or `*`. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/webhooks" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/pantech", "events": [ "instance.*", "invoice.paid" ] }' ``` Response `201`: ```json { "id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": [ "instance.*", "invoice.paid" ], "enabled": true, "disabled_reason": null, "failing_since": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z", "secret": "whsec_Jx2Qm9vR4tY7wK1pZ8nB3cF6hL0sD5gA2eU9iO4rT7y" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the `write` scope. Use a key with that scope. `FORBIDDEN`: the member who created the key is not an owner or admin. Use a key created by one. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server. An owner can turn it on in the console under Organization › AI apps. `WEBHOOK_LIMIT_REACHED`: your organization already has 10 webhooks. Delete one first. - `409`: `IDEMPOTENCY_CONFLICT`: this `Idempotency-Key` was already used for a different webhook. Use a new key. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `url` `REQUIRED` or `INVALID_WEBHOOK_URL`: send an `https` URL on the public internet, with no user name or password, of at most 2,048 characters. `events` `INVALID_WEBHOOK_EVENTS`: send 1 to 100 entries, each an event type from `GET /webhook-event-types`, `.*`, or `*`. `description` `INVALID_DESCRIPTION`: send at most 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a webhook ```http GET /public/v1/webhooks/{webhook_id} ``` One webhook. `failing_since` is when its deliveries started failing without a success since; null while they succeed. Scope: `read`. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": [ "instance.*", "invoice.paid" ], "enabled": true, "disabled_reason": null, "failing_since": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `RESOURCE_NOT_FOUND`: the id is not a webhook id (`whk_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Change a webhook ```http PATCH /public/v1/webhooks/{webhook_id} ``` Changes the URL, description, events or `enabled`; fields you leave out keep their value. Enabling a disabled webhook clears its failure history. Deliveries still waiting for a disabled webhook are cancelled when they come due. **Idempotency:** the same request applied twice leaves the webhook the same. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). Body: - `url` (string): A new `https` URL. - `description` (string): A new note, at most 255 characters. - `events` (array of string): The new subscriptions, replacing the old ones. - `enabled` (boolean): `false` stops deliveries; `true` resumes them and clears the failure history. Example: ```sh curl -X PATCH "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` Response `200`: ```json { "id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": [ "instance.*", "invoice.paid" ], "enabled": true, "disabled_reason": null, "failing_since": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `RESOURCE_NOT_FOUND`: the id is not a webhook id (`whk_…`). Check the id. - `409`: `CONFLICT`: the webhook was changed at the same time. Get it again and retry. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `url` `REQUIRED` or `INVALID_WEBHOOK_URL`: send an `https` URL on the public internet, with no user name or password, of at most 2,048 characters. `events` `INVALID_WEBHOOK_EVENTS`: send 1 to 100 entries, each an event type from `GET /webhook-event-types`, `.*`, or `*`. `description` `INVALID_DESCRIPTION`: send at most 255 characters. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Delete a webhook ```http DELETE /public/v1/webhooks/{webhook_id} ``` Deletes a webhook and its delivery history. Nothing more is sent to it. **Idempotency:** a retry is not replayed. Once the webhook is deleted, a repeat returns `404`; treat that as done. **Scope:** `write`. Destructive: an AI app's key also needs `delete`. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `204`: Deleted. Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `RESOURCE_NOT_FOUND`: the id is not a webhook id (`whk_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Send a test event ```http POST /public/v1/webhooks/{webhook_id}/test ``` Queues a `webhook.test` event to this webhook, whatever it subscribes to, and returns the delivery. It is sent within a few seconds and retried like any other. **Idempotency:** a retry is not replayed: each request sends another test event. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w/test" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "id": "whd_06gk3x9tq7m2v8r4y1c5d0zp8w", "webhook_id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_type": "webhook.test", "status": "pending", "attempts": 0, "next_attempt_at": "2026-10-03T09:30:00Z", "last_attempt_at": null, "last_status_code": null, "last_error": null, "last_response": null, "last_duration_ms": null, "delivered_at": null, "created_at": "2026-10-03T09:30:00Z", "payload": { "id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "type": "webhook.test", "api_version": "2026-10-22", "created_at": "2026-10-03T09:30:00Z", "organization_id": "org_06gk3x9tq7m2v8r4y1c5d0zp8w", "project_id": "acc_06gk3x9tq7m2v8r4y1c5d0zp8w", "data": { "endpoint_id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "message": "This is a test event from Pantech." } } } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `RESOURCE_NOT_FOUND`: the id is not a webhook id (`whk_…`). Check the id. - `409`: `WEBHOOK_DISABLED`: the webhook is disabled. Enable it first. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Rotate a webhook's secret ```http POST /public/v1/webhooks/{webhook_id}/rotate-secret ``` Replaces the signing secret, which is in this response only. For 24 hours every delivery is signed with both the new and the old secret, so you can update your receiver without dropping events. **Idempotency:** a retry is not replayed: each request rotates again, and only the latest secret and the one before it sign. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w/rotate-secret" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `200`: ```json { "id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": [ "instance.*", "invoice.paid" ], "enabled": true, "disabled_reason": null, "failing_since": null, "created_at": "2026-10-03T09:30:00Z", "updated_at": "2026-10-03T09:30:00Z", "secret": "whsec_Jx2Qm9vR4tY7wK1pZ8nB3cF6hL0sD5gA2eU9iO4rT7y" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `RESOURCE_NOT_FOUND`: the id is not a webhook id (`whk_…`). Check the id. - `409`: `CONFLICT`: the webhook was changed at the same time. Retry. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## List a webhook's deliveries ```http GET /public/v1/webhooks/{webhook_id}/deliveries ``` Every event sent or waiting to be sent to this webhook, newest first, kept for 30 days: its status, attempts, and how the last attempt went. Scope: `read`. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w/deliveries" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "whd_06gk3x9tq7m2v8r4y1c5d0zp8w", "webhook_id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_type": "instance.created", "status": "succeeded", "attempts": 1, "next_attempt_at": null, "last_attempt_at": "2026-10-03T09:30:00Z", "last_status_code": 200, "last_error": null, "last_response": "ok", "last_duration_ms": 184, "delivered_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z" } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_PAGE`: `limit` is not an integer from 1 to 100. `INVALID_CURSOR`: the cursor is not valid for this list. Start again without one. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `RESOURCE_NOT_FOUND`: the id is not a webhook id (`whk_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a delivery ```http GET /public/v1/webhooks/{webhook_id}/deliveries/{delivery_id} ``` One delivery, with `payload`: the exact body sent. Scope: `read`. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). - `delivery_id`: The delivery's id (`whd_…`). Example: ```sh curl "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w/deliveries/whd_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "whd_06gk3x9tq7m2v8r4y1c5d0zp8w", "webhook_id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_type": "instance.created", "status": "succeeded", "attempts": 1, "next_attempt_at": null, "last_attempt_at": "2026-10-03T09:30:00Z", "last_status_code": 200, "last_error": null, "last_response": "ok", "last_duration_ms": 184, "delivered_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "payload": { "id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "type": "instance.created", "api_version": "2026-10-22", "created_at": "2026-10-03T09:30:00Z", "organization_id": "org_06gk3x9tq7m2v8r4y1c5d0zp8w", "project_id": "acc_06gk3x9tq7m2v8r4y1c5d0zp8w", "data": { "resource_type": "instance", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "action": "create", "status": "succeeded" } } } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `WEBHOOK_DELIVERY_NOT_FOUND`: no delivery with this id for this webhook. `RESOURCE_NOT_FOUND`: an id is not a webhook id (`whk_…`) or delivery id (`whd_…`). Check the ids. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Send a delivery again ```http POST /public/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver ``` Queues a delivery that succeeded, failed or was cancelled to be sent again from its first attempt, with the same event `id`. **Idempotency:** a retry while it is being sent returns `409 WEBHOOK_DELIVERY_IN_PROGRESS`; treat that as done. Scope: `write`. Send an `Idempotency-Key` header. Path parameters: - `webhook_id`: The webhook's id (`whk_…`). - `delivery_id`: The delivery's id (`whd_…`). Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/webhooks/whk_06gk3x9tq7m2v8r4y1c5d0zp8w/deliveries/whd_06gk3x9tq7m2v8r4y1c5d0zp8w/redeliver" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` Response `202`: ```json { "id": "whd_06gk3x9tq7m2v8r4y1c5d0zp8w", "webhook_id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "event_type": "instance.created", "status": "succeeded", "attempts": 1, "next_attempt_at": null, "last_attempt_at": "2026-10-03T09:30:00Z", "last_status_code": 200, "last_error": null, "last_response": "ok", "last_duration_ms": 184, "delivered_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z" } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `WEBHOOK_NOT_FOUND`: no webhook with this id in your organization. List your webhooks to find it. `WEBHOOK_DELIVERY_NOT_FOUND`: no delivery with this id for this webhook. `RESOURCE_NOT_FOUND`: an id is not a webhook id (`whk_…`) or delivery id (`whd_…`). Check the ids. - `409`: `WEBHOOK_DISABLED`: the webhook is disabled. Enable it first. `WEBHOOK_DELIVERY_IN_PROGRESS`: the delivery is still being attempted. Wait for it to finish. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `WEBHOOKS_UNAVAILABLE`: webhooks are not available on this deployment yet. Try again later. `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # Usage Source: https://docs.pantechdynamics.com/api/usage What you have used, and what it cost. Usage is measured per resource and rated into cost. `GET /usage/by-resource` answers "what did each thing cost"; the other endpoints return the measured quantities. Endpoints: - `GET /public/v1/instances/{instance_id}/usage`: Get an instance's usage - `GET /public/v1/volumes/{volume_id}/usage`: Get a volume's usage - `GET /public/v1/snapshots/{snapshot_id}/usage`: Get a snapshot's usage - `GET /public/v1/usage`: Get usage - `GET /public/v1/usage/by-resource`: Get cost by resource ## Get an instance's usage ```http GET /public/v1/instances/{instance_id}/usage ``` Measured usage for this instance in the current billing period: running hours, storage, memory and network. Scope: `read`. Path parameters: - `instance_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/instances/vm_06gk3x9tq7m2v8r4y1c5d0zp8w/usage" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "window_start": "2026-10-03T09:30:00Z", "window_end": "2026-10-03T09:30:00Z", "totals": { "running_hours": 730, "storage_gb_hours": 1, "allocated_memory_mb_hours": 1, "network_bytes_sent": 1, "network_bytes_received": 1 }, "entries": [ { "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "usage_type": 1, "usage": "24 Hrs", "period_start": "2026-10-03T09:30:00Z", "period_end": "2026-10-03T09:30:00Z" } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no instance with this id in your organization, or the id is not a instance id (`vm_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a volume's usage ```http GET /public/v1/volumes/{volume_id}/usage ``` Measured storage usage for this volume in the current billing period. Scope: `read`. Path parameters: - `volume_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/volumes/vol_06gk3x9tq7m2v8r4y1c5d0zp8w/usage" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "window_start": "2026-10-03T09:30:00Z", "window_end": "2026-10-03T09:30:00Z", "totals": { "running_hours": 730, "storage_gb_hours": 1, "allocated_memory_mb_hours": 1, "network_bytes_sent": 1, "network_bytes_received": 1 }, "entries": [ { "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "usage_type": 1, "usage": "24 Hrs", "period_start": "2026-10-03T09:30:00Z", "period_end": "2026-10-03T09:30:00Z" } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no volume with this id in your organization, or the id is not a volume id (`vol_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get a snapshot's usage ```http GET /public/v1/snapshots/{snapshot_id}/usage ``` Measured storage usage for this snapshot in the current billing period. Scope: `read`. Path parameters: - `snapshot_id`: The resource's id. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/snapshots/snap_06gk3x9tq7m2v8r4y1c5d0zp8w/usage" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "window_start": "2026-10-03T09:30:00Z", "window_end": "2026-10-03T09:30:00Z", "totals": { "running_hours": 730, "storage_gb_hours": 1, "allocated_memory_mb_hours": 1, "network_bytes_sent": 1, "network_bytes_received": 1 }, "entries": [ { "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "usage_type": 1, "usage": "24 Hrs", "period_start": "2026-10-03T09:30:00Z", "period_end": "2026-10-03T09:30:00Z" } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no snapshot with this id in your organization, or the id is not a snapshot id (`snap_…`). Check the id. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get usage ```http GET /public/v1/usage ``` Measured usage for the whole organization in the current billing period. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/usage" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "window_start": "2026-10-03T09:30:00Z", "window_end": "2026-10-03T09:30:00Z", "totals": { "running_hours": 730, "storage_gb_hours": 1, "allocated_memory_mb_hours": 1, "network_bytes_sent": 1, "network_bytes_received": 1 }, "entries": [ { "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "usage_type": 1, "usage": "24 Hrs", "period_start": "2026-10-03T09:30:00Z", "period_end": "2026-10-03T09:30:00Z" } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Get cost by resource ```http GET /public/v1/usage/by-resource ``` What each resource cost over `period`: the last `30d` or `7d`, this `month` or `last_month`. Scope: `read`. Query parameters: - `period` (string): One of `30d`, `7d`, `month`, `last_month`. Defaults to `30d`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/usage/by-resource" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "currency": "NGN", "period_start": "2026-10-03T09:30:00Z", "period_end": "2026-10-03T09:30:00Z", "data": [ { "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "resource_name": "web-1", "resource_kind": "instance", "type": "compute", "days_active": 3, "window_days": 30, "rate_unit": "hour", "rate_unit_price_micros": 1232876000, "cost_minor": 295890 } ] } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `period` `INVALID_PERIOD`: use `30d`, `7d`, `month` or `last_month`. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. --- # Billing Source: https://docs.pantechdynamics.com/api/billing Your credit, and whether it covers your next invoice. Check your credit before creating resources from automation, or poll it so you can top up in time. `standing` tells you at a glance: `covered`, `low` or `empty`. Owners and admins are also emailed once when credit becomes low and once when it runs out. To add credit, create a purchase and send its `checkout_url` to whoever pays. The link opens Flutterwave for organizations billed in NGN and Stripe for everyone else, so card details never pass through your systems or ours. Nothing is charged until the link is paid. Endpoints: - `GET /public/v1/billing/credit`: Get credit - `POST /public/v1/billing/credit/purchases`: Buy credit - `GET /public/v1/billing/credit/purchases/{purchase_id}`: Get a credit purchase - `GET /public/v1/billing/receipts`: List receipts - `GET /public/v1/billing/receipts/{receipt_id}`: Get a receipt - `GET /public/v1/billing/receipts/{receipt_id}/pdf`: Download a receipt ## Get credit ```http GET /public/v1/billing/credit ``` Your organization's credit, and how it compares with your next invoice. `estimated_next_invoice_minor` is usage so far this billing period plus the recent daily rate for the days left, plus 7.5% VAT on that (VAT applies only to the monthly invoice, never to credit purchases). It is an estimate: the invoice issued at `next_invoice_at` is the real figure. `standing` is `covered` when credit and prepaid instance payments cover the estimate, `low` when they don't, and `empty` when no credit is left. Anything credit doesn't cover is charged to your default payment method. Check this before creating resources from automation, or poll it to top up in time. Owners and admins are also emailed once when credit becomes `low` and once when it becomes `empty`. To add credit, see *Buy credit*. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/billing/credit" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "currency": "NGN", "available_minor": 1, "pending_minor": 1, "prepaid_minor": 1, "estimated_next_invoice_minor": 1, "next_invoice_at": "2026-10-03T09:30:00Z", "standing": "covered", "has_payment_method": true } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. ## Buy credit ```http POST /public/v1/billing/credit/purchases ``` Creates a payment link to add credit to your organization. Send `checkout_url` to whoever pays: it opens the payment provider's own secure page, so no card details ever pass through your systems or ours. Organizations pay in NGN or USD. The currency follows your organization's country and can't be chosen. Nothing is charged until someone pays the link. The credit is added once the provider confirms the payment, usually within a minute: follow the purchase with `GET /public/v1/billing/credit/purchases/{purchase_id}` until `status` is `settled`, and the owners and admins get a receipt by email. A link that isn't paid expires at `checkout_expires_at`; create a new purchase after that. One purchase is between NGN 500 and NGN 500,000 (`50000` to `50000000` kobo), or between USD 5 and USD 2,000 (`500` to `200000` cents). Send an `Idempotency-Key`: a retry with the same key returns the same purchase and the same link. The member who created the key must be an owner or admin with a verified email. Scope: `write`. Send an `Idempotency-Key` header. Body: - `amount_minor` (integer, required): How much credit to buy, in minor units of your organization's currency: kobo for NGN, cents for USD. Example: ```sh curl -X POST "https://api.pantechdynamics.com/public/v1/billing/credit/purchases" \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "amount_minor": 1059200 }' ``` Response `201`: ```json { "id": "id", "status": "pending", "amount_minor": 1059200, "currency": "NGN", "method": "checkout", "provider": "provider", "checkout_url": "checkout-url", "checkout_expires_at": "2026-10-03T09:30:00Z", "failure_code": null, "settled_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "receipt": { "id": "id", "number": 100 } } ``` Errors: - `400`: `IDEMPOTENCY_KEY_REQUIRED`: the `Idempotency-Key` header is missing. Send a new unique value, such as a UUID, with every write. `INVALID_IDEMPOTENCY_KEY`: the `Idempotency-Key` header is longer than 255 characters. Use a shorter value. `INVALID_REQUEST_BODY`: the body is not a single valid JSON object. Send one JSON object with the fields documented for this endpoint. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: this API key has only the `read` scope. Use a key with `write` access. `FORBIDDEN`: only an owner or admin may buy credit. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server. An owner can turn it on in the console under Organization › AI apps. - `409`: `EMAIL_VERIFICATION_REQUIRED`: the member who created this key has not verified their email, where the receipt is sent. Verify it in the console, then retry. `CREDIT_PURCHASE_UNAVAILABLE`: credit can't be bought for this organization yet. Finish billing setup in the console, or contact support. `IDEMPOTENCY_CONFLICT`: this `Idempotency-Key` was already used for a different amount. Use a new key for a different purchase. - `413`: `REQUEST_TOO_LARGE`: the body is larger than 64 KiB. Send a smaller request. - `422`: `VALIDATION_FAILED`: a field is invalid. Each entry of `errors` names the `field` and its `code`: `amount_minor` `REQUIRED`: send a positive amount in minor units. `amount_minor` `AMOUNT_OUT_OF_RANGE`: the amount is outside the per-purchase limits above for your currency. Choose an amount within them. `UNKNOWN_FIELD`: the body has a field this endpoint does not take (named in `field`). Remove it or fix its spelling. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. ## Get a credit purchase ```http GET /public/v1/billing/credit/purchases/{purchase_id} ``` One credit purchase: whether it has been paid. `checkout_url` is shown only while the purchase can still be paid. Poll this after *Buy credit* until `status` is `settled` (credit added), or `failed` or `expired` (nothing charged; create a new purchase). Scope: `read`. Path parameters: - `purchase_id`: The purchase's id, e.g. `cpr_...`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/billing/credit/purchases/id_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "id", "status": "pending", "amount_minor": 1059200, "currency": "NGN", "method": "checkout", "provider": "provider", "checkout_url": "checkout-url", "checkout_expires_at": "2026-10-03T09:30:00Z", "failure_code": null, "settled_at": "2026-10-03T09:30:00Z", "created_at": "2026-10-03T09:30:00Z", "receipt": { "id": "id", "number": 100 } } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RESOURCE_NOT_FOUND`: no credit purchase with this id in your organization. Check the id (`cpr_…`). - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. ## List receipts ```http GET /public/v1/billing/receipts ``` Every receipt issued to your organization, newest first. A receipt is issued, numbered `PTD-RCT--`, whenever money moves: a card payment, a credit top-up, an invoice paid by card or from credit, a debit from your credit (for example a new instance's first charges), and a refund. A payment's receipt usually appears within a minute of it settling. Scope: `read`. Query parameters: - `limit` (integer): Items per page, 1 to 100. Defaults to 50. From 1 to 100. Defaults to `50`. - `cursor` (string): The previous page's `next_cursor`, to get the next page. Leave out for the first page; keep the other parameters the same. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/billing/receipts" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "data": [ { "id": "id", "number": 100, "kind": "payment", "issuer_name": "issuer-name", "currency": "NGN", "amount_minor": 1059200, "issued_at": "2026-10-03T09:30:00Z", "occurred_at": "2026-10-03T09:30:00Z", "description": "description", "billed_to": "billed-to", "facts": [ { "label": "label", "value": "value" } ], "verification": "verification", "transaction_id": "transaction-id", "pdf_bytes": 1 } ], "next_cursor": "eyJhdCI6IjIwMjYtMTAtMDNUMDk6MzA6MDBaIiwiaWQiOiJ2bV8wNmdrIn0" } ``` Errors: - `400`: `INVALID_CURSOR`: the cursor did not come from this list. Start again without one. - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. ## Get a receipt ```http GET /public/v1/billing/receipts/{receipt_id} ``` One receipt: what it was for, the amount, who issued it and the details printed on it. Download it as a PDF with `GET /public/v1/billing/receipts/{receipt_id}/pdf`. Scope: `read`. Path parameters: - `receipt_id`: The receipt's id, e.g. `rcp_...`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/billing/receipts/id_06gk3x9tq7m2v8r4y1c5d0zp8w" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "id": "id", "number": 100, "kind": "payment", "issuer_name": "issuer-name", "currency": "NGN", "amount_minor": 1059200, "issued_at": "2026-10-03T09:30:00Z", "occurred_at": "2026-10-03T09:30:00Z", "description": "description", "billed_to": "billed-to", "facts": [ { "label": "label", "value": "value" } ], "verification": "verification", "transaction_id": "transaction-id", "pdf_bytes": 1 } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RECEIPT_NOT_FOUND`: no receipt with this id in your organization. List your receipts to find it. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. ## Download a receipt ```http GET /public/v1/billing/receipts/{receipt_id}/pdf ``` The receipt as a PDF, the same document that was emailed. The file name is the receipt number, e.g. `PTD-RCT-2026-000123.pdf`. Scope: `read`. Path parameters: - `receipt_id`: The receipt's id, e.g. `rcp_...`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/billing/receipts/id_06gk3x9tq7m2v8r4y1c5d0zp8w/pdf" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: The PDF. Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `404`: `RECEIPT_NOT_FOUND`: no receipt with this id in your organization. List your receipts to find it. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. `BILLING_UNAVAILABLE`: billing is not answering right now. Wait a few seconds, then retry. --- # Account Source: https://docs.pantechdynamics.com/api/account Check which organization an API key acts for, what it may do and when it expires. Call `GET /me` first: it confirms the key works, and tells you its scopes before you try anything they do not allow. Endpoints: - `GET /public/v1/me`: Describe this API key - `DELETE /public/v1/me`: Revoke this API key ## Describe this API key ```http GET /public/v1/me ``` The organization and project the key acts in, its scopes and when it expires. Call it to check that a key works before using it. Scope: `read`. Example: ```sh curl "https://api.pantechdynamics.com/public/v1/me" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `200`: ```json { "organization_id": "org_06gk3x9tq7m2v8r4y1c5d0zp8w", "project_id": "acc_06gk3x9tq7m2v8r4y1c5d0zp8w", "api_key": { "id": "id", "name": "web-1", "kind": "standard", "client_name": "Research assistant", "scopes": [ "read" ], "expires_at": "2026-10-03T09:30:00Z" } } ``` Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. ## Revoke this API key ```http DELETE /public/v1/me ``` Revokes the API key that sends this request, at once and for good. This is how an AI app disconnects itself and how `pantech auth logout` ends its key. Other keys are untouched. It needs no `Idempotency-Key`: once the key is revoked, repeating the call gets `401 UNAUTHENTICATED`, because the key no longer works. It works even while the organization has AI app access turned off. **Scope:** `read` (any key may revoke itself). Scope: `read`. Example: ```sh curl -X DELETE "https://api.pantechdynamics.com/public/v1/me" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Response `204`: The key is revoked. Errors: - `401`: `UNAUTHENTICATED`: the request has no API key, or the key is mistyped, revoked or expired, or its creator has left the organization. Send `Authorization: Bearer PAN_…` with a valid key; create one in the console under Organization › API keys. - `403`: `INSUFFICIENT_SCOPE`: the API key lacks the scope this endpoint needs (`read` for GET, `write` for changes; an AI app's key also needs `delete` for a destructive call). Use a key with that scope, or reconnect the AI app with "Manage and delete" access. `FORBIDDEN`: the member who created the key may not do this in the organization. Use a key created by an owner or admin. `AI_APPS_DISABLED`: the organization has turned AI app access off, and this request was made with an AI app's key or sent by Pantech's MCP server (`Pantech-Client: mcp/…`). An owner can turn it on in the console under Organization › AI apps. - `429`: `RATE_LIMITED`: this API key, verified user or organization used its request budget for the current window, or this address exceeded an IP/failed-auth limit. Wait `Retry-After` seconds, then retry. For authenticated quota denials, `RateLimit-Scope` identifies the denying quota and `RateLimit-Reset` reports its remaining window in seconds. - `503`: `SERVICE_UNAVAILABLE`: request quota storage is unavailable. Wait a few seconds, then retry. --- # Launch your first machine Source: https://docs.pantechdynamics.com/getting-started/quickstart From a new account to a running server in Africa in a few minutes. You will sign up, set up your organization, create a virtual machine in the console and connect to it over SSH. ## 1. Sign up Go to [console.pantechdynamics.com/signup](https://console.pantechdynamics.com/signup) and sign up with your work email and a password of at least 12 characters, or with Google. We email you a six-digit code: enter it on **Verify your email** to confirm the address and sign in. You can't use the account until you do. If you close the page first, the next sign-in sends a new code and asks for it before anything else. Google sign-ups skip this step. ## 2. Set up your account Once your email is verified, the console walks you through four short steps. If you stop part way, it picks up where you left off. **About you.** Your name, phone number and address. They go on your invoices and are not shown to anyone outside your organization. ![The About you step: name, phone number, gender and address](https://docs.pantechdynamics.com/images/quickstart/about-you.png) **Security.** Turn on an authenticator app so a stolen password is not enough to sign in. You can skip this and turn it on later in Profile. ![The Security step, with Set up authenticator app and Skip for now](https://docs.pantechdynamics.com/images/quickstart/security.png) **Organization.** The organization is the billing entity: it owns your balance, your projects and everything in them. Give it a name and the country it is registered in. The country sets your billing currency for good: naira for Nigeria, US dollars everywhere else. The note under the field tells you which, and how much your activation credits will be. ![The Organization step: organization name, country of registration, and the billing currency note](https://docs.pantechdynamics.com/images/quickstart/organization.png) **Funding.** Activate your account by adding **activation credits**: ₦1,000 if your organization is registered in Nigeria, or $1 anywhere else. This is not a fee, and carries no VAT. The full amount goes into your organization's credit balance and pays for your first machines, databases or invoices, and it does not expire while your account is open. Accounts billed in dollars pay by card. Accounts billed in naira pay by card or bank transfer through Flutterwave's checkout; see [paying in naira](https://docs.pantechdynamics.com/billing/paying-in-naira). Pantech never stores your card details. ![The Funding step once the account is activated](https://docs.pantechdynamics.com/images/quickstart/funding.png) When the payment goes through, the activation credits are on your balance and your account is ready. ![You're set up, with Create your first VM](https://docs.pantechdynamics.com/images/quickstart/ready.png) ## 3. Create a machine Choose **Create your first VM**. The summary beside the form shows the **Upfront payment (40%)**, what it costs **If it runs all month** and **If it’s stopped all month**, excluding [VAT](https://docs.pantechdynamics.com/billing/vat). Machines are billed by the hour, so deleting one stops the billing, and the unused part of the upfront payment goes back to your credit balance. 1. **Name and region**: name the machine, or press **Generate**. Leave the kind on **Standard VPS**, which gives the machine its own public IPv4 address. 2. **Image**: the operating system to start from. 3. **Size**: vCPUs, memory and disk. 4. **Network**: optionally a security group. Leave it empty and default networking applies. 5. **SSH key**: choose a key, or create one (below). 6. **Tags**: optional labels to find the machine by. Then press **Create VM**. The machine's page shows it provisioning, which takes about a minute. ## 4. Get an SSH key Password login is disabled on every image, so you need a key before you can connect. Creating one in the SSH key section generates it for you: name it after the computer it is for, like `ada@macbook-pro`, then press **Generate a key**. The private key downloads as a `.pem` file. It is not shown and we do not keep a copy, so save it. ```console $ mv ~/Downloads/ada-macbook-pro.pem ~/.ssh/ ``` A key is added to machines you create after it, not to ones already running. ## 5. Connect over SSH The **Connect** card on the machine's page shows its IP address. ssh refuses a key that other users can read, so lock the file down first, then sign in as the image's user: `ubuntu` on Ubuntu, `debian` on Debian (see [which user to log in as](https://docs.pantechdynamics.com/compute/ssh-keys#which-user-to-log-in-as)). The **Connect** card shows the exact command: ```console $ chmod 600 ~/.ssh/ada-macbook-pro.pem $ ssh -i ~/.ssh/ada-macbook-pro.pem ubuntu@ # debian@ on Debian Welcome to Ubuntu 24.04 LTS ``` If networking or a security group locks you out, **Open serial console** on the same card gets you in without SSH. ## Next steps ::::cards :::card[Attach block storage]{href=/storage/block-storage} Add a disk that outlives the machine. ::: :::card[Take a snapshot]{href=/compute/snapshots} A copy of the root disk to roll back to. ::: :::card[Set up a security group]{href=/networking/firewalls} Choose which ports reach the machine. ::: :::: --- # How Pantech fits together Source: https://docs.pantechdynamics.com/getting-started/concepts The handful of ideas the rest of these guides lean on, including organizations, the two kinds of virtual machine, how traffic is filtered, where data lives and how you pay. ## Organizations Everything you create belongs to an **organization**: virtual machines, volumes, networks, keys, the credit balance and the invoices. It is the billing entity, so you set it up once, the first time you sign in, with the country it is registered in. That country fixes the currency you are billed in. People join an organization by invitation and each holds one role: Owner, Admin or Viewer. See [team and roles](https://docs.pantechdynamics.com/account/team). ## Two kinds of virtual machine When you create a virtual machine you pick how it is networked. The choice is made once; a machine cannot move between kinds later. | | Standard VPS | VM in a VPC | | --- | --- | --- | | Address | Its own public IPv4 address | A private address in a subnet you choose | | Filtered by | A [security group](https://docs.pantechdynamics.com/networking/firewalls) | The subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls) | | Reachable from the internet | Yes, on the ports its security group opens | Only once you give it a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses) or forward a port to it | | Private network | None | Every VM in the VPC can reach the others | Pick a standard VPS for a single server that faces the internet. Pick a VM in a VPC when several machines need to talk privately, say an app tier and a database. A standard VPS and a VM in a VPC cannot reach each other privately, even in the same region. ## Security groups and network ACLs Both are firewalls; they apply in different places. - A **security group** is a set of inbound and outbound rules attached to a standard VPS. One group can cover many machines, and changing a rule changes all of them. Anything no rule allows is blocked. - A **network ACL** belongs to a subnet in a VPC and applies to every VM in it. Its rules are numbered and checked lowest first, can allow or deny, and are stateless. ## Disks Every virtual machine comes with a **root disk**, sized by its plan. It holds the operating system and is wiped if you rebuild the machine. A **volume** is a disk of its own that you attach to a machine. It outlives the machine it is attached to, can grow later, and never shrinks. See [volumes](https://docs.pantechdynamics.com/storage/block-storage). ## Snapshots A snapshot is a copy of a disk at a moment in time. There are two kinds: - A **VM snapshot** copies a machine's root disk. Restoring one puts the disk back and reboots the machine. - A **volume snapshot** copies a volume. Restoring one makes a new volume and leaves the original alone. Snapshots are taken by hand. There is no automatic schedule. See [backup and recovery](https://docs.pantechdynamics.com/storage/backup-and-recovery). ## Paying Your organization has a **credit balance** that you top up by card, or by bank transfer if you are billed in naira. It starts with the **activation credits** you add when you set up your account: ₦1,000 or $1, depending on the country the organization is registered in. Creating a virtual machine or database takes an upfront payment of 40% of its monthly estimate, from that credit if it covers the whole amount, or else from your saved card. The upfront payment is deducted from your next bill, not added on top. Kubernetes nodes take no upfront payment, but creating a cluster needs a saved card or enough credit to cover 40% of a month for its nodes. Usage is metered by the hour, rated through the billing period and invoiced when the period closes, with 7.5% [VAT](https://docs.pantechdynamics.com/billing/vat) added (the only payment that carries VAT) and your credit taken off the invoice before your card is charged. See [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). ## Operations take a moment Creating, starting, stopping and deleting all happen in the background. The console sends the request, shows the resource in a moving state (_Provisioning_, _Stopping_, _Rebooting_) and updates the page on its own when the platform finishes. You do not need to reload. --- # Finding your way around the console Source: https://docs.pantechdynamics.com/getting-started/the-console What is on the overview, where each kind of resource lives, and the shortcuts that save you clicks. ## The overview [console.pantechdynamics.com](https://console.pantechdynamics.com) opens on **Overview**, a summary of the organization you are signed in to: - **Running VMs**, **Compute spend**, **Idle spend** and **Uptime** across the top. - **Compute spend** over time, against the period before it. - **Needs attention**: what you are paying for and not using, such as unattached volumes and stopped virtual machines. Both are still billed; see [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). - Your virtual machines, with their status, so the most common next click is one away. ## The sidebar Resources are grouped the way the guides are: | Section | Holds | | --- | --- | | **Compute** | Virtual machines, VM snapshots and SSH keys | | **Network** | VPCs, security groups and static IPs | | **Storage** | Volumes, volume snapshots and databases | | **Team**, **Billing**, **Help & first step** | At the foot of the sidebar | Rows marked **Soon** (load balancers and object storage) are not available yet. Your account menu, at the top, holds **Profile**, **Organization**, **Notifications**, a link to the [status page](https://docs.pantechdynamics.com/reference/status-and-sla), the light, dark and system themes, and **Log out**. ## Jump anywhere with ⌘K Press **⌘K** on a Mac, or **Ctrl+K** elsewhere, to open the command palette. Type part of a destination, like "volumes", "add funds" or "invite", and press Enter. It reaches every section and the pages under them, like **Create VM** or **Payment methods**. ## Lists, filters and search Lists such as virtual machines and team members have a **Filter** menu. A filter you pick shows as a chip above the table; remove the chip to clear it. On virtual machines you can filter by status, size and image, and search by name. Filters and the open tab are kept in the page address, so you can bookmark a filtered list or send a link to a teammate and they land on the same view. Back works the way you would expect. ## Pages keep themselves current When something is changing, such as a machine booting, a snapshot being taken or a volume attaching, its page follows the change and updates without a reload. You can leave and come back; the work carries on either way. --- # Regions and zones Source: https://docs.pantechdynamics.com/getting-started/facility-and-region Where your resources run, and why the two kinds of virtual machine sit in different zones. ## One region Pantech runs one region, in Africa. Every virtual machine, disk, snapshot and network you create lives there, on our own hardware, so nothing you store leaves the region. The region is shown on your organization's page and cannot be changed. ## Zones A region is split into zones, and the kind of virtual machine decides which zone it lands in: - **Standard VPSes** run in one zone. - **VMs in a VPC**, and the VPCs themselves, run in another. You do not pick a zone directly; choosing the kind on the create form does it. The zone shows on a machine's **Networking** tab and on each volume, written like `af-abj-1`. Zones matter in two places: - **Volumes attach only within their zone.** A volume made for a standard VPS cannot be attached to a VM in a VPC. - **Some images are offered in only one zone.** The create form lists only the images that run where your machine is going. ## Public addresses are limited Public IPv4 addresses are scarce, and each zone has a fixed pool. Every standard VPS takes one, each VPC takes one for its outbound traffic, and each static IP takes another. If a zone runs out, the create form marks that kind as unavailable and says why. Sharing one address between VMs with [port forwarding](https://docs.pantechdynamics.com/networking/ip-addresses#share-an-address-with-port-forwarding) helps when machines only need a port or two. --- # Connect your AI app to Pantech Source: https://docs.pantechdynamics.com/ai/connect-your-ai Let Claude, ChatGPT, Cursor, VS Code and other AI apps look after your Pantech servers, with one link and your approval. Paste one link into your AI app, approve it in the console, and you can ask things like "what is running?" or "make me a small Ubuntu server". ```text https://mcp.pantechdynamics.com/mcp ``` ## What MCP is MCP (the Model Context Protocol) is a standard way for an AI app to use outside tools. Pantech runs an MCP server at the link above. Your AI app connects to it, and it gets a set of [Pantech tools](https://docs.pantechdynamics.com/ai/mcp-tools): list machines, create a network, check your bill, and so on. Every tool calls the [public API](https://docs.pantechdynamics.com/api) as you, with the access you approved. Nothing else is shared. ## How connecting works 1. Add the link to your AI app (the steps for each app are below). 2. The app opens your browser at the console's approval page. Sign in if you need to. 3. Choose the organization, the access level and when it expires, then press **Allow**. Connecting an app needs two-factor authentication on your account. 4. Go back to your AI app. It is connected. ### What the approval page asks - **Organization.** If you belong to more than one, pick the one this app may use. One connection works in one organization. - **Access.** - **Read only**: look at what the organization runs and what it costs, never change. Good for questions about cost and status. - **Manage** (the default, unless the app asked only to read): create, change, start and stop things, which can cost money, but never delete. - **Manage and delete**: everything Manage can, plus deleting servers, volumes, databases and their data, and anything that wipes data, such as rebuilding a machine. - **Expires after.** 7, 30 (the default) or 90 days. When it expires, the app asks you to approve it again. The page shows the app's name with the label **Not verified by Pantech**. Any app can call itself anything, so check the name and the address it will send you back to before you press **Allow**. [What the label means](https://docs.pantechdynamics.com/ai/ai-apps-security#not-verified-by-pantech). If the button is greyed out, AI apps are turned off for that organization, and the page says so. An owner can turn them on with **Turn on AI apps**. See [Turn AI apps off for the organization](https://docs.pantechdynamics.com/ai/ai-apps-security#turn-ai-apps-off-for-the-organization). ## Set up your app The same steps are in the console under **Organization › AI apps › Connect an AI app**. The id in each heading is the one `pantech mcp install` takes. See [Use the CLI to set it up](#use-the-cli-to-set-it-up). ::::cards :::card[Claude]{href=#claude-claudeai-and-claude-desktop--claude cta="Set up" icon=/images/ai/claude.svg} claude.ai and Claude Desktop. ::: :::card[ChatGPT]{href=#chatgpt--chatgpt cta="Set up" icon=/images/ai/chatgpt.svg mono} As a connector, in developer mode. ::: :::card[Claude Code]{href=#claude-code--claude-code cta="Set up" icon=/images/ai/claude-code.svg} Anthropic’s coding agent, in your terminal. ::: :::card[Cursor]{href=#cursor--cursor cta="Set up" icon=/images/ai/cursor.svg mono} One click, or a line in mcp.json. ::: :::card[VS Code]{href=#vs-code--vscode cta="Set up" icon=/images/ai/vscode.svg} GitHub Copilot’s agent mode. ::: :::card[Windsurf]{href=#windsurf--windsurf cta="Set up" icon=/images/ai/windsurf.svg mono} A line in Windsurf’s MCP config. ::: :::card[Codex]{href=#codex--codex cta="Set up" icon=/images/ai/codex.svg} OpenAI’s coding agent, in your terminal. ::: :::card[Gemini CLI]{href=#gemini-cli--gemini cta="Set up" icon=/images/ai/gemini.svg} Google’s agent, in your terminal. ::: :::card[Grok]{href=#grok-xai-api--grok cta="Set up" icon=/images/ai/grok.svg mono} Through the xAI API, with an API key. ::: :::: ### Claude (claude.ai and Claude Desktop) — `claude` 1. In Claude, open **Settings › Connectors**. 2. Choose **Add custom connector**, name it Pantech and paste `https://mcp.pantechdynamics.com/mcp`. 3. Choose **Connect**, then allow access on the Pantech page that opens. On a Team or Enterprise plan, an admin may need to add the connector first. ### ChatGPT — `chatgpt` 1. In ChatGPT, open **Settings › Connectors**. If you don't see **Create**, turn on developer mode under **Advanced**. 2. Choose **Create**, name it Pantech, paste `https://mcp.pantechdynamics.com/mcp` and choose **OAuth** for authentication. 3. Allow access on the Pantech page that opens. ### Claude Code — `claude-code` ```console $ claude mcp add --transport http pantech https://mcp.pantechdynamics.com/mcp ``` Then run `/mcp` inside Claude Code, choose **pantech** and sign in. Allow access on the Pantech page that opens. ### Cursor — `cursor` Add Pantech to `~/.cursor/mcp.json`: ```json title="~/.cursor/mcp.json" { "mcpServers": { "pantech": { "url": "https://mcp.pantechdynamics.com/mcp" } } } ``` Or open this link, which does the same: ```text cursor://anysphere.cursor-deeplink/mcp/install?name=pantech&config=eyJ1cmwiOiJodHRwczovL21jcC5wYW50ZWNoZHluYW1pY3MuY29tL21jcCJ9 ``` Then, in Cursor's MCP settings, choose **Connect** next to **pantech** and allow access on the Pantech page that opens. ### VS Code — `vscode` ```console $ code --add-mcp '{"name":"pantech","type":"http","url":"https://mcp.pantechdynamics.com/mcp"}' ``` Or open this link: ```text vscode:mcp/install?%7B%22name%22%3A%22pantech%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.pantechdynamics.com%2Fmcp%22%7D ``` Start the **pantech** server when VS Code asks, and allow access on the Pantech page that opens. ### Windsurf — `windsurf` ```json title="~/.codeium/windsurf/mcp_config.json" { "mcpServers": { "pantech": { "serverUrl": "https://mcp.pantechdynamics.com/mcp" } } } ``` Refresh Windsurf's MCP servers and allow access on the Pantech page that opens. ### Codex — `codex` ```toml title="~/.codex/config.toml" [mcp_servers.pantech] url = "https://mcp.pantechdynamics.com/mcp" ``` Then sign in, and allow access on the Pantech page that opens: ```console $ codex mcp login pantech ``` ### Gemini CLI — `gemini` ```console $ gemini mcp add --transport http pantech https://mcp.pantechdynamics.com/mcp ``` Or add it to `~/.gemini/settings.json` yourself. Gemini CLI finds the sign-in on its own: allow access on the Pantech page that opens. ```json title="~/.gemini/settings.json" { "mcpServers": { "pantech": { "httpUrl": "https://mcp.pantechdynamics.com/mcp" } } } ``` ### Grok (xAI API) — `grok` Grok doesn't sign in: it uses the xAI API's remote MCP tool, and sends a Pantech [API key](https://docs.pantechdynamics.com/getting-started/api-keys) that you create under **Organization › API keys**. Make it read only unless Grok needs to make changes. Add Pantech to the tools of your xAI API request: ```json { "tools": [ { "type": "mcp", "server_url": "https://mcp.pantechdynamics.com/mcp", "server_label": "pantech", "authorization": "Bearer PAN_…" } ] } ``` :::warning[Keep the key out of your code] Read the key from an environment variable or a secret store when you build the request. Never paste it into a prompt. ::: That key is listed under **API keys**, not under **Connected apps** on the AI apps page. Revoke it there. ## Use the CLI to set it up The [Pantech CLI](https://docs.pantechdynamics.com/cli) can write the config for you. It keeps your other MCP servers and saves the old file as `.bak`. For Claude Code and Gemini CLI it runs their own `mcp add` command (user scope) when they are installed. ```console $ pantech mcp install cursor $ pantech mcp install vscode --print # show the config, change nothing $ pantech mcp list-clients # the ids it knows $ pantech mcp status # the link, and whether pantech-mcp is installed ``` For `claude` it prints the connector steps above. For `chatgpt` and `grok` it prints instructions only, since their setup lives in the app or your request. `--url` points it at another hosted server. ## Run it locally You can also run the MCP server on your own computer. It is a program called `pantech-mcp`. In this mode there is no browser approval: it uses a key you already have. Install it from the [releases page](https://github.com/Pantech-Dynamics/pantech-mcp/releases) (macOS, Linux and Windows), or with Go: ```console $ go install github.com/Pantech-Dynamics/pantech-mcp/cmd/pantech-mcp@latest ``` ```console $ pantech-mcp stdio ``` It looks for a key in this order: 1. The `PANTECH_API_KEY` environment variable. 2. The key the [Pantech CLI](https://docs.pantechdynamics.com/cli) stored when you ran `pantech auth login`, from your system's keychain or the CLI's `credentials.json`. Set `PANTECH_PROFILE` to pick a profile (`PANTECH_CONFIG_DIR` if the CLI's settings are somewhere else). `PANTECH_API_URL` points it at another API. To point an app at the local server: ```console $ pantech mcp install claude-code --local ``` That runs `claude mcp add --scope user pantech -- stdio`. The CLI writes the full path, because apps opened from the dock or Start menu may not see your shell's `PATH`; write the full path too if you edit a config by hand (find it with `which pantech-mcp`). The same flag works for `claude` (it writes Claude Desktop's `claude_desktop_config.json`), `cursor`, `vscode`, `windsurf`, `codex` and `gemini`. By hand, the config is the same for most apps: ```json title="claude_desktop_config.json" { "mcpServers": { "pantech": { "command": "pantech-mcp", "args": ["stdio"] } } } ``` Restart the app afterwards. For VS Code, run `code --add-mcp '{"name":"pantech","type":"stdio","command":"pantech-mcp","args":["stdio"]}'`. Codex takes: ```toml title="~/.codex/config.toml" [mcp_servers.pantech] command = "pantech-mcp" args = ["stdio"] ``` If `pantech-mcp` is not on your `PATH`, `pantech mcp install --local` warns you and writes the config anyway, so install it before you restart the app. ::::cards :::card[What your AI app can do]{href=/ai/mcp-tools} Every tool, the access it needs, and which ones ask first. ::: :::card[Security and access]{href=/ai/ai-apps-security} Access levels, expiry, disconnecting, and turning AI apps off. ::: :::: --- # What your AI app can do Source: https://docs.pantechdynamics.com/ai/mcp-tools Every tool the Pantech MCP server gives your AI app, the access level each one needs, and which ones ask before they spend money or delete. Once [connected](https://docs.pantechdynamics.com/ai/connect-your-ai), your AI app picks the right tool for what you ask. You do not call tools by name. Ask "which of my servers are stopped?" and it uses `list_instances`. Each tool calls the [public API](https://docs.pantechdynamics.com/api) with the access you approved. A tool your access level does not cover fails with a clear message, and nothing changes. ## Access levels at a glance | Access level | Tools it can use | | --- | --- | | **Read only** | Every tool marked *Read* below | | **Manage** | *Read* and *Manage* tools | | **Manage and delete** | Every tool | Your access level is enforced by Pantech's API, not by the app. With **Manage**, an app can create and change things but cannot delete them: a delete fails with `INSUFFICIENT_SCOPE`. Replacing rules, resizing and scaling are changes, not deletes, so they need only **Manage**. ## It asks before it spends, opens access or deletes Some tools cost money, open a path from the internet to your servers, or delete or overwrite something. These work in two steps: 1. The first call changes nothing. It returns a preview: what will happen, the price where the API can quote one, any warnings, and a confirmation token. 2. Your AI app shows you the preview. Only when you say yes does it call the same tool again, with exactly the same details plus that token, and the change is made. The token is tied to the tool, the exact details and your connection, and it expires after 5 minutes. If anything differs, or it has expired, nothing is done and the app has to get a new preview and ask you again. The server never confirms on its own. A confirmed call that is retried is not made twice. In the tables, **Asks first** marks these tools, with the reason. ## Account, catalogue and billing | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `whoami` | Shows the key the app is using: organization, kind, access and expiry | Read | | | `list_regions` | Lists the regions and zones you can create things in | Read | | | `list_plans` | Lists machine sizes and their prices | Read | | | `list_images` | Lists the operating systems you can install | Read | | | `estimate_price` | Prices a whole setup (machines, extra storage, VPC networks, snapshots) without creating anything | Read | | | `get_usage` | Shows what you have used and what it cost | Read | | | `get_credit_balance` | Shows your credit balance | Read | | ## Machines | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_instances` | Lists your machines | Read | | | `get_instance` | Shows one machine: state, addresses, size | Read | | | `create_instance` | Creates a machine | Manage | Yes: costs money | | `start_instance` | Starts a stopped machine | Manage | | | `stop_instance` | Stops a machine | Manage | | | `reboot_instance` | Restarts a machine | Manage | | | `resize_instance` | Moves a machine to a bigger plan (it restarts) | Manage | Yes: changes the price | | `rename_instance` | Renames a machine | Manage | | | `delete_instance` | Deletes a machine. Its attached volumes are detached, not deleted | Manage and delete | Yes: deletes it | ## SSH keys | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_ssh_keys` | Lists the SSH keys installed on new machines | Read | | | `create_ssh_key` | Adds a public SSH key you already have (it never makes key pairs) | Manage | | | `change_instance_ssh_key` | Gives a machine a different SSH key from your account (it restarts) | Manage | Yes: the old key stops working | ## Networking | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_networks` | Lists your VPC networks | Read | | | `create_network` | Creates a VPC network | Manage | Yes: costs money | | `list_subnets` | Lists a network's subnets | Read | | | `create_subnet` | Adds a subnet to a network | Manage | | | `list_firewall_rules` | Lists a subnet's firewall rules | Read | | | `add_firewall_rule` | Adds a rule to a subnet's firewall | Manage | Yes: can open access | | `delete_firewall_rule` | Removes a firewall rule | Manage and delete | Yes: removes it for good | | `list_public_ips` | Lists your public IPs | Read | | | `create_public_ip` | Reserves a public IP | Manage | Yes: costs money, opens access | | `attach_public_ip` | Puts a public IP on a machine | Manage | Yes: opens access | | `detach_public_ip` | Takes a public IP off its machine, and keeps it | Manage | | | `create_port_forwarding_rule` | Forwards a port of a public IP to a machine | Manage | Yes: opens access | | `list_security_groups` | Lists security groups, the firewalls of standard machines | Read | | | `update_security_group_rules` | Replaces all of a security group's rules | Manage | Yes: overwrites the rules | | `list_load_balancers` | Lists your load balancers | Read | | | `create_load_balancer` | Creates a load balancer | Manage | Yes: costs money, opens access | ## Storage | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_volumes` | Lists your block storage volumes | Read | | | `create_volume` | Creates a volume | Manage | Yes: costs money | | `attach_volume` | Attaches a volume to a machine | Manage | | | `detach_volume` | Detaches a volume, and keeps it | Manage | | | `resize_volume` | Makes a volume bigger | Manage | Yes: changes the price | | `delete_volume` | Deletes a volume and its data (its snapshots are kept) | Manage and delete | Yes: cannot be undone | | `list_snapshots` | Lists your snapshots | Read | | | `create_snapshot` | Takes a snapshot of a machine or volume | Manage | Yes: costs money | ## Databases | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_databases` | Lists your managed databases | Read | | | `get_database` | Shows one database: state, host, port | Read | | | `create_database` | Creates a managed database (set its password in the console) | Manage | Yes: costs money | | `update_database_access_rules` | Replaces the list of who may connect | Manage | Yes: overwrites the rules | ## Kubernetes | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_kubernetes_clusters` | Lists your clusters | Read | | | `get_kubernetes_cluster` | Shows one cluster and its node pools | Read | | | `create_kubernetes_cluster` | Creates a cluster | Manage | Yes: costs money | | `scale_node_pool` | Changes how many nodes a pool has (removed nodes are drained and deleted) | Manage | Yes: changes the price | ## Webhooks and operations | Tool | What it does | Access | Asks first | | --- | --- | --- | --- | | `list_webhooks` | Lists your webhooks | Read | | | `get_operation` | Shows how a change is going | Read | | | `wait_for_operation` | Waits for a change to finish | Read | | Most changes take a little while. A tool waits up to about a minute. If the change is still running, it gives your app the operation's id, and the app follows it with `wait_for_operation`. ## Built-in prompts and the brief Some apps show these as shortcuts: - `deploy_web_app`: set up a server for a website, step by step. - `explain_my_bill`: what you are paying for, and why. - `lock_down_firewall`: find rules that are too open, and tighten them. The server also offers the [agent brief](https://docs.pantechdynamics.com/agent-brief.md) as `pantech://docs/agent-brief`, so the app can read the API's rules. ## What it can never do Some things return a secret: a password, a login or a signing key. A secret in an AI chat can end up in logs or history, so there is no tool for these. Do them yourself: | Not available | Where to do it instead | | --- | --- | | Download a cluster's kubeconfig | Console, or `pantech kubernetes kubeconfig` in the [CLI](https://docs.pantechdynamics.com/cli#kubernetes) | | Get a volume's encryption key | [Console](https://docs.pantechdynamics.com/compute/encrypted-vms) | | Open a machine's console | [Serial console](https://docs.pantechdynamics.com/compute/serial-console) in the console | | Open SSH access for your address | `pantech vm ssh` in the [CLI](https://docs.pantechdynamics.com/cli) | | Set or reset a database password | [Database passwords](https://docs.pantechdynamics.com/databases/passwords) | | Create a webhook, or rotate its secret | [Webhooks](https://docs.pantechdynamics.com/webhooks/overview) in the console or CLI | It also cannot buy credit, create or revoke API keys, invite people or change your organization's settings. Anything the tables do not list (deleting a network, a database or a cluster, for example) is done in the console, the CLI or the API. --- # AI apps and security Source: https://docs.pantechdynamics.com/ai/ai-apps-security What a connected AI app can and cannot do, how to limit it, disconnect it, or turn AI apps off for your whole organization. When you connect an AI app, the console makes it a key of its own. That key decides everything the app can do. This page explains how to keep it small, and how to take it away. ## Access levels You choose one when you approve the app. | Level | Scopes | The app can | | --- | --- | --- | | **Read only** | `read` | Look at anything. Change nothing, spend nothing | | **Manage** | `write` | Also create, change, start, stop and resize. Never delete | | **Manage and delete** | `write`, `delete` | Also delete, and run anything that wipes data, such as a rebuild | Pick the lowest level that does the job. **Read only** is enough for "what is running?" and "what did this month cost?". ### The `delete` scope `delete` exists only for AI apps. With **Manage**, an AI app cannot: - delete anything (a machine, volume, snapshot, network, rule and so on); - rebuild a machine, or restore a machine from a snapshot, since both erase what is on its disk now; - lower a snapshot schedule's number of copies kept, since that deletes the oldest ones. Restoring a snapshot to a new volume, resizing, scaling and replacing rules overwrite no data, so **Manage** can do them. It gets `403 INSUFFICIENT_SCOPE` instead, and nothing changes. A normal [API key](https://docs.pantechdynamics.com/getting-started/api-keys) with `write` can still do all of these. ## Expiry An AI app's access lasts 7, 30 (the default) or 90 days. There is no renewal in the background: when it ends, the app's calls fail with `401`, and the app asks you to approve it again. ## See and disconnect AI apps Open **Organization › AI apps**. Under **Connected apps** it lists every connected app with its name, who connected it, its access level, when it was connected, when it was last used and when it expires. AI apps do not appear under **Organization › API keys**, and keys from that page (such as the one Grok uses) do not appear here. - **Disconnect one app:** press **Disconnect** on its row, then confirm. It stops working within about 30 seconds. - **Disconnect all of them:** press **Disconnect all** (shown when you can disconnect more than one). To use an app again, connect it again. Or, to pause them all without disconnecting, turn AI apps off (below). Some apps can disconnect themselves too, for example when you remove the connector. That ends the key the same way. You can revoke an app you connected yourself. Owners and admins can revoke any of them. An organization can have up to 25 connected AI apps at once. This is separate from its 25 API keys. ## Turn AI apps off for the organization An owner can stop every AI app at once. 1. Open **Organization › AI apps**. 2. Turn off the **Allow AI apps** switch, then press **Turn off** to confirm. While it is off: - No one can connect a new AI app. The approval page explains why. - Every connected AI app gets `403 AI_APPS_DISABLED` on every call. - Anything that uses the Pantech MCP server is refused too, even with a normal API key. This includes [running it locally](https://docs.pantechdynamics.com/ai/connect-your-ai#run-it-locally). - Your scripts, CI, Terraform and the CLI keep working. Turning it off does not delete anything. The AI apps' keys are kept, and they work again when you turn it back on. An app can still disconnect itself while it is off. To end one for good, disconnect it. ## What an AI app can never do Whatever the level: - It works in one organization, the one you chose. - It can never do more than you can. If your role cannot do something, the app cannot either. - It cannot see or create passwords, kubeconfigs, signing secrets or console logins. [The full list](https://docs.pantechdynamics.com/ai/mcp-tools#what-it-can-never-do). - It cannot manage API keys, members or organization settings. - Through the Pantech MCP server, anything that costs money, opens access from the internet, or deletes or overwrites asks you first. [How](https://docs.pantechdynamics.com/ai/mcp-tools#it-asks-before-it-spends-opens-access-or-deletes). ## Be careful with what the app reads An AI app reads what tools return, and that includes text that someone else could have written: a machine's name, a webhook's description, a page it fetched from the web. Someone could hide an instruction in that text, such as "delete every server". This is called prompt injection. The Pantech server marks such text as data, not instructions, but no AI app is perfect. To stay safe: - Use **Read only** unless you need more. - Do not give **Manage and delete** to an app that also reads email, the web or files you did not write. - Read every preview before you say yes. If you did not ask for it, say no. - Use a short expiry for anything that can write. ## "Not verified by Pantech" Anyone can build an AI app and give it any name. The approval page shows the name the app gave itself, with **Not verified by Pantech** next to it. It means we have not checked who made the app. Before you press **Allow**, check: - that you started this connection yourself, just now (the page says "Only continue if you started connecting this app yourself"); - the address the page says it will send you back to. If anything looks wrong, press **Cancel**. ## Audit Every request an AI app makes is recorded against its own key, like any key's: when, from which address, what it called and how it ended. **Organization › AI apps** shows when each app was last used. If you need the full record of what an app did, [open a ticket](https://docs.pantechdynamics.com/help/support-tickets) and give the app's name, for example `Claude (AI app)`. ## Errors | Status | `code` | What it means | What to do | | --- | --- | --- | --- | | `403` | `AI_APPS_DISABLED` | AI app access is turned off for this organization | Ask an owner to turn it on under **Organization › AI apps** | | `403` | `INSUFFICIENT_SCOPE` | The app was connected without the access this needs, for example without permission to delete | Reconnect it with a higher level, such as **Manage and delete**, or do it in the console | | `401` | `UNAUTHENTICATED` | The connection expired or was revoked | Reconnect the app | More fixes, app by app: [Troubleshooting](https://docs.pantechdynamics.com/ai/troubleshooting). --- # Terraform or an AI app? Source: https://docs.pantechdynamics.com/ai/terraform-and-ai-apps When to describe your infrastructure in Terraform, when to ask an AI app, and how to use both without them getting in each other's way. Terraform and AI apps both manage the same Pantech resources, but they suit different work. Terraform is for infrastructure you keep and want to repeat. An AI app is for questions and quick tasks, by chat. ## Which should I use? | | Terraform | An AI app (Claude, ChatGPT, Cursor…) | | --- | --- | --- | | Good for | Production and staging, team setups, anything you rebuild | Questions, checking status, small changes, trying things out | | How you work | Write `.tf` files, review them, run `terraform apply` | Ask in plain words: "make me a small Ubuntu server" | | Repeatable | Yes: the same files give the same result | No: each chat is one-off | | Reviewable | Yes: changes go through git and code review | You approve each costly or destructive step in the chat | | Remembers what it made | Yes, in its state file | No | | Signs in with | An API key from **Organization › API keys** | Its own connection from **Organization › AI apps** | If you would want to rebuild it tomorrow, put it in Terraform. If you just want an answer or a quick change, ask an AI app. ## Use them together The safest way to combine them is to let the AI app **write** Terraform and let **you** apply it. ```text Write the Terraform for two small Ubuntu servers in a new private network, with port 443 open, using the pantechdynamics provider. ``` Read what it writes, then run `terraform plan` and `terraform apply` yourself. Nothing changes until you do. ## Don't let both change the same things Terraform expects to be the only thing changing what it manages. If an AI app resizes, renames or deletes a server that Terraform created, the next `terraform plan` shows the difference and `terraform apply` puts it back, or fails. - Connect AI apps that look at Terraform-managed resources with **Read only** access. They can still answer questions and spot problems. See [Access levels](https://docs.pantechdynamics.com/ai/ai-apps-security#access-levels). - Make changes to those resources in your `.tf` files, not in a chat. - If an AI app did change something by mistake, run `terraform plan` to see the difference. Then either put the change into your files, or run `terraform apply` to undo it. - Keep **Manage and delete** for AI apps that only work on resources Terraform does not manage. ## They are separate on purpose Terraform uses an ordinary API key. AI apps each get their own connection. So: - Turning AI apps off for the organization never stops Terraform. See [Turn AI apps off for the organization](https://docs.pantechdynamics.com/ai/ai-apps-security#turn-ai-apps-off-for-the-organization). - Disconnecting an AI app never touches your Terraform key. - Every request is recorded against the key that made it, so Terraform's changes can always be told apart from an AI app's. ::::cards :::card[Set up Terraform]{href=/terraform} Install the provider and describe your first server. ::: :::card[Connect your AI]{href=/ai/connect-your-ai} Add Pantech to Claude, ChatGPT, Cursor and more. ::: :::: --- # Troubleshooting AI apps Source: https://docs.pantechdynamics.com/ai/troubleshooting Fixes for the problems people hit most when connecting an AI app to Pantech. Find the message you see, then follow the fix. ## The browser did not open Some apps open the approval page only when you ask them to sign in. - **Claude:** in **Settings › Connectors**, press **Connect** on the Pantech connector. - **ChatGPT:** open the connector in **Settings › Connectors** and connect it. If you don't see **Create**, turn on developer mode under **Advanced**. - **Claude Code:** run `/mcp`, choose **pantech**, then sign in. - **Codex:** run `codex mcp login pantech`. - **Cursor:** in Cursor's MCP settings, press **Connect** next to **pantech**. - **VS Code:** start the **pantech** server when VS Code asks. - **Windsurf:** refresh Windsurf's MCP servers. - **Gemini CLI:** restart it. It finds the sign-in on its own on first use. - **Over SSH or a remote desktop:** the browser opens on the machine running the app. Sign in there, or [run it locally](https://docs.pantechdynamics.com/ai/connect-your-ai#run-it-locally) with a key. If the browser opened but showed an error and no **Allow** button, the app sent a bad request. Remove the server from the app, add it again, and try once more. ## Set up two-factor authentication to connect AI apps The approval page asks for this when your account has no second factor. An AI app acts with a key, and creating one needs two-factor authentication. Add an authenticator app on your profile, then connect the app again. ## It worked, then started failing with 401 The connection expired (after 7, 30 or 90 days), or someone revoked it. Nothing is wrong with your account. Reconnect the app: remove the Pantech server and add it again, or use the app's sign-in action above. Approve it again in the browser. For [Grok](https://docs.pantechdynamics.com/ai/connect-your-ai#grok-xai-api--grok) and [local mode](https://docs.pantechdynamics.com/ai/connect-your-ai#run-it-locally), the API key it uses expired or was revoked. Make a new one under **Organization › API keys**, or run `pantech auth login` again. ## AI_APPS_DISABLED > AI app access is turned off for this organization. An owner can turn it on in the console under Organization › AI apps. An owner turned AI apps off. Every AI app, and the Pantech MCP server even with a normal key, is refused until it is turned back on. Ask an owner of the organization. [What it does](https://docs.pantechdynamics.com/ai/ai-apps-security#turn-ai-apps-off-for-the-organization). ## INSUFFICIENT_SCOPE > This AI app was connected without permission to delete. Reconnect it with "Manage and delete" access, or do this in the console. The app's access level does not cover what you asked. A **Read only** app cannot change anything; a **Manage** app cannot delete, or rebuild or restore a machine. - Do it yourself in the console or [CLI](https://docs.pantechdynamics.com/cli), or - Reconnect the app with a higher level. [Access levels](https://docs.pantechdynamics.com/ai/ai-apps-security#access-levels). ## Too many requests (429) The app sent requests faster than its limit allows. The error says how long to wait. Wait that long, then ask again. Asking for fewer things at once helps, for example "list my machines" instead of one machine at a time. ## The app says a confirmation expired Tools that spend money, open access or delete [ask first](https://docs.pantechdynamics.com/ai/mcp-tools#it-asks-before-it-spends-opens-access-or-deletes). The confirmation token in the preview lasts 5 minutes and works only for that exact request. If it has expired, or the app changed any detail, nothing is done. Ask the app to start again, check the new preview, then say yes. ## The local server will not start - `pantech-mcp: command not found`: install `pantech-mcp`, and make sure it is on your `PATH`. `pantech mcp status` tells you if it is found. - No key found: run `pantech auth login`, or set `PANTECH_API_KEY`. - Wrong organization: set `PANTECH_PROFILE` to the CLI profile you want. ## Still stuck Every error from Pantech includes a `request_id`. Open a [support ticket](https://docs.pantechdynamics.com/help/support-tickets) and include it. --- # Create a virtual machine Source: https://docs.pantechdynamics.com/compute/virtual-machines Every choice on the create form, what happens after you press Create VM, and how to find your machines again. ## Before you start You need two things in place: - **A way to pay.** Creating a machine takes an upfront payment from your credit, or from your saved card if the credit does not cover it. See [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). - **An SSH key.** Password login is off on every image and cannot be turned on, so a key is the only way in over SSH. You can make one on the create form itself. See [SSH keys](https://docs.pantechdynamics.com/compute/ssh-keys). Go to **Virtual machines** and press **Create VM**. The form is six sections; open one at a time, and each shows a tick once it is filled in. The summary beside it reads your choices back with the price. ## 1. Name and region **VM name** must be at least three characters. **Generate** suggests one, which you can edit. Then choose the kind of machine: - **Standard VPS**: its own public IPv4 address, filtered by a security group. The right choice for a single server facing the internet. - **VM in a VPC**: a private address in a subnet you choose, filtered by the subnet's network ACL. The right choice for machines that talk to each other privately. A standard VPS and a VM in a VPC cannot reach each other privately, even in the same region, and a machine cannot change kind later. [Core concepts](https://docs.pantechdynamics.com/getting-started/concepts#two-kinds-of-virtual-machine) compares the two. If a kind is greyed out, it says why. Usually the zone has no public addresses left, or your account is still being set up there and will be ready in a minute. ## 2. Image Pick the operating system. Each distribution is one card; choosing it picks its newest version, and a row underneath lets you pick an older one. Only images that run in the zone you chose are listed. For Ubuntu 24.04 and 26.04 in zone af-abj-1, an **Encrypt disk at rest** switch under the version picker builds the VM from the encrypted image instead: its disk, snapshots and backups are encrypted on our storage, with a key of its own. It is off by default, and shown only where it is available and enabled for your organization. See [encrypted VMs](https://docs.pantechdynamics.com/compute/encrypted-vms). ## 3. Size Sizes are listed cheapest first, each with its vCPUs, memory and disk, and two monthly figures: - **Running**: an estimate for running it all month (730 hours). You are billed by the hour, so it is not a cap: a 31-day month costs a little more. - **Stopped**: what it costs if it is stopped all month. A stopped machine still holds its disk, so it pays for storage and is never free. Prices exclude [VAT](https://docs.pantechdynamics.com/billing/vat), which is added to your end-of-period invoice. The disk comes with the size and can only grow. Pick the size you need now. ## 4. Network **For a standard VPS**, choose a security group, or leave it empty and default networking applies. **Create** opens a short form without leaving the page, with three starting points: | Preset | Inbound | | --- | --- | | Web server | Ports 80 and 443 from anywhere, SSH only from your current IP | | Internal service | Nothing from the internet; reachable only from inside the private range | | Locked down | Nothing at all. The serial console still works | If the group you pick lets the whole internet reach a login port, the form warns you before you create anything. You can change the group later, but only while the machine is stopped. See [security groups](https://docs.pantechdynamics.com/networking/firewalls). **For a VM in a VPC**, choose the VPC, then one of its subnets. The machine stays in that subnet for its whole life, the subnet's network ACL applies to it, and it has no public address until you give it one. If you have no VPC yet, the form offers to make one (see [VPCs and subnets](https://docs.pantechdynamics.com/networking/private-networks)). ## 5. SSH key Choose the key to put on the machine, or **Create key** to generate one here. The new key's private half downloads as a `.pem` file, and the key is selected for you. Keep that file: we do not store it. ## 6. Tags Optional key and value labels, such as `env: production` or `role: web`. Press **Add tag**, fill in both, and press Enter. Using a key twice replaces the first value. Tags become filters on the virtual machine list. ## Create it When every required section is ticked, **Create VM** in the summary turns on. The summary shows: - **Upfront payment (40%)**: 40% of the monthly estimate, paid as soon as you press Create VM, from your credit if it covers the whole amount, otherwise from your saved card. It is deducted from your next bill, not added on top. - **If it runs all month**: an estimate for 730 hours of running. You pay for the hours it actually runs. - **If it’s stopped all month**: disk storage only. All three exclude [VAT](https://docs.pantechdynamics.com/billing/vat); the upfront payment itself carries none. Each **?** beside a figure explains it. See [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). The machine's page opens straight away and follows the build: the payment, then the build itself, then **Ready**. It usually takes about a minute. You can leave the page; the build carries on. :::warning[If the payment fails, nothing is built] The page says so and offers **Add funds**. Top up, or check your saved card, then create the machine again. You are not charged for a machine that was never built. ::: ## Find your machines **Virtual machines** lists every machine in the organization with its status, size and image, above a row of totals. From the list you can: - **Search** by name. - **Filter** by status, size, image, or a tag's environment and role. - Use a row's **⋯** menu to power a machine on or off, take a snapshot, or destroy it. - Tick several rows to power them off or destroy them together. Click a row to open the machine's own page, where everything else happens. See [managing a machine](https://docs.pantechdynamics.com/compute/managing-machines). ### What the statuses mean | Status | Meaning | | --- | --- | | Provisioning | Being built for the first time | | Running | Switched on | | Stopped | Powered off. Its disk and addresses are kept, and storage is still billed | | Starting, Stopping, Rebooting | Changing power state | | Rebuilding, Restoring | Its root disk is being replaced | | Deleting | Being removed | | Failed | The platform could not finish an operation. [Open a ticket](https://docs.pantechdynamics.com/help/support-tickets) | --- # Encrypted virtual machines Source: https://docs.pantechdynamics.com/compute/encrypted-vms A VM whose disk, snapshots and backups are encrypted on our storage, each VM with its own key. How it starts, what changes for snapshots and volumes, and who can open the data. ## What an encrypted VM is An encrypted VM is a normal virtual machine built from an encrypted image. Its root disk is encrypted at rest on our storage, and so are its snapshots and backups, because they are copies of that encrypted disk. **Each VM has its own key**, made on the VM itself the first time it starts. It is opt-in. The plain images, and every VM built from them, stay exactly as they are. You get an encrypted VM only by choosing one when you create it, and an existing VM cannot be converted: to move to encryption, create an encrypted VM and copy your data across. Encrypted images today: | Image | Version | Slug | | --- | --- | --- | | Ubuntu (encrypted) | 24.04 LTS | `ubuntu-24-04-encrypted` | | Ubuntu (encrypted) | 26.04 LTS | `ubuntu-26-04-encrypted` | Inside the VM it is the same Ubuntu as the plain image: the same users, SSH key login, package updates and kernel updates. :::info[Enabled per organization] Encrypted VMs are being rolled out one organization at a time. If you don't see the option, [open a ticket](https://docs.pantechdynamics.com/help/support-tickets) and ask for encrypted VMs to be enabled for your organization. ::: ## Where it is available Encrypted VMs run in **zone af-abj-1** as a Standard VPS, and in **zone af-abj-2** inside a VPC subnet. The create form only offers encryption where it is available. ## Create an encrypted VM On [the create form](https://docs.pantechdynamics.com/compute/virtual-machines), in **Image**, choose **Ubuntu** and the version, 24.04 or 26.04. Under the version picker, turn on **Encrypt disk at rest**. The switch is off by default, and it only appears for a version that has an encrypted image in the zone you chose. If you change the version or the image, check the switch again: it turns off when the new choice has no encrypted image. Encrypted VMs will carry an extra charge. Its price is not set yet; when it is, the summary shows it as its own line before you create the VM. From the [CLI](https://docs.pantechdynamics.com/cli) or the [API](https://docs.pantechdynamics.com/api), you can name the encrypted image's slug wherever you would use the plain one, or ask for the encrypted version of a plain image. With the CLI, `--encrypted` picks the encrypted version of `--image` for you, and stops before ordering anything if that image has none available to your organization: ```console $ pantech vm create --name vault-01 --plan starter --image ubuntu-24-04 --encrypted \ --ssh-key deploy $ pantech vm create --name vault-01 --plan starter --image ubuntu-24-04-encrypted \ --ssh-key deploy ``` With the API, send `encrypted: true` in [Create an instance](https://docs.pantechdynamics.com/api/instances) with the plain image's slug, and you get that image's encrypted version: ```json { "name": "vault-01", "plan_slug": "starter", "image_slug": "ubuntu-24-04", "encrypted": true, "ssh_key_id": "sshk_06gk3x9tq7m2v8r4y1c5d0zp8w" } ``` - `encrypted: false`, or leaving it out, creates the image exactly as named. - If the image has no encrypted version available to your organization, nothing is ordered and the request is refused with `422` and the code `ENCRYPTION_NOT_SUPPORTED` on the `encrypted` field. It is the same answer whether the image has no encrypted version or encrypted VMs are not yet enabled for you. - `encrypted: false` together with an encrypted image's own slug is refused as `ENCRYPTION_CONFLICT`: send `true`, or name the plain image. In `GET /public/v1/images`, an encrypted image has `encrypted: true`, and `encrypted_variant_of` names the plain image it is the encrypted version of (`ubuntu-24-04` for `ubuntu-24-04-encrypted`). Plain images have `encrypted: false` and `encrypted_variant_of: null`. An encrypted image only appears in the list, and can only be used, once it is enabled for your organization; until then it is refused as an unknown image. An encrypted VM shows an **Encrypted** tag on the virtual machine list and on its own page. ## How it starts **The first start takes a little longer.** On its first boot, before your system starts, the VM encrypts its root disk in place with a new key made on the VM, then ties the disk to our key servers. **The key servers never receive or store your disk's key** (see [what goes over the network](#what-goes-over-the-network-and-who-can-ask) below). The key is held only in the VM's memory while it does this, and is never written anywhere in readable form. After that the VM boots as usual and runs your user data. **Every later start unlocks by itself.** While booting, the VM asks our key servers to unlock its disk. There is nothing for you to type, and a reboot, a stop and start, or a power cycle works as on any VM. ### The key servers There are two key servers, on different hosts, and either one is enough to unlock a VM. They matter only while a VM is starting: - **A running VM does not need them.** It keeps running if both are away. - **A starting VM waits for one.** If neither answers, the VM keeps trying for 30 minutes and finishes booting by itself as soon as one returns. Until then it cannot start, and its disk stays locked. Losing one key server does not stop anything. Both being away at once delays starts, not running workloads. **Your firewall rules do not need to allow them.** A starting VM reaches the key servers over the network on TCP port 80. Pantech keeps outbound access to them open on the network of every encrypted VM, whatever firewall rules you set. You will not see this rule in your security groups or network ACLs, and you cannot remove it. It allows only the two key servers on that one port. ### What goes over the network, and who can ask The exchange with a key server is **not a delivery of your key**. Your disk's key is never sent to a key server and the key servers never store it. At boot the VM and a key server do a short calculation together (a key agreement) over plain HTTP on TCP port 80. The VM sends one blinded value, the key server answers with one computed from its own private key, and the VM combines the answer into the key by itself. Someone watching the network sees values from which the key cannot be worked out. The VM also checks the key server's signed identity against a fingerprint that is built into the image, so a fake server cannot take its place. What the key servers do **not** do is check which VM is asking. Anyone who holds a copy of a disk's header and can reach a key server can unlock that copy. That is why the key servers only answer inside Pantech's own network, and why the snapshots and backups of your disks stay on our storage. It protects you against the theft of a disk, a snapshot or retired hardware. It does not stop someone who has both a copy of your data and a position on our network. [Encrypted data volumes](#encrypted-data-volumes) add the check that is missing here: their key is released only to the instance the volume is attached to. ## Snapshots, restore and resize [VM snapshots](https://docs.pantechdynamics.com/compute/snapshots), [automatic snapshots](https://docs.pantechdynamics.com/storage/backup-and-recovery) and backups work as they do for any VM. A snapshot is a copy of the encrypted disk, so it is encrypted too. - **Restoring a snapshot** onto the same VM puts the disk back and reboots; the VM unlocks as usual. - **[Resizing](https://docs.pantechdynamics.com/compute/resizing)** works as for any VM. When the disk grows, the encrypted root grows with it on the next boot, and the key does not change. - **A new VM built from a snapshot gets its own key.** It starts with a copy of the original VM's disk, so at first it shares that VM's key. On its first boot as a new VM it re-encrypts its disk with a fresh key in the background. The VM is usable meanwhile, but **expect heavy disk activity for about 10 minutes** after that first boot. A reboot, a restore or a resize of the same VM never triggers this. ## Encrypted data volumes A [volume](https://docs.pantechdynamics.com/storage/block-storage) is **not** encrypted just because the VM it is attached to is. An encrypted VM protects its root disk. To protect a data volume, make it an **encrypted volume**. An encrypted volume works on an encrypted VM **and on a plain Ubuntu 24.04 or 26.04 VM**. It is not tied to one VM: detach it and attach it to any instance of your organization, and that instance can open it. Its key is released only to an instance of your organization that the volume is attached to, after Pantech has checked who is asking. | | Root disk of an encrypted VM | Encrypted data volume | | --- | --- | --- | | Encrypted on our storage, with its snapshots | Yes | Yes | | Unlocked by | the key servers, for any VM that can reach them | the platform, only for an instance of your organization that the volume is attached to | | Works on a plain VM | No | Yes (Ubuntu 24.04 and 26.04) | | The platform shows its state | Yes (the **Encrypted** tag) | Yes (`encryption.status`) | ### Create one 1. Make sure the VM has the agent and is enrolled. An encrypted VM already is. For a plain VM, install the agent and enroll it once (below), before you attach encrypted volumes to it. 2. Create the volume with `encrypted: true` in [Create a volume](https://docs.pantechdynamics.com/api/volumes), and attach it to that VM (send `instance_id`, or attach it afterwards). It is optional, and a plain volume is the default. 3. The volume reports `encryption.status` `requested`. **Nothing is encrypted yet.** Creating or attaching a volume does not encrypt it. 4. Within seconds of the attach, the agent formats the new, empty disk by itself, with a key from Pantech, and opens it. Only after the platform has confirmed that does the status become `encrypted`. 5. Create a filesystem on the disk and mount it (below). The agent formats a disk by itself only when the volume was created with `encrypted: true`, was not made from a snapshot, and its disk is untouched: no filesystem, no partitions and nothing written. A disk that holds anything is never formatted behind your back. A VM without the agent cannot encrypt the volume, because the format runs inside the VM. | `encryption.status` | Means | | --- | --- | | `none` | A plain volume. | | `requested` | You asked for encryption when you created it, and the disk has not been formatted yet. | | `encrypted` | The platform confirmed that the disk was formatted with a key from the platform. It never changes because you say so. | ### Encrypted VMs: nothing to install A VM built from an encrypted image already has the agent, and enrolls itself the first time it boots: when Pantech creates the VM it gives it a one-time token (not a credential), and the agent exchanges that token for the VM's credential, once. The credential is kept in a file only root can read. You do nothing. Create the volume with `encrypted: true`, attach it, and the agent formats it as soon as it appears, then you create a filesystem and mount it (below). The token is in the VM's first-boot settings until it is used or 24 hours pass, and it cannot be used twice. If an encrypted VM did not enroll (it was built from an older image, or it was rebuilt, which gives it a new disk), enroll it as a plain VM, below. ### Install the agent and enroll a plain VM A plain VM, or an encrypted one that did not enroll, needs the agent installed and enrolled by hand. The agent is `pantech-vkey`. Ubuntu 24.04 and 26.04 only, for now. Ask [support](https://docs.pantechdynamics.com/help/support-tickets) for the agent bundle until it has a download link. ```console $ sudo bash install.sh ``` Get a credential for this VM with [Issue a volume key credential](https://docs.pantechdynamics.com/api/instances). It is shown **once**: Pantech keeps only a hash of it, so it cannot be shown again. Then, on the VM, with the address and certificate Pantech gave you: ```console $ echo "pvk_YOUR_CREDENTIAL" | sudo pantech-vkey enroll --url https://HOST:PORT --ca cert.pem ``` The credential goes in on standard input, never as an argument, and is kept in a file only root can read. Asking for another credential ends the old one for that VM. A credential also ends when its instance is deleted. ### Mount it When the agent has formatted the disk it opens it as `/dev/mapper/pvk-` followed by the disk's serial. `sudo pantech-vkey list` shows it. Create a filesystem and mount it: ```console $ sudo pantech-vkey list $ sudo mkfs.ext4 /dev/mapper/pvk-edcaa549e4b340478e0c $ sudo mkdir -p /data $ sudo mount /dev/mapper/pvk-edcaa549e4b340478e0c /data ``` Use the name `list` prints instead of the example. To mount it at every boot, add this line to `/etc/fstab`: ```text /dev/mapper/pvk-edcaa549e4b340478e0c /data ext4 defaults,nofail,x-systemd.device-timeout=10min 0 2 ``` From then on the volume opens **by itself** whenever it is attached and whenever the VM boots. #### Format it yourself If you attached the volume before the agent was installed, or want to format a new, empty disk yourself, run the format step directly. The device is the disk's own, for example `/dev/vdb` (check with `lsblk`): ```console $ sudo pantech-vkey format /dev/vdb /dev/mapper/pvk-edcaa549e4b340478e0c ``` :::warning[It only formats a new, empty disk] `pantech-vkey format` turns down a disk that already holds data or a signature, so it cannot wipe your data by mistake. It cannot encrypt existing data in place. To protect data you already have, create a new encrypted volume and copy the data onto it. To stop encrypting a volume, copy its data to a plain volume. ::: ### Move, snapshot and restore - **Move it.** Unmount it, detach it, and attach it to another instance of your organization that has the agent and its own credential. It opens on attach. A volume stays on the host it was created on, so it can only move to a VM on that same host. - **Snapshot it.** A [volume snapshot](https://docs.pantechdynamics.com/storage/volume-snapshots) is a copy of the encrypted disk, so it is encrypted too. - **Restore it.** A volume made from a snapshot starts with the same key as the volume it was copied from. Before you can mount it, the agent **re-encrypts it with a new key of its own**. Nothing can mount it until that has finished, and it takes longer the larger the volume is. If the VM stops part way, the next start resumes it. ### What it needs - **The platform must be reachable when the volume is attached and when the VM boots.** If it isn't, the volume stays locked and the agent keeps trying for 30 minutes, then stops until the next boot or attach. A volume that is already open does not need it. - **Your firewall must let the VM reach the platform address and port you enrolled with.** Unlike the key servers of an encrypted VM, Pantech does not open this for you. - **The credential is a secret.** Anyone who holds it and can attach a volume to that VM can open that volume. - **Growing an encrypted volume** is not supported yet: create a larger encrypted volume and copy the data across. ### The older method: volumes opened by the key servers (encrypted VMs only) Encrypted VMs also carry an older tool, `pantech-volume`, which encrypts a new, empty volume with a key held by the key servers, like the root disk's. It works only on a VM built from an encrypted image, and the key servers do not check which VM asks. For a new volume, use the encrypted volume above. ```console $ lsblk $ sudo pantech-volume encrypt /dev/vdb /dev/mapper/pvol-3f6c1a52-8d1e-4f63-9c2a-0b7e5d4a9e11 $ sudo mkfs.ext4 /dev/mapper/pvol-3f6c1a52-8d1e-4f63-9c2a-0b7e5d4a9e11 $ sudo mkdir -p /data $ sudo mount /dev/mapper/pvol-3f6c1a52-8d1e-4f63-9c2a-0b7e5d4a9e11 /data ``` Use your volume's own device instead of `/dev/vdb`, and the name that `pantech-volume` prints instead of the example. Like `pantech-vkey`, it refuses a disk that already holds data. From then on the volume unlocks by itself on attach and at boot, `sudo pantech-volume list` shows your encrypted volumes, and `sudo pantech-volume grow pvol-…` grows the encrypted layer after you [resize the volume](https://docs.pantechdynamics.com/storage/block-storage). A volume restored from a snapshot has the same identity as the volume it was copied from, so while the original is attached to a VM the copy can't unlock on that same VM. Detach the original first, or attach the copy to a different VM. ## What Pantech can and cannot access Your VM's disks, snapshots and backups are encrypted on our storage, each VM with its own key. That protects your data on our disks, on copies of them, and on retired hardware. Be clear about what it does not do: - **Keys are released by our key servers** to a VM that can reach them. They are run by Pantech. - **We hold a recovery key.** Each disk and encrypted volume also carries an emergency recovery slot that our recovery key can open, so a disk can be recovered if the key servers are ever lost. That key is kept in a secret manager, apart from the key servers and with restricted access. - **Inside your running VM, your data is not encrypted** to anyone who can log in to it. Encryption at rest protects storage, not a running system: keep your access, SSH keys and software secure as usual. - **Deleting is not revoking.** Deleting a volume or a VM removes the data as described in [deleting a volume](https://docs.pantechdynamics.com/storage/block-storage). Revoking a credential ends what an instance can ask for, and does not erase any copy of the data. Neither one is a promise that every copy of your data is gone. - **Key rotation.** Pantech's master keys are rotated by wrapping keys again under the new key, without touching your data. An old master key is retired only when no volume still depends on it. So encryption at rest is a strong baseline for data on our storage, not a guarantee that only you can ever open it. If you need keys that only you hold, encrypt the data in your application as well. ## Card data and compliance If you store card numbers, disk encryption alone does not satisfy PCI DSS 4.0 (requirement 3.5.1.2) for card data. The card number must also be made unreadable by another mechanism, such as encryption or tokenisation in your application. Use encrypted VMs as the baseline, and add application-level encryption for card data. Your own compliance review decides what else your regulator expects. --- # Managing a virtual machine Source: https://docs.pantechdynamics.com/compute/managing-machines The machine's page, tab by tab, and how to reboot and power off, rename, rebuild from a clean image, or destroy it for good. ## The machine's page Open a machine from **Virtual machines**. Its name and status sit at the top with **Reboot**, **Power off** (or **Power on**) and **Console** beside them. Below are six tabs: | Tab | What is on it | | --- | --- | | **Overview** | The last 24 hours of CPU, memory, disk and network, and the machine's details: ID, addresses, security group, vCPUs, memory, SSH key, disk, when it was created and what it costs | | **Networking** | Its addresses and the firewall in front of it. See [security groups](https://docs.pantechdynamics.com/networking/firewalls) or, in a VPC, [network ACLs](https://docs.pantechdynamics.com/networking/network-acls) | | **Storage** | The root disk and any attached volumes, and **Attach a volume**. See [volumes](https://docs.pantechdynamics.com/storage/block-storage) | | **Snapshots** | **Take snapshot now** and the machine's snapshots. See [VM snapshots](https://docs.pantechdynamics.com/compute/snapshots) | | **Metrics** | CPU, memory, disk and network over 1 hour, 24 hours, 7 days or 30 days | | **Settings** | Rename, resize, SSH key, deletion protection, rebuild and destroy | Each action asks you to confirm first. While one is running, the status shows it (_Rebooting_, _Stopping_) and the other actions wait until it finishes. ## Reboot **Reboot** restarts the machine in place. Running processes stop and anything held only in memory is lost; the disk and its data are kept. ## Power off and on **Power off** shuts the machine down. It stops answering until you power it on again, and its disk, data and addresses are kept. A stopped machine is billed less, not nothing: its disk costs the **If it’s stopped all month** price you saw when you created it, by the hour. To stop paying for it entirely, destroy it; the unused part of its upfront payment goes back to your credit balance. **Power on** boots it again from its disk, with the same size, image and network. ## Connect The machine's address is on **Overview** and, with a copy button, on **Networking**. Connect with the key you chose when you created it. The [quickstart](https://docs.pantechdynamics.com/getting-started/quickstart#5-connect-over-ssh) shows the command. If SSH does not answer, perhaps because of a broken network setting or a security group that shuts you out, **Console** opens the [serial console](https://docs.pantechdynamics.com/compute/serial-console), which reaches the machine without the network. ## Rename On **Settings**, change the name under **Name** and press save. Renaming changes only the label; the address, ID and everything on the disk stay the same. ## Rebuild Rebuild reinstalls the operating system from a clean image. Everything on the root disk goes, including your files, databases and installed packages, and there is no undo. What survives: the machine's name, its IP address, its security group and any volumes attached to it. 1. Take a [snapshot](https://docs.pantechdynamics.com/compute/snapshots) first if there is anything on the disk you might want back. 2. On **Settings**, under **Danger zone**, press **Rebuild**. The machine must be running. 3. Keep the current image, or choose another. The dialog shows the change, like _Ubuntu 22.04 LTS → Debian 12_. 4. Type the machine's name to confirm. ## Destroy Destroying deletes the machine and its root disk permanently. There is no restore and no grace period, and nobody at Pantech can bring back the data or the address. Snapshots you have already taken are kept, and keep billing until you delete them. Destroy takes two deliberate steps: 1. On **Settings**, turn **Deletion protection** off. It is on every time you open the page. 2. Press **Destroy** and type the machine's name. You can also destroy a machine from its **⋯** menu on the list, or several at once by ticking them. :::warning[Detach volumes you want to keep] A volume attached to the machine is not something to leave to chance. [Detach it](https://docs.pantechdynamics.com/storage/block-storage#detach-a-volume) before you destroy the machine, and it stays in your account, unattached. ::: A VM in a VPC that a static IP still points at cannot be destroyed until the address is off it; the API answers `409 INSTANCE_HAS_PUBLIC_IP`. [Detach the address](https://docs.pantechdynamics.com/networking/ip-addresses#move-an-address-to-another-vm) to keep it for another machine, or release it, then destroy the VM. Port forwards to the VM have to be deleted first too (`409 INSTANCE_HAS_PORT_FORWARDS`). A VM that is one of a [load balancer](https://docs.pantechdynamics.com/networking/load-balancers)'s targets cannot be destroyed until you remove it from the targets; the API answers `409 INSTANCE_BEHIND_LOAD_BALANCER`. ## Change size The **Settings** tab's **Resize** moves the machine to a bigger size, with a reboot. See [resizing](https://docs.pantechdynamics.com/compute/resizing). ## Change SSH key The **Settings** tab's **SSH key** card moves the machine to another key on your account, with a restart of about two minutes. Afterwards only the new key logs in. See [change a machine's key](https://docs.pantechdynamics.com/compute/ssh-keys#change-a-machines-key). --- # SSH keys Source: https://docs.pantechdynamics.com/compute/ssh-keys Keys are the only way into a virtual machine over SSH. How to make one, keep the private half safe, and retire one you no longer trust. ## Why a key is required Password login is disabled on every image, and it cannot be turned on. A key is the only way in over SSH, so you need one before you create a machine. If you lose a key, you are not locked out: [give the machine another key](#change-a-machines-key) from your account, or use the [serial console](https://docs.pantechdynamics.com/compute/serial-console), which works without SSH, the network or a security group. ## Create a key 1. Go to **SSH keys** and press **Add SSH key**. You can also do this from the **SSH key** section of the [create form](https://docs.pantechdynamics.com/compute/virtual-machines#5-ssh-key). 2. Name it after the computer you will connect from, usually `you@machine` (like `ada@macbook-pro`), so you know which one to remove if that laptop goes missing. 3. Press **Generate a key**. We make the key pair for you. The private half downloads straight away as a `.pem` file named after the key, and the dialog stays open on **Save your private key**: - **Download again** if the download did not land. - **Copy to clipboard** if you would rather paste it into a file yourself. This is the only copy. We never store the private key, and once you close the dialog it is gone. Anyone who holds it can sign in to every machine the key is on, so treat it like a password. ## Put it where ssh can use it Move the file to `~/.ssh` and make it readable only by you, because ssh refuses a key that other users can read: ```console $ mv ~/Downloads/ada-macbook-pro.pem ~/.ssh/ $ chmod 600 ~/.ssh/ada-macbook-pro.pem ``` Then point ssh at it when you connect, as the image's login user: ```console $ ssh -i ~/.ssh/ada-macbook-pro.pem ubuntu@ ``` ## Which user to log in as Each image has its own login user, and your key is added to that user. Log in as it, then use `sudo` for anything that needs root: | Image | Log in as | |---|---| | Ubuntu | `ubuntu` | | Debian | `debian` | | Rocky Linux | `rocky` | So on a Debian machine the command is `ssh -i ~/.ssh/ada-macbook-pro.pem debian@`. The **Connect** card on the machine's page shows the right command for its image, and `pantech vm ssh` picks the user for you. To stop typing `-i` every time, add the machine to `~/.ssh/config`: ```text title="~/.ssh/config" Host web-03 HostName User ubuntu # debian on a Debian machine IdentityFile ~/.ssh/ada-macbook-pro.pem ``` After that, `ssh web-03` is enough. ## Allow SSH in the security group This applies to a standard VPS. A machine in a VPC subnet has no security group; its subnet's [network ACL](#allow-ssh-in-the-network-acl-vpc) decides instead, as the next section explains. A key is not enough on its own: the machine's [security group](https://docs.pantechdynamics.com/networking/firewalls) must also let SSH in. If it has no inbound rule for port 22, the connection times out before your key is ever checked. :::warning[Enable SSH on the machine's security group] Make sure the security group attached to the machine has an inbound **SSH** rule (TCP, port 22). Without it, nobody can SSH into the machine, whatever keys it holds. ::: To add the rule: 1. Open the machine's security group under **Firewalls** and edit its **inbound** rules. 2. Add a rule and set **Type** to **SSH**. The protocol (TCP) and port (22) fill in for you. 3. Choose who may connect in the **Source** field (see the table below). 4. Save. The rule applies to every machine that uses the group within seconds. The **Source** field decides who can reach port 22. Pick it by who needs to sign in: | Source | Who can connect | Use it when | | --- | --- | --- | | **My IP** | Only the address you are browsing the console from | You are the only person who signs in, from one place | | **Custom** | Only the address or block you type, like `203.0.113.0/24` for an office, or `198.51.100.7/32` for one address | A team, an office, a VPN or a bastion host needs access. Add one rule per block | | **Anywhere** | Every address on the internet (`0.0.0.0/0`) | A short test, or you cannot know where people connect from. Not for a machine you keep | Prefer **My IP** or **Custom**. **Anywhere** lets the whole internet try to sign in; your key makes password guessing useless, but scanners will still hammer port 22 and fill your logs. [Narrow SSH without locking yourself out](https://docs.pantechdynamics.com/networking/firewalls#narrow-ssh-without-locking-yourself-out) explains how to tighten a rule safely. On an **outbound** rule the same field is called **Destination**. Outbound rules do not control who can SSH in to the machine; only the inbound **Source** does. ## Allow SSH in the network ACL (VPC) A machine in a [VPC](https://docs.pantechdynamics.com/networking/private-networks) is protected by its subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls), and it applies to every machine in the subnet. A new subnet blocks every inbound connection except ping, so SSH times out before your key is ever checked until you allow it. :::warning[Enable SSH on the subnet's network ACL] Add an inbound rule to the subnet's network ACL that allows **TCP** on port **22**. Without it, nobody can SSH into any machine in that subnet, whatever keys they hold. ::: Open the subnet (from **VPCs**, open the VPC, then the subnet) or the machine's **Networking** tab, find **Network ACL**, and press **Add rule**: | Field | What to put | | --- | --- | | **Number** | From 100 to 9999, unique in the subnet, like `100` | | **Action** | **Allow** | | **Direction** | **Inbound** | | **Protocol** | **TCP** | | **Ports** | `22` | | **Source** | Your IP or the block you work from, like `203.0.113.0/24`. **Your IP** fills it in for you. `0.0.0.0/0` is anywhere, and **Anywhere** fills that in | The rule applies to every machine in the subnet within seconds. Use **Your IP** or your office block as the source where you can. **Anywhere** lets the whole internet try to sign in, which is fine for a short test but a bad idea for a machine you keep. A key makes guessing a password useless, though scanners will still hammer port 22. To reach the machine from outside the VPC it also needs a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses). The platform's built-in outbound rule lets the replies out, which matters because network ACL rules are stateless. ## Change a machine's key You can move a running machine to another key on your account, for example after losing the private key or when a laptop that held it goes missing. The machine restarts to take the new key, about two minutes of downtime. 1. Add the new key under **SSH keys** first, if it is not there yet. 2. Open the machine and go to its **Settings** tab. The **SSH key** card lists your keys, with the one the machine accepts marked **Current**. 3. Choose the new key and press **Restart with** *key name*. The machine's status shows **Changing key** while it restarts. Once it is running again: - the new key logs in, as the image's [login user](#which-user-to-log-in-as); - the previous key no longer does; - keys you added to `~/.ssh/authorized_keys` on the server yourself keep working. The machine must be running. Start a stopped machine first. The same change is available in the API as [`POST /instances/{id}/change-ssh-key`](https://docs.pantechdynamics.com/api/instances#change-an-instances-ssh-key), and in Terraform by changing `ssh_key_id` on `pantechdynamics_instance`, which updates the machine in place instead of replacing it. Adding a key to your account does not put it on any machine by itself. To give a machine an extra key while keeping the current one, sign in with a key it already trusts and append the public key to `~/.ssh/authorized_keys` yourself. ## Remove a key Open the key's **⋯** menu on **SSH keys** and choose **Remove**. Removing a key stops it being put on machines you create from now on. **It does not remove it from machines that are already running**. Anything that trusts the key goes on trusting it. If you are removing a key because the private half may have leaked, first [change the key](#change-a-machines-key) on every machine that uses it, or delete it from `~/.ssh/authorized_keys` on each one yourself. --- # The serial console Source: https://docs.pantechdynamics.com/compute/serial-console Out-of-band access to a virtual machine's screen and keyboard, for when SSH, the network or a security group won't let you in. ## When to use it The serial console connects you to the machine's own screen, below its networking. It works when SSH does not: - A firewall rule or [security group](https://docs.pantechdynamics.com/networking/firewalls) shuts out port 22. - A network setting inside the machine is broken. - The machine is stuck at boot, or waiting at a prompt. - You have lost every [SSH key](https://docs.pantechdynamics.com/compute/ssh-keys). It is free, on every account, with no jump host and no support ticket. ## Open a session The machine has to be running. 1. Open the machine from **Virtual machines**. 2. Press **Console** at the top of its page. The console opens a new session each time, on every open and every reconnect. Click the screen to type into it, then sign in with a user on the machine. ![The serial console, connected, with the toolbar above the screen and the image, machine and zone below it](https://docs.pantechdynamics.com/images/compute/serial-console.png) :::info[Set a password while you still have SSH] The console shows the machine's own login prompt, which asks for a user's password. An SSH key does not help there. So that you can sign in when you need it, set a password for your user while you can still connect over SSH, with `sudo passwd ubuntu`. ::: ## The toolbar | Control | What it does | | --- | --- | | **Ctrl+Alt+Del** | Sends the keys to the machine, which usually reboots it | | **Keys** | Escape, Tab, Ctrl+Alt+Backspace, switching terminal (Ctrl+Alt+F1–F6) and the function keys F1–F12 | | **Type text** | Types a block of text into the machine as keystrokes, with a US keyboard layout. Use it to paste a long command | | **Reconnect** | Opens a fresh session | | **Focus** | Fills the window with the screen | | **⋯** | Copy all text, fit to window or actual size, save a screenshot, full screen | ## End the session Press **End session**. If the machine is at a shell prompt, the console logs out for you. If a program is still running, it asks first: disconnecting anyway leaves that program, and the login, running on the machine. ## What is recorded Who opened a session, on which machine, and when it ended. Never your keystrokes or the screen. We can see that you used the console, but not what you did in it. --- # VM snapshots Source: https://docs.pantechdynamics.com/compute/snapshots A copy of a machine's root disk to roll back to. Take one before an upgrade, and restore it if the upgrade goes wrong. ## What a VM snapshot is A VM snapshot is a copy of a virtual machine's root disk at the moment you take it. Restoring it puts that disk back and reboots the machine; its addresses, security group and attached volumes stay as they are. A VM snapshot covers the root disk only. A [volume](https://docs.pantechdynamics.com/storage/block-storage) attached to the machine has [snapshots of its own](https://docs.pantechdynamics.com/storage/volume-snapshots). Snapshots are taken by hand. There is no daily schedule, so take one before any change you might want to undo, such as a package upgrade, a migration or a config change you are not sure of. ## Take a snapshot 1. Open the machine and go to its **Snapshots** tab. You can also use **Take snapshot** in the machine's **⋯** menu on the list. 2. Press **Take snapshot now**. 3. Name it after what you are about to do, like `pre-nginx-upgrade`, and confirm. A snapshot is ready in about 90 seconds. The tab lists it as soon as the platform records it, marked _Creating_ until the copy finishes, then _Ready_. Every snapshot stores the whole disk, not just the part in use, and is billed until it is removed. ## Restore a snapshot Restoring replaces the root disk and reboots. **Everything written to the root disk since the snapshot was taken is lost**, and it cannot be undone. Before you start: - **Take a snapshot of the current disk.** It is the only way back if the restore is not what you wanted. - **Make sure nothing is mid-write.** A database comes back as if from a power cut; stop it first if you can. - **Tell anyone who depends on the machine.** It is unreachable until it has rebooted. The machine must be running and the snapshot _Ready_. Then either: - On the machine's **Snapshots** tab, press **Restore** on the snapshot's row, or - Open the snapshot from **VM snapshots** and press **Restore**. This page lays out what changes and what stays, offers **Snapshot current disk first**, and asks you to type the machine's name. Confirm with **Restore and reboot**. The machine shows _Restoring_, then comes back running from the snapshot. ## Find your snapshots **VM snapshots** under Compute lists every machine's snapshots. Open one to see what it was taken from, its size and when it was taken. ## Removing a snapshot Open the snapshot and press **Delete** in its **Delete** card, then type its name to confirm. The copy is removed for good and can't be restored; its storage stops being billed once the platform confirms. Destroying a machine keeps its snapshots, and they keep billing until you delete them. --- # Resizing Source: https://docs.pantechdynamics.com/compute/resizing Giving a virtual machine or a volume more room. Both only grow. ## Virtual machines A machine's **Settings** tab lists every size under **Resize**, with the current one marked. Choose a bigger one, check the cost after the change, and press **Resize to …**. - **It needs a reboot**, with about 90 seconds of downtime. - **The disk only grows.** Sizes with a smaller disk than the current one are shown but can't be chosen. - **The machine must be running**, with no other change in progress. There is no upfront payment: the machine is billed by the hour at its new size from the moment the resize completes. ## Volumes On a volume's page, press **Resize** and choose the new size. A volume on a custom-size offering grows in 10 GB steps; one on a fixed size moves to a bigger fixed size, or to a custom size of the same storage type. The volume must be ready, with no other change in progress. Growing takes a short while, with no downtime, and can't be undone: a volume never shrinks, so the size you create is the smallest it can ever be (see [choosing a size](https://docs.pantechdynamics.com/storage/block-storage#choose-a-size)). The bigger size is billed for as long as the volume exists. The disk grows, but the filesystem on it does not. Extend it from inside the machine afterwards. For an XFS filesystem mounted at `/data`: ```console $ sudo xfs_growfs /data ``` For ext4 on `/dev/vdb`: ```console $ sudo resize2fs /dev/vdb ``` --- # Volumes Source: https://docs.pantechdynamics.com/storage/block-storage Disks that live on their own. Create one, attach it to a machine, format and mount it, and detach or delete it when you are done. ## What a volume is for Every virtual machine already has a root disk, sized by its plan. A volume is a separate disk for data you want to keep when the machine under it goes: a database's files, uploads, backups of your own. - It attaches to one running machine at a time, in the same zone. - A machine holds up to eight volumes. - It is billed from the moment it exists until you delete it, **attached or not**. - It can never shrink. ## Choose a size Go to **Volumes** and press **Create volume**. Pick one of the listed sizes (each shows its monthly and hourly price and the kind of storage), or **Custom size** and type any whole number of gigabytes up to 2 TB. A custom size is billed per GB-month, prorated by the hour, and the form prices it as you type. Prices exclude [VAT](https://docs.pantechdynamics.com/billing/vat), which is added to your end-of-period invoice. :::warning[Pick the size you need now, not the size you might need] A volume never shrinks. Starting bigger because it feels safer locks in that monthly price for as long as the volume exists. ::: ## Where a volume lives A volume is stored on the local disks of one host: the host that runs the virtual machine it is first attached to. Two things follow: - **It has no copy on another host.** Protect data you cannot lose with [volume snapshots](https://docs.pantechdynamics.com/storage/volume-snapshots), the same as for any single disk. - **Moving it to a machine on another host is automatic, and takes longer.** When you attach a volume to a machine that runs on a different host, Pantech first moves the volume's disk to that host, then attaches it. You do nothing: the volume shows **Attaching** until it is done. The move copies the data between hosts, so a volume holding a lot of data takes longer to attach. The volume must be detached to move, and it cannot move between regions. You cannot choose or see which host a machine runs on, and you don't need to. If a move cannot be completed, for example because the other host has no room, the attach fails and the volume stays unattached; try again later or create a new volume on that machine and copy the data over. ## Create one Under **Name and attachment**: 1. **Volume name**: lowercase letters, digits and hyphens, starting with a letter or digit, like `db-primary-wal`. 2. **Attach to**: a running virtual machine, or **None** to create it unattached. Only running machines are listed. The summary shows the size, the monthly and hourly price, and when billing starts. Press **Create and attach**, or **Create volume** if you left it unattached. A volume is ready in about 20 seconds, with no reboot. Its page follows the work, making the disk and then attaching it if you asked, and fills in when it is done. ## Attach a volume To attach a volume you made earlier: 1. Open the machine and go to its **Storage** tab. The machine must be running. 2. Under **Attach a volume**, choose a free volume from the same zone. 3. Press **Attach**. It takes under a minute. ## Format and mount it The machine gets a raw disk. Nothing formats or mounts it for you. Connect over SSH and run the commands for your machine's image: ::::tabs :::tab[Ubuntu / Debian] ```console $ lsblk $ sudo mkfs.ext4 /dev/vdb $ sudo mkdir -p /data $ sudo mount /dev/vdb /data $ echo "UUID=$(sudo blkid -s UUID -o value /dev/vdb) /data ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab ``` ext4 is the default filesystem on Ubuntu and Debian, and its tools come installed. ::: :::tab[Rocky / Oracle Linux] ```console $ lsblk $ sudo mkfs.xfs /dev/vdb $ sudo mkdir -p /data $ sudo mount /dev/vdb /data $ sudo restorecon -R /data $ echo "UUID=$(sudo blkid -s UUID -o value /dev/vdb) /data xfs defaults,nofail 0 0" | sudo tee -a /etc/fstab ``` XFS is the default filesystem on Rocky Linux and Oracle Linux, and its tools come installed. The last field in the `/etc/fstab` line is `0` because XFS is not checked at boot. SELinux is on by default, and `restorecon` gives the new mount the labels services expect. If you mount the volume where a service reads its files, like `/var/www` or `/var/lib/mysql`, run `restorecon -R` on that path instead. ::: :::tab[openSUSE] ```console $ lsblk $ sudo mkfs.ext4 /dev/vdb $ sudo mkdir -p /data $ sudo mount /dev/vdb /data $ echo "UUID=$(sudo blkid -s UUID -o value /dev/vdb) /data ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab ``` If `mkfs.ext4` is not found, install it with `sudo zypper install e2fsprogs` and run it again. ::: :::: What each step does: 1. `lsblk` lists the disks. The first volume is usually `/dev/vdb`, the next `/dev/vdc`, and so on. Use the name it shows in the commands after it. 2. `mkfs` formats the volume. **This erases the disk**, so run it only on a new, empty volume, and never on one you have reattached with data on it. 3. `mount` makes it available at `/data`. Pick another path if you like. 4. The `/etc/fstab` line mounts it again after every reboot. It names the disk by its UUID rather than `/dev/vdb`, because device names can change as you attach more volumes and a UUID cannot. `nofail` lets the machine boot even if the volume is detached. Before you rely on the `/etc/fstab` line, check it works: ```console $ sudo umount /data $ sudo mount -a $ df -h /data ``` If `mount -a` reports an error, fix the line in `/etc/fstab` before you reboot. ## Encrypted volumes A volume is **not** encrypted by default, and attaching one to an [encrypted VM](https://docs.pantechdynamics.com/compute/encrypted-vms) does not encrypt it: only that VM's root disk is. If you need a data volume encrypted, create it as an **encrypted volume** (`encrypted: true` in [Create a volume](https://docs.pantechdynamics.com/api/volumes)) and format it with the Pantech agent. An encrypted volume works with an encrypted VM and with a plain Ubuntu 24.04 or 26.04 VM, and it can be detached and attached to any instance of your organization. The volume shows its real state as `encryption.status`: `none`, `requested` until it has been formatted, and `encrypted` once Pantech has confirmed it. See [Encrypted data volumes](https://docs.pantechdynamics.com/compute/encrypted-vms#encrypted-data-volumes) for the steps, what is and is not protected, and how to move existing data onto one. ## The volume's page Open a volume from **Volumes** to see: - **Attachment**: the machine it is on, if any. - **Capacity**: how full it is, and its read and write throughput over the last 24 hours. These appear once the machine reports them. - **Snapshots of this volume**, with **Take snapshot**. See [volume snapshots](https://docs.pantechdynamics.com/storage/volume-snapshots). - Its size, zone and monthly cost. The **Volumes** list shows every volume with its status, what it is attached to, size, how full it is and its cost. ## Detach a volume Unmount it inside the machine first, or anything still using the disk loses it mid-write: ```console $ sudo umount /data ``` Remove its line from `/etc/fstab` too. Then on the volume's page, under **Detach or delete**, press **Detach**. The data stays on the volume, and it is still billed. ## Delete a volume A volume must be detached before it can be deleted. Then press **Delete**, and type the volume's name to confirm. Deleting removes the volume and everything written to it. There is no undo. [Take a snapshot](https://docs.pantechdynamics.com/storage/volume-snapshots) first if you might want the data back. ## Growing a volume Press **Resize** on the volume's page and choose a bigger size. Then extend the filesystem from inside the machine. See [resizing](https://docs.pantechdynamics.com/compute/resizing#volumes). --- # Volume snapshots Source: https://docs.pantechdynamics.com/storage/volume-snapshots A copy of a volume at a moment in time. Restoring one makes a new volume and leaves the original as it is. ## What a volume snapshot is A volume snapshot is a read-only copy of a [volume](https://docs.pantechdynamics.com/storage/block-storage) at the moment you take it. Copies of a machine's root disk are separate; see [VM snapshots](https://docs.pantechdynamics.com/compute/snapshots). - **Full-size, not incremental.** Every snapshot stores the whole volume, not just the part in use. - **Billed until it is removed.** Snapshots are not in the price list yet; we would rather say so than imply they are free. - **Taken by hand.** There is no schedule. ## Take a snapshot From **Snapshots** under Storage, press **Take snapshot** and choose the volume. Or open the volume and press **Take snapshot** under **Snapshots of this volume**. Name it after why you are taking it, and confirm. It is ready in about 90 seconds; until then its page shows _Still being taken_ and follows the copy. For a consistent copy of a busy volume, such as a database's data directory, stop writes or flush them before you take it. ## Restore a snapshot Restoring never touches the original volume. It makes a new one: 1. Open the snapshot from **Snapshots**. 2. Press **Restore**. 3. Give the **New volume name** and confirm. The new volume is the same kind and size as the original, and starts **unattached**, so nothing on any machine changes until you [attach it](https://docs.pantechdynamics.com/storage/block-storage#attach-a-volume). From there you can mount it alongside the original and copy back only what you need. The new volume is billed like any other from the moment it exists. ## Removing a snapshot Open the snapshot and press **Delete** in its **Delete** card, then type its name to confirm. The copy is removed for good and can't be restored; its storage stops being billed once the platform confirms. --- # Backup and recovery Source: https://docs.pantechdynamics.com/storage/backup-and-recovery What Pantech keeps for you, what it does not, and a routine that covers the gap. ## What exists today | | Covers | Restoring it | | --- | --- | --- | | [VM snapshot](https://docs.pantechdynamics.com/compute/snapshots) | A machine's root disk | Replaces the root disk and reboots the machine | | [Volume snapshot](https://docs.pantechdynamics.com/storage/volume-snapshots) | One volume | Makes a new volume beside the original | Both are taken by hand. **There are no scheduled backups**: nothing is copied for you daily or weekly, and a machine's **Snapshots** tab says so. If you need copies on a schedule, take them yourself, as below. ## Snapshots are not off-site A snapshot lives in the same region as the disk it copies, on our hardware. It protects you from your own mistakes, like a bad upgrade or a deleted table, but it is not a second copy somewhere else. If you need an off-site copy, pull one down yourself. ## Losing a machine does not lose its snapshots Destroying a virtual machine keeps the snapshots you took of it, and they keep billing until they are removed. ## A routine that works 1. **Keep data on volumes, not the root disk.** A root disk is wiped by a rebuild and goes with the machine. A volume outlives both, and you can snapshot it on its own. 2. **Snapshot before every change you might undo.** An OS upgrade, a schema migration, a config change you are not sure of. Name the snapshot after the change, like `pre-postgres-16`. 3. **Back up your data from inside the machine too.** `pg_dump`, `mysqldump`, or a copy of the files you care about, sent off the platform on a schedule with `cron` and a tool such as `rsync` or `restic`. This is what gets you back if the region itself is the problem. 4. **Test a restore now and then.** Restore a volume snapshot to a new volume, mount it, and check the files are there. ## Recovering | What went wrong | What to do | | --- | --- | | A change broke the machine | [Restore its VM snapshot](https://docs.pantechdynamics.com/compute/snapshots#restore-a-snapshot) | | Files on a volume were deleted or damaged | [Restore the volume snapshot](https://docs.pantechdynamics.com/storage/volume-snapshots#restore-a-snapshot) to a new volume, attach it, and copy the files back | | You cannot reach the machine at all | Use the [serial console](https://docs.pantechdynamics.com/compute/serial-console) to get in and fix it | | Nothing above helps | [Open a ticket](https://docs.pantechdynamics.com/help/support-tickets), marked _Production down_ if customers are affected | --- # Managed databases Source: https://docs.pantechdynamics.com/databases/overview PostgreSQL, MySQL and MariaDB that we install, patch and keep private, on a plan you choose and billed like a virtual machine. ## What a managed database is A managed database is a single PostgreSQL, MySQL or MariaDB server that we run for you. You choose the engine, the version and a size; we install it, keep it patched, give it a stable hostname and apply the access rules you set. You connect to it with your usual client, as the admin login you named. - **One server, one disk.** Each database runs on its own virtual machine, sized by a [plan](https://docs.pantechdynamics.com/compute/virtual-machines): the plan's vCPUs and memory, and a data disk that starts at the plan's disk size. Choose a bigger disk at create, and [grow it later](https://docs.pantechdynamics.com/databases/storage) without a restart. There is no replica or failover yet. - **Private only.** A database never has a public address. It is reached from your own network, from the addresses its [access rules and security groups](https://docs.pantechdynamics.com/databases/access-rules) allow: from a VM in its VPC in `af-abj-2`, or from a [standard VPS with a private interface](https://docs.pantechdynamics.com/databases/standard-vps) in `af-abj-1`. - **No shell.** There is no SSH or console login to a database's machine. Everything you change, you change through the API: [access rules and security groups](https://docs.pantechdynamics.com/databases/access-rules), the [password](https://docs.pantechdynamics.com/databases/passwords), [storage](https://docs.pantechdynamics.com/databases/storage), [snapshots](https://docs.pantechdynamics.com/databases/snapshots), power and deletion. - **Billed like a machine.** A database costs what a VM in a VPC on the same plan costs, plus any storage beyond the plan's disk, with no management fee. It is [paid for first](https://docs.pantechdynamics.com/databases/orders). See [pricing](https://docs.pantechdynamics.com/databases/pricing). :::info[Where to manage databases] Databases are in the [public API](https://docs.pantechdynamics.com/api/databases), in the [CLI](https://docs.pantechdynamics.com/cli#databases) as `pantech db`, and on the console's **Databases** page. The `pantechdynamics_database` [Terraform](https://docs.pantechdynamics.com/terraform#databases) resource comes with the provider's next release. ::: ## Engines and versions Only long-term-support lines are offered, and a new major version of PostgreSQL is added once it has had its first minor release. | Engine | Versions | Port | | --- | --- | --- | | PostgreSQL | 18 | `5432` | | MySQL | 8.4 LTS, 9.7 LTS | `3306` | | MariaDB | 11.4 LTS, 11.8 LTS | `3306` | You choose the version line; we apply its minor releases for you (see [patching](https://docs.pantechdynamics.com/databases/patching)). A line is withdrawn from new databases at least six months before its upstream end of life, and moving to a new major version is never automatic. What is on offer right now, and in which zone, is in the catalogue. Each version lists its end-of-life date and the zones it can be created in: ```console $ curl https://api.pantechdynamics.com/public/v1/database-engines \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` A version that is not listed there, or not listed for your zone, cannot be created yet. ## The admin login Each database has one admin login, named when you create it (`dbadmin` unless you choose another; a database created before the name could be chosen has `pantech_admin`). Its `admin_username` field always says which. It is deliberately **not** a superuser, so it cannot run commands on the machine or change the access configuration behind the access rules. | Engine | The admin login can | | --- | --- | | PostgreSQL | Create databases and roles, and read and write all data. A database called `defaultdb`, owned by the admin login, is created for you. | | MySQL | Create databases and users, and grant them privileges. It cannot change the `mysql` system schema. | | MariaDB | Create databases and work with all data. It is the database's only login: MariaDB cannot safely let it create further users. | ## Lifecycle A database is `running`, `stopped` or deleted. Stop and start it from its page in the console, the API or the CLI, like a machine; a stopped database keeps its data and still pays for its disk. To delete one in the console, turn **Deletion protection** off in its **Danger zone**, press **Delete** and type its name. Its data disk is destroyed 12 hours later, and nothing can bring it back after that. Deleting a database removes its hostname first, then its machine and data disk. Billing stops, and the part of its upfront payment not yet deducted from a bill goes back to your credit balance; see [pricing](https://docs.pantechdynamics.com/databases/pricing#while-stopped-and-after-deletion). Take a [dump](https://docs.pantechdynamics.com/databases/backups) first if you might need the data. Its [snapshots](https://docs.pantechdynamics.com/databases/snapshots) are not deleted with it: they stay, and stay billed, until you delete them. ## Limits | | | | --- | --- | | Nodes per database | 1 (no replicas or failover yet) | | Access rules per database | 50 | | Security groups attached to a database | 5 | | Ranges in the applied allow-list, rules and groups together | 1,024 | | Widest access rule | a `/8`; never `0.0.0.0/0` | | Admin login | 3 to 32 characters: lowercase letters, digits and underscores, starting with a letter | | Admin password | 16 to 128 printable characters, no spaces, quotes or backslashes | | vCPUs and memory | Fixed by the plan; moving to a bigger plan is not available yet | | Data disk | The plan's size up to 2,000 GB, in 10 GB steps. Grows, never shrinks. See [storage](https://docs.pantechdynamics.com/databases/storage) | | Network | IPv4 and private addresses only | | Backups | No automatic backups yet. Take [dumps](https://docs.pantechdynamics.com/databases/backups) and [snapshots](https://docs.pantechdynamics.com/databases/snapshots); restoring a snapshot is not available yet | | Unpaid order | Expires after one hour; see [orders](https://docs.pantechdynamics.com/databases/orders) | ## Next ::::cards :::card[Create a database]{href=/databases/create} Choose the engine, plan, storage and zone, and pay for it like a machine. ::: :::card[Connect to it]{href=/databases/connecting} The hostname, where it resolves, and where it can be reached from. ::: :::card[From a standard VPS]{href=/databases/standard-vps} Give a VPS in af-abj-1 a private interface on the database network. ::: :::card[Error codes]{href=/databases/errors} Every code the database endpoints return, and what to do. ::: :::: --- # Create a database Source: https://docs.pantechdynamics.com/databases/create Choose an engine, a version, a plan and where it lives, then follow the order until the database is ready. ## Before you start You need: - For the API, a write [API key](https://docs.pantechdynamics.com/getting-started/api-keys), in `PANTECH_API_KEY`. - Credit or a saved card: a database is [paid for first](https://docs.pantechdynamics.com/databases/pricing), like a machine. - For a database in a VPC, the **subnet** it should live in. See [VPCs and subnets](https://docs.pantechdynamics.com/networking/private-networks). Then choose: | | Where it comes from | | --- | --- | | `engine` and `version` | [`GET /database-engines`](https://docs.pantechdynamics.com/api/databases#list-database-engines): `postgresql` `18`, `mysql` `8.4`, and so on | | `plan_slug` | [`GET /plans`](https://docs.pantechdynamics.com/api/catalogue#list-plans): sets vCPUs and memory, and the data disk's base size (`disk_gb`) | | `storage_gb` | Optional. A bigger data disk than the plan's, in 10 GB steps up to 2,000 GB. See [storage](https://docs.pantechdynamics.com/databases/storage) | | `zone_id` | `af-abj-2` for a database in a VPC, with its `subnet_id`; `af-abj-1` for one your [standard VPSes](https://docs.pantechdynamics.com/databases/standard-vps) reach over a private interface, with no `subnet_id` | | `admin_username` | Optional. Defaults to `dbadmin` | | `password` | Optional. Leave it out to have one generated and shown once | ## In the console On **Databases**, press **Create database**, then fill in each section: 1. **Name and location**: the name, and where it lives. **Standard network** puts it on the region's private database network, for your [standard VPSes](https://docs.pantechdynamics.com/databases/standard-vps) and the ranges you allow: nothing can reach it until you add an [access rule](https://docs.pantechdynamics.com/databases/access-rules). **In a VPC** puts it in a subnet you choose, and lets the VPC's own range in. 2. **Engine**: PostgreSQL, MySQL or MariaDB, and the version. 3. **Size**: the plan, which sets vCPUs, memory and the data disk's base size. Add more storage in steps; see [storage](https://docs.pantechdynamics.com/databases/storage). 4. **Network**: for a database in a VPC, the VPC and the subnet. 5. **Admin login**: the username (`dbadmin` unless you change it), and either **Generate one** for the password or **I'll choose one** (at least 16 characters). The summary shows the **Upfront payment (40%)**, what it costs **If it runs all month** and **If it’s stopped all month**; see [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). They exclude [VAT](https://docs.pantechdynamics.com/billing/vat), and the upfront payment carries none. If the build fails, the upfront payment goes back to your credit balance, and if you delete the database later, the unused part does too. Press **Create database**. A generated password is shown once, under **Save the admin password**, with the username. We don't store it and support can't read it back: copy both, tick that you've saved them, then continue. If you lose it, [reset it](https://docs.pantechdynamics.com/databases/passwords#reset-it) from the database's page. The database's page then follows the [order](https://docs.pantechdynamics.com/databases/orders) until the database is running. ## With the API ```console $ curl https://api.pantechdynamics.com/public/v1/databases \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "orders-db", "engine": "postgresql", "version": "18", "plan_slug": "starter", "zone_id": "af-abj-2", "subnet_id": "snet_…" }' ``` The answer is `202 Accepted` with an order: ```json { "order_id": "ord_…", "database_id": "db_…", "status": "awaiting_payment", "amount_minor": 1059200, "currency": "NGN", "admin_username": "dbadmin", "password_returned": true, "password": "q8Vn3TzK1mXw7RbLc4Hs9JdP2fYa6GeU" } ``` :::warning[Save the password now] Because you did not send a `password`, one was generated and is in this response, **and nowhere else**. It is never stored, and it cannot be read again. Put it in your secret store before you do anything else. If it is lost, [reset it](https://docs.pantechdynamics.com/databases/passwords#reset-it). A retry with the same `Idempotency-Key` returns the same order with `password_returned: false` and no password. If the first response never reached you, wait for the database and reset the password. ::: To choose the password yourself, send `password` (16 to 128 printable characters, no spaces, quotes or backslashes). It is then never returned. Other optional fields: - `admin_username`: the admin login's name, 3 to 32 lowercase letters, digits and underscores, starting with a letter. Reserved names are refused with `INVALID_DATABASE_ADMIN_USERNAME`: `root`, `postgres`, `public`, `none`, `all`, `user`, `sys`, `system`, `replication`, `current_user`, `current_role`, `session_user`, and any name starting with `pg_`, `mysql`, `mariadb` or `pantech`. - `access_rules`: the starting allow-list, as a list of CIDRs. Leave it out for the default (your VPC's whole range in `af-abj-2`, nothing in `af-abj-1`), or send `[]` for none. See [access rules](https://docs.pantechdynamics.com/databases/access-rules). - `storage_gb`: the data disk's size in GB, if you want more than the plan's `disk_gb`. Above the plan's size it must be a multiple of 10, up to 2,000. The upfront payment is priced for it. You can [grow it later](https://docs.pantechdynamics.com/databases/storage#grow-it-later), but never shrink it. ### In `af-abj-1` Leave out `subnet_id`. The database starts with no access rules, so nothing can connect until you add one. The usual one is a VPS's `private_network_ip` as a `/32`, after you have [given the VPS a private interface](https://docs.pantechdynamics.com/databases/standard-vps): ```console $ curl https://api.pantechdynamics.com/public/v1/databases \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "orders-db", "engine": "mysql", "version": "8.4", "plan_slug": "starter", "zone_id": "af-abj-1", "storage_gb": 60, "access_rules": ["10.250.4.23/32"] }' ``` ## Follow the order The order takes the payment, then builds the database. An order not paid within **one hour** expires (`payment_failed`, `payment_expired`) and nothing is built. [Database orders](https://docs.pantechdynamics.com/databases/orders) explains each status and failure. Poll it every few seconds: ```console $ curl https://api.pantechdynamics.com/public/v1/database-orders/ord_… \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` | `status` | Meaning | | --- | --- | | `awaiting_payment` | Taking the first payment, from credit or else your default card | | `paid` | Paid; about to be built | | `provisioning` | Being built. `operation_id` is set, and you can follow it too | | `provisioned` | Ready: read the database | | `payment_failed` | Not paid (`failure_code` says why: `insufficient_credit`, `card_declined` or `payment_expired`). Nothing was built | | `failed` | Paid but not built. The payment goes back to your credit | Then read the database for its hostname: ```console $ curl https://api.pantechdynamics.com/public/v1/databases/db_… \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` When `observed_state` is `running` and `hostname` is set, [connect to it](https://docs.pantechdynamics.com/databases/connecting). ## With the CLI `pantech db create` places the order, prints a generated password once, and waits until the database is running: ```console $ pantech db create --name orders-db --engine postgresql --version 18 --plan starter \ --zone af-abj-2 --subnet web --storage-gb 100 ``` Pass `--password-stdin` to choose the password instead; it is read from a hidden prompt or a pipe, never from the command line. Leave out `--storage-gb` for the plan's own disk size. See the [CLI](https://docs.pantechdynamics.com/cli#databases) for every `db` command. ## With Terraform In the provider's next release: ```hcl resource "pantechdynamics_database" "orders" { name = "orders-db" engine = "postgresql" version = "18" plan_slug = "starter" zone_id = "af-abj-2" subnet_id = pantechdynamics_subnet.data.id storage_gb = 100 # Write-only: never saved in the plan or the state. password_wo = var.orders_db_password password_wo_version = 1 } ``` The provider places the order, waits for it, and reads back `hostname`, `port` and `private_ip`. The password is a write-only attribute, so it never lands in your Terraform state. See [Terraform](https://docs.pantechdynamics.com/terraform#databases). ## If it is refused The codes a create can return, field by field, are in [database error codes](https://docs.pantechdynamics.com/databases/errors#creating-a-database). --- # Database orders Source: https://docs.pantechdynamics.com/databases/orders Creating a database places an order that is paid before anything is built. Follow it from payment to a running database, and know what each status and failure means. ## Paid first, then built A database is paid for before it is built, exactly like a [virtual machine](https://docs.pantechdynamics.com/billing/how-billing-works#virtual-machines). `POST /databases` does not build anything itself: it records an **order** and answers `202 Accepted` with it. Then, in the background: 1. The order takes the **upfront payment**, `amount_minor`: from your credit if there is enough, otherwise from your organization's default card. 2. Once paid, the database is built. The order's `operation_id` is set, and you can follow that [operation](https://docs.pantechdynamics.com/api/operations) too. 3. The order ends `provisioned` when the database is running. The amount is what a VM in a VPC on the same plan costs, with the data disk at the [size you chose](https://docs.pantechdynamics.com/databases/storage). See [pricing](https://docs.pantechdynamics.com/databases/pricing). ## Place one ```console $ curl https://api.pantechdynamics.com/public/v1/databases \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name": "orders-db", "engine": "postgresql", "version": "18", "plan_slug": "starter", "zone_id": "af-abj-2", "subnet_id": "snet_…"}' ``` ```json { "order_id": "ord_…", "database_id": "db_…", "resource_id": "db_…", "status": "awaiting_payment", "operation_id": null, "failure_code": null, "amount_minor": 1059200, "currency": "NGN", "admin_username": "dbadmin", "password_returned": true, "password": "q8Vn3TzK1mXw7RbLc4Hs9JdP2fYa6GeU" } ``` `amount_minor` is in kobo (or cents for `USD`): `1059200` is NGN 10,592.00. `database_id` is the id the database will have; you can read it straight away. The response's `Location` header is the order's URL. If a password was generated, it is in this response and nowhere else: see [passwords](https://docs.pantechdynamics.com/databases/passwords). If there is not enough credit and no card to fall back on, the request itself is refused with `402 INSUFFICIENT_CREDIT` and no order is made. ## Follow it ```console $ curl https://api.pantechdynamics.com/public/v1/database-orders/ord_… \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Poll every few seconds until `status` is final: | `status` | Meaning | Final? | | --- | --- | --- | | `awaiting_payment` | Taking the payment, from credit or else your default card | | | `paid` | Paid; about to be built | | | `provisioning` | Being built. `operation_id` is set | | | `provisioned` | Built. Read the database for its hostname | Yes | | `payment_failed` | Not paid, so nothing was built | Yes | | `failed` | Paid but not built. The payment goes back to your credit | Yes | A small script that waits: ```bash order=ord_… while :; do status=$(curl -s "https://api.pantechdynamics.com/public/v1/database-orders/$order" \ -H "Authorization: Bearer $PANTECH_API_KEY" | jq -r .status) echo "$status" case "$status" in provisioned|failed|payment_failed) break ;; esac sleep 5 done ``` `provisioned` means the build has finished. Then read the database, and wait for `observed_state` `running` and a `hostname` before you [connect](https://docs.pantechdynamics.com/databases/connecting). ## An unpaid order expires after an hour An order still `awaiting_payment` **one hour** after it was placed ends `payment_failed` with `failure_code` `payment_expired`, and nothing is built. That usually means a card payment that needed you to act and never completed. Top up your credit or fix your card, then create the database again with a **new** `Idempotency-Key`. ## Why an order failed `failure_code` says why an order ended `payment_failed` or `failed`: | `failure_code` | Status | What happened | What to do | | --- | --- | --- | --- | | `insufficient_credit` | `payment_failed` | Not enough credit, and no card to fall back on | [Add funds](https://docs.pantechdynamics.com/billing/adding-funds) and order again | | `card_declined` | `payment_failed` | Your default card was declined | Use another card, or add credit | | `payment_expired` | `payment_failed` | Still unpaid an hour after it was placed | Order again | | `database_name_taken` | `failed` | Another database took the name meanwhile | Order again with another name | | `subnet_not_found`, `subnet_not_usable` | `failed` | The subnet was deleted, or failed, after you ordered | Order again into a usable subnet | | `invalid_name`, `region_not_available` | `failed` | The request could not be built as given | Order again with a valid name and zone | | `provisioning_failed` | `failed` | It was paid for but could not be built | Order again; contact support if it repeats | | `organization_deleted` | `payment_failed` or `failed` | The organization was deleted before the database was built, so the order was cancelled | Nothing to do: any payment taken was returned to credit | When the build itself fails, `failure_code` may instead be the build operation's own code, such as `DATABASE_DEPLOY_FAILED` or `DATABASE_SETUP_TIMED_OUT`; treat it like `provisioning_failed`, and quote it to support if it repeats. Whenever an order ends `failed`, anything it took is returned to your credit. A password held for a failed or expired order is erased with it. ## List your orders ```console $ curl "https://api.pantechdynamics.com/public/v1/database-orders?limit=20" \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Orders never include a password, only the `admin_username` the database is made with. ## Retrying a create Send the same request with the same `Idempotency-Key` and you get the **same order** back, never a second database or a second payment. The replay has `password_returned: false` and `password: null`: a generated password is only ever in the first response. If that first response was lost, wait for the database, then [reset the password](https://docs.pantechdynamics.com/databases/passwords#reset-it). A different request body with a key you have used before is `409 IDEMPOTENCY_CONFLICT`. Use a new key for a new database. ## With the CLI `pantech db create` places the order, prints a generated password once, and follows the order until the database is running: ```console $ pantech db orders list $ pantech db orders get ord_… ``` --- # Connect to a database Source: https://docs.pantechdynamics.com/databases/connecting A database's hostname, where it can be reached from, and connecting with psql or mysql. ## The hostname Every database gets a hostname of its own, in its `hostname` field: ```text db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com ``` It points at the database's **private** address (`private_ip`), and keeps pointing at it for the database's life, so put the hostname, not the address, in your connection strings. It is ordinary public DNS with a 60-second TTL: it resolves from anywhere, but the address it gives is reachable only from your own network. The port is the engine's usual one, also in the database's `port` field: `5432` for PostgreSQL, `3306` for MySQL and MariaDB. :::note[Your own resolver] Some resolvers refuse public names that point at private addresses (DNS rebinding protection). If the hostname does not resolve from a machine behind a VPN or a custom resolver, allow `db.pantechdynamics.com` there: in dnsmasq, `rebind-domain-ok=/db.pantechdynamics.com/`. ::: ## Where it can be reached from A database never has a public address, so a client has to be on the same private network, and inside one of its [access rules](https://docs.pantechdynamics.com/databases/access-rules). ### In a VPC (`af-abj-2`) A database in `af-abj-2` lives in one of your VPC's subnets, like a [VM in a VPC](https://docs.pantechdynamics.com/networking/private-networks). Every machine in that VPC can reach it, as long as its address is in an access rule. The default rule is the whole VPC range, so out of the box every machine in the VPC can connect. The database's rules are also applied to its subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls), as rules of its own, numbered after yours, so a deny rule of yours still wins. A network ACL applies to the whole subnet, so a rule that admits a range to the database's port admits it to that port on every machine in the same subnet. Put databases in a subnet of their own if that matters to you. To reach a database from outside the VPC, such as from your laptop, go through a machine in the VPC: an SSH tunnel, or a VPN you run there. ```console $ ssh -L 5432:db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com:5432 ubuntu@ $ psql "host=localhost port=5432 user=dbadmin dbname=defaultdb" ``` ### Standard zone (`af-abj-1`) A database in `af-abj-1` sits on a private network for databases (`10.250.0.0/20`), never on the public network your standard VPSes use. A standard VPS reaches it through a **second, private network interface** on that network, which you add with [`POST /instances/{instance_id}/private-network`](https://docs.pantechdynamics.com/api/instances). In short: 1. Make sure the VPS's security group admits **nothing** from `10.250.0.0/20`. The default group's _ICMP from anywhere_ rule, and any rule open to `0.0.0.0/0`, has to be narrowed first. 2. Add the interface, and read the VPS's `private_network_ip` once it is attached. 3. Bring the interface up in the VPS with DHCP, without taking its routes or DNS. 4. Add `/32` to the database's access rules. Every step, with the security group rules to use and the netplan file, is in [connect a standard VPS to a database](https://docs.pantechdynamics.com/databases/standard-vps). To reach the database from your laptop, tunnel through such a VPS, as in a VPC: ```console $ ssh -L 5432:db-3h8w1c6r0b.af-abj-1.db.pantechdynamics.com:5432 ubuntu@ ``` ## Connect Use the admin login (`admin_username`, `dbadmin` by default) and its [password](https://docs.pantechdynamics.com/databases/passwords). ::::tabs :::tab[PostgreSQL] ```console $ psql "host=db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com port=5432 user=dbadmin dbname=defaultdb" ``` As a URL, for most drivers and frameworks: ```text postgresql://dbadmin:@db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com:5432/defaultdb ``` ::: :::tab[MySQL / MariaDB] ```console $ mysql -h db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com -P 3306 -u dbadmin -p ``` As a URL: ```text mysql://dbadmin:@db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com:3306/ ``` ::: :::: Passwords never contain spaces, quotes or backslashes, so they are safe in a shell and in a connection string. A generated one is letters and digits only. ## If it does not connect - **It times out.** The client's address is not in an access rule, or the client is not on the database's network. In `af-abj-1`, check the VPS's private interface is up (`ip -br addr`) and its `private_network_ip` is in a rule. Check the database's `effective_access_rules` (its own rules plus what its security groups add) and `ignored_security_group_rules`, and in a VPC, your own network ACL rules. - **The hostname does not resolve.** `hostname` is null until the database is first running, or a resolver is refusing private answers (see above). - **Authentication fails.** Check `admin_username` on the database, then [reset the password](https://docs.pantechdynamics.com/databases/passwords#reset-it). - **It stopped working after a rule change.** Removing a rule also ends the connections already open from that range. The codes the API can return, and what to do about each, are on the [errors page](https://docs.pantechdynamics.com/databases/errors). --- # Connect a standard VPS to a database Source: https://docs.pantechdynamics.com/databases/standard-vps In af-abj-1, give a standard VPS a second, private network interface on the database network, configure it, and allow its address on the database. ## How it works A database in `af-abj-1` never sits on the public network your standard VPSes use. It lives on a **private database network**, `10.250.0.0/20`, shared by the databases of that zone. To reach it, a standard VPS gets a **second network interface** on that network: 1. You check the VPS's [security group](https://docs.pantechdynamics.com/networking/firewalls) admits nothing from `10.250.0.0/20` (the [reason](#why-the-security-group-matters) is below). 2. You ask for the interface. The VPS gets an address on `10.250.0.0/20`: its `private_network_ip`. 3. You bring the interface up inside the VPS, with DHCP but without its routes or DNS. 4. You add `/32` to the database's [access rules](https://docs.pantechdynamics.com/databases/access-rules). The interface is free. A running VPS gets it without a restart. The range is in the catalogue as the zone's `private_network_cidr` in [`GET /regions`](https://docs.pantechdynamics.com/api/catalogue#list-regions); it is `null` in a zone without one, and in every VPC zone. This page is for `af-abj-1` only. A VM in a VPC (`af-abj-2`) already reaches databases in its own VPC; see [connecting](https://docs.pantechdynamics.com/databases/connecting#in-a-vpc-af-abj-2). Asking for the interface anywhere else is `409 PRIVATE_NETWORK_NOT_AVAILABLE`. ## Why the security group matters A security group applies to **every** interface of a machine, not just its public one. If the VPS's group has an inbound rule whose source includes any part of `10.250.0.0/20`, that rule would also admit traffic arriving on the new private interface: from every database on the network, and from every other customer's VPS that has an interface there. So a VPS may have the interface only while its group admits **nothing** from `10.250.0.0/20`: - Any **inbound** rule whose source overlaps the range is refused, whatever its protocol or ports, **ICMP included**. `0.0.0.0/0` contains the range, `10.0.0.0/8` contains it, and `10.250.3.17/32` sits inside it: all three are refused. - **Outbound** rules do not count. The VPS can still reach anything its outbound rules allow, including the database. The check is made when you ask, again just before the interface is added, and for as long as the VPS has it: while it does, a rule change on its group, or moving it to another group, that would admit the range is refused with `422`, code `RULE_ALLOWS_PRIVATE_NETWORK` on `rules` or `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` on `security_group_id`. :::warning[Your default security group fails this check] Every organization's default group has an inbound rule allowing **ICMP from `0.0.0.0/0`** (so the machine answers ping). That rule includes `10.250.0.0/20`, so a VPS on the default group, unchanged, is refused. So is any group with an SSH, HTTP or other rule open to `0.0.0.0/0`. Narrow those rules first, as below. ::: ## Step 1: narrow the security group Find the group the VPS uses (`security_group_id` on the instance) and read its rules: ```console $ curl https://api.pantechdynamics.com/public/v1/security-groups/sg_… \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Every inbound rule with `0.0.0.0/0`, a `10.0.0.0/8` or any other range overlapping `10.250.0.0/20` has to change. You have three choices for each: 1. **Allow only the addresses you need.** Best for SSH: your office's or your VPN's range, like `203.0.113.0/24`. 2. **Drop it.** The ICMP rule is only for ping; most machines do without it. 3. **Keep it open to the internet, minus the private range.** Replace `0.0.0.0/0` with these 20 ranges, which together cover every IPv4 address except `10.250.0.0/20`: ```text 0.0.0.0/5 8.0.0.0/7 10.0.0.0/9 10.128.0.0/10 10.192.0.0/11 10.224.0.0/12 10.240.0.0/13 10.248.0.0/15 10.250.16.0/20 10.250.32.0/19 10.250.64.0/18 10.250.128.0/17 10.251.0.0/16 10.252.0.0/14 11.0.0.0/8 12.0.0.0/6 16.0.0.0/4 32.0.0.0/3 64.0.0.0/2 128.0.0.0/1 ``` Rules are replaced as a whole. For a web server that serves HTTPS to everyone and SSH to one office, with all traffic allowed out, the new set is: ```console $ curl -X PUT https://api.pantechdynamics.com/public/v1/security-groups/sg_…/rules \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d @rules.json ``` ```json title="rules.json" { "rules": [ { "direction": "ingress", "protocol": "tcp", "port_range": "22", "cidr": "203.0.113.0/24" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "0.0.0.0/5" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "8.0.0.0/7" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.0.0.0/9" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.128.0.0/10" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.192.0.0/11" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.224.0.0/12" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.240.0.0/13" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.248.0.0/15" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.250.16.0/20" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.250.32.0/19" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.250.64.0/18" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.250.128.0/17" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.251.0.0/16" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "10.252.0.0/14" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "11.0.0.0/8" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "12.0.0.0/6" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "16.0.0.0/4" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "32.0.0.0/3" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "64.0.0.0/2" }, { "direction": "ingress", "protocol": "tcp", "port_range": "443", "cidr": "128.0.0.0/1" }, { "direction": "egress", "protocol": "all", "cidr": "0.0.0.0/0" } ] } ``` The ICMP rule is gone, SSH is open to one range, and HTTPS to everything except the private network. The outbound rule can stay `0.0.0.0/0`. :::note[A group covers every machine that uses it] Editing a group changes it for all its machines at once. If other machines share it and you would rather leave them alone, create a new group for the VPS instead and move the VPS onto it; it has to be stopped to change groups. ::: ## Step 2: add the interface ```console $ curl -X POST https://api.pantechdynamics.com/public/v1/instances/vm_…/private-network \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` ```json { "operation_id": "op_…", "resource_id": "vm_…", "status": "submitting" } ``` The VPS must be running or stopped, with no other change under way (start, stop, resize, restore or delete): otherwise `409 INSTANCE_NOT_SETTLED`, so wait and try again. If the group still admits the range, the answer is `409 SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK`, and its `detail` lists the rules to change: ```json { "status": 409, "code": "SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK", "title": "Security group allows the private network", "detail": "This instance's security group allows inbound traffic from 10.250.0.0/20, the private database network: ingress icmp from 0.0.0.0/0. Security groups apply to every network interface of an instance, so these rules would also let other machines on the private network reach it. Narrow them so that no rule's source includes 10.250.0.0/20 (for example, replace 0.0.0.0/0 with the addresses you need), then try again.", "request_id": "req_…" } ``` Follow the [operation](https://docs.pantechdynamics.com/api/operations). While it runs the instance's `private_network_state` is `attaching`; when it has `succeeded`, it is `attached` and `private_network_ip` holds the address: ```console $ curl https://api.pantechdynamics.com/public/v1/instances/vm_… \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` ```json { "id": "vm_…", "private_network_state": "attached", "private_network_ip": "10.250.4.23" } ``` ## Step 3: configure it in the VPS The new interface arrives **down**. Bring it up with DHCP, but take **neither its routes nor its DNS**: your VPS's default route and resolvers must stay on its public interface, or it loses the internet. Find the interface's name. It is the one with no address, often `ens4` or `eth1`: ```console $ ip -br link lo UNKNOWN 00:00:00:00:00:00 ens3 UP 1e:00:4a:00:01:7c ens4 DOWN 1e:00:91:00:03:b2 ``` ::::tabs :::tab[Ubuntu / Debian (netplan)] Create a file of its own, so the image's own configuration is left alone: ```yaml title="/etc/netplan/60-pantech-private.yaml" network: version: 2 ethernets: ens4: dhcp4: true dhcp4-overrides: use-routes: false use-dns: false ``` ```console $ sudo chmod 600 /etc/netplan/60-pantech-private.yaml $ sudo netplan apply ``` ::: :::tab[Rocky / Oracle Linux (NetworkManager)] ```console $ sudo nmcli connection add type ethernet ifname eth1 con-name pantech-private \ ipv4.method auto ipv4.never-default yes ipv4.ignore-auto-routes yes \ ipv4.ignore-auto-dns yes ipv6.method disabled $ sudo nmcli connection up pantech-private ``` ::: :::: Check it has the address from `private_network_ip`, and that the default route is still on the public interface: ```console $ ip -br addr show ens4 ens4 UP 10.250.4.23/20 $ ip route show default default via 102.211.122.1 dev ens3 proto dhcp src 102.211.122.80 metric 100 ``` The database network is directly attached (`10.250.0.0/20 dev ens4`), so no route needs adding. ## Step 4: allow it on the database Add the VPS's `private_network_ip` as a `/32` [access rule](https://docs.pantechdynamics.com/databases/access-rules). The rules are replaced as a whole, so send the full list, including any rules you already have: ```console $ curl -X PUT https://api.pantechdynamics.com/public/v1/databases/db_…/access-rules \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"rules": [{"cidr": "10.250.4.23/32"}]}' ``` When that operation has `succeeded`, connect from the VPS using the database's hostname: ```console $ psql "host=db-3h8w1c6r0b.af-abj-1.db.pantechdynamics.com port=5432 user=dbadmin dbname=defaultdb" ``` Use one `/32` per VPS. A wider range such as `10.250.0.0/20` would admit every machine on the shared network, other customers' included. ## With the CLI ```console $ pantech security-groups rules set web --rule ingress:tcp:22:203.0.113.0/24 \ --rule egress:all::0.0.0.0/0 $ pantech vm private-network attach web-1 $ pantech db access-rules add orders-db 10.250.4.23/32 ``` `rules set` replaces every rule of the group with the `--rule DIRECTION:PROTOCOL:PORTS:CIDR` flags given (here: SSH from one office, everything out, no ICMP), so the group admits nothing from `10.250.0.0/20`. `attach` waits for the interface, then prints its address and the `db access-rules add` command to allow it. `access-rules add` keeps the database's other rules. If the group still admits the range, the CLI says which rules to narrow. `pantech vm private-network detach web-1` removes the interface. Both wait for the operation; pass `--no-wait` to return its id at once. ## With Terraform In the provider's next release: ```hcl resource "pantechdynamics_instance" "web" { name = "web-1" plan_slug = "starter" image_slug = "ubuntu-24-04" ssh_key_id = pantechdynamics_ssh_key.admin.id security_group_id = pantechdynamics_security_group.web.id private_network = true } resource "pantechdynamics_database" "orders" { name = "orders-db" engine = "postgresql" version = "18" plan_slug = "starter" zone_id = "af-abj-1" access_rules = ["${pantechdynamics_instance.web.private_network_ip}/32"] password_wo = var.orders_db_password password_wo_version = 1 } ``` `private_network_ip` is known once the interface is attached, so Terraform creates the database's rule after the instance has its address. The security group must already be narrow: Terraform does not change your rules for you, and an apply against a group that admits `10.250.0.0/20` fails with the error above. ## Remove the interface ```console $ curl -X DELETE https://api.pantechdynamics.com/public/v1/instances/vm_…/private-network \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` `private_network_state` goes `detaching` and then `none`. The address stays readable until it has gone. Afterwards: - remove its `/32` from each database's access rules, because the address can be given to another machine later, - delete the netplan file (or the NetworkManager connection) from the VPS. Once the interface is gone, the security group may admit the private range again. ## If something goes wrong | What you see | Why | What to do | | --- | --- | --- | | `409 SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | The VPS's group admits some of `10.250.0.0/20` | Narrow the rules the `detail` names ([step 1](#step-1-narrow-the-security-group)) | | `409 PRIVATE_NETWORK_NOT_AVAILABLE` | The VPS is in a VPC, or in a zone without a database network | VMs in a VPC reach databases in their VPC directly | | `409 INSTANCE_NOT_SETTLED` | The VPS is starting, stopping, resizing, restoring or being deleted | Wait until it is running or stopped, then retry | | `409 PRIVATE_NETWORK_ALREADY_ATTACHED` | It already has the interface, or one is being added | Read `private_network_ip` | | `409 PRIVATE_NETWORK_NOT_ATTACHED` | A detach for a VPS with no interface | Nothing to do | | `409 PRIVATE_NETWORK_CHANGE_IN_PROGRESS` | An attach or detach is still running | Follow that operation first | | `409 ACCOUNT_SUSPENDED` | Attaching from a suspended account | Pay the overdue invoice. Detaching is always allowed | | `422 RULE_ALLOWS_PRIVATE_NETWORK` on `rules` | A rule change would admit the range while a VPS on the group has the interface | Leave the range out of the new rules | | The operation fails `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | The group's rules changed between your request and the attach | Narrow them again, then retry | | The operation fails `INSTANCE_PRIVATE_NETWORK_ATTACH_FAILED` or `INSTANCE_BUSY` | The interface could not be added; nothing changed | Retry with a new `Idempotency-Key` | | The VPS lost the internet after `netplan apply` | The private interface took the default route or DNS | Make sure `use-routes: false` and `use-dns: false` are set, then `netplan apply` | | Connecting to the database times out | No access rule for the address, or the interface is down | Check `ip -br addr` and the database's `effective_access_rules` | More codes are on the [errors page](https://docs.pantechdynamics.com/databases/errors). --- # Access rules and security groups Source: https://docs.pantechdynamics.com/databases/access-rules Each database's allow-list of the address ranges that may connect to it, from its own rules and from security groups you attach. ## How access rules work A database accepts connections only from the address ranges in its allow-list. Everything else is dropped before it reaches the engine. The list has two sources: - the database's own **access rules**, and - the ranges of any [security groups you attach](#attach-security-groups). The database's `effective_access_rules` is the list as it is applied, each range with its `source`: `access_rule`, or `security_group:` and the group's id. An access rule is: - **One CIDR per rule**, IPv4 only, such as `10.0.1.0/24` or `10.0.1.12/32` for one machine. - **Nothing wider than a `/8`.** `0.0.0.0/0` is always refused: a database is never open to everyone, even on a private network. - **At most 50 rules** on a database. - **TCP to the engine's port only.** A rule cannot open any other port. Rules are enforced in more than one place: in the network (the subnet's network ACL in `af-abj-2`, a security group of the database's own in `af-abj-1`), on the database machine's own firewall, and in the engine's own access configuration. ## The default | Zone | A new database starts with | | --- | --- | | `af-abj-2` (VPC) | One rule: your VPC's whole range, so every machine in the VPC can connect | | `af-abj-1` | No rules: nothing can connect until you add one, usually a VPS's `private_network_ip` as a `/32` (see [connecting from a standard VPS](https://docs.pantechdynamics.com/databases/standard-vps)) | Set your own starting list with `access_rules` when you [create the database](https://docs.pantechdynamics.com/databases/create), or `[]` for none. ## Change the rules In the console, the database's **Access rules** card lists the ranges. Add one (IPv4, `/8` or narrower; one server is its address with `/32`), remove one, then press **Save access rules**. With the API, rules are replaced as a whole: send the complete new list. ```console $ curl -X PUT https://api.pantechdynamics.com/public/v1/databases/db_…/access-rules \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"rules": [{"cidr": "10.0.1.0/24"}, {"cidr": "10.0.2.15/32"}]}' ``` The change is asynchronous: follow the returned [operation](https://docs.pantechdynamics.com/api/operations) until it is `succeeded`. The database's `observed_generation` catches up with its `generation` once the new rules are in force. `{"rules": []}` removes every rule, which closes the database to everyone, except ranges its security groups still add. With the CLI, `access-rules set` takes the whole list of CIDRs, or `--none`. `add` and `remove` change some ranges and keep the rest: ```console $ pantech db access-rules set orders-db 10.0.1.0/24 10.0.2.15/32 $ pantech db access-rules add orders-db 10.250.4.23/32 $ pantech db access-rules remove orders-db 10.0.2.15/32 ``` With Terraform, `access_rules` on `pantechdynamics_database` is the whole list. :::warning[Removing a rule ends open connections] When a range leaves the list, the connections already open from it are closed too, not just new ones refused. Applications in that range lose their connection at once. ::: ## Choosing ranges - **Narrowest that works.** A subnet's range for an application tier, or a `/32` for each machine that should connect. - **In a VPC, keep databases in a subnet of their own.** A network ACL rule applies to the whole subnet, so the database's rules also open its port on every other machine in that subnet. See [connecting](https://docs.pantechdynamics.com/databases/connecting#in-a-vpc-af-abj-2). ## Attach security groups If you already keep your trusted ranges in [security groups](https://docs.pantechdynamics.com/networking/firewalls), attach them to the database instead of copying the ranges into its rules. When you change a group's rules, every database it is attached to follows automatically. ```console $ curl -X PUT https://api.pantechdynamics.com/public/v1/databases/db_…/security-groups \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"security_group_ids": ["sg_…"]}' ``` The list replaces the attached groups as a whole; `[]` detaches them all. At most 5 groups, each one of your own, in the database's zone. A group is only a **source of address ranges** here. It is never put on the database's machine, so it cannot open SSH or any other port. From each attached group, only the **ingress** rules that could admit a client to the engine count: - protocol `tcp` or `all`, - ports that include the engine's port (`5432` or `3306`): a single port, a range such as `3000-4000`, or all ports, - an IPv4 CIDR of `/8` or narrower. Every other rule is left out, and listed in the database's `ignored_security_group_rules` with the reason: | `reason` | The rule | | --- | --- | | `egress_rule` | Is outbound | | `protocol_not_tcp` | Is `udp` or `icmp` | | `port_not_covered` | Does not include the engine's port, like a rule for SSH on `22` | | `cidr_too_wide` | Is `0.0.0.0/0` or wider than a `/8` | | `cidr_not_ipv4` | Is not IPv4 | So a typical web tier's group, allowing `443` from everywhere and `22` from your office, adds nothing to a database: neither rule covers the database's port. For a group to admit a range to the database, it needs an ingress rule for the database's port from that range. The range is matched against the address a client connects from, which is its private address on the database's network. The database's own rules plus the ranges its groups add may come to at most 1,024. A change to a group that would take an attached database past that is refused. A group cannot be deleted while a database uses it (`409 SECURITY_GROUP_ATTACHED_TO_DATABASE`): detach it first. ### A worked example A database `db_…` in `af-abj-1` has one access rule of its own, `10.250.4.23/32`, and you attach a group `sg_app` with these inbound rules: | Rule | Counts for the database? | | --- | --- | | `tcp` `5432` from `10.250.7.0/24` | **Yes**: TCP, covers 5432, a `/24` | | `tcp` `22` from `203.0.113.0/24` | No: `port_not_covered` | | `icmp` from `10.250.0.0/20` | No: `protocol_not_tcp` | | `tcp` `5432` from `0.0.0.0/0` | No: `cidr_too_wide` | | `all` from `10.250.9.8/32` | **Yes**: all protocols and ports include TCP 5432 | ```console $ curl -X PUT https://api.pantechdynamics.com/public/v1/databases/db_…/security-groups \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"security_group_ids": ["sg_app"]}' ``` Once the operation has succeeded, the database reads: ```json { "security_group_ids": ["sg_app"], "effective_access_rules": [ { "cidr": "10.250.4.23/32", "protocol": "tcp", "port": 5432, "source": "access_rule" }, { "cidr": "10.250.7.0/24", "protocol": "tcp", "port": 5432, "source": "security_group:sg_app" }, { "cidr": "10.250.9.8/32", "protocol": "tcp", "port": 5432, "source": "security_group:sg_app" } ], "ignored_security_group_rules": [ { "security_group_id": "sg_app", "direction": "ingress", "protocol": "tcp", "port_range": "22", "cidr": "203.0.113.0/24", "reason": "port_not_covered" }, { "security_group_id": "sg_app", "direction": "ingress", "protocol": "icmp", "port_range": "", "cidr": "10.250.0.0/20", "reason": "protocol_not_tcp" }, { "security_group_id": "sg_app", "direction": "ingress", "protocol": "tcp", "port_range": "5432", "cidr": "0.0.0.0/0", "reason": "cidr_too_wide" } ] } ``` A range listed both in the database's rules and in a group appears once, under its first source: the database's own rules first, then the groups in the order you attached them. `effective_access_rules` is what the database applies once `observed_generation` equals `generation`. When you later change `sg_app`'s rules, the database follows on its own: no call to the database is needed. With the CLI: ```console $ pantech db security-groups set orders-db sg_app $ pantech db security-groups set orders-db --none ``` ### When something is refused | `code` | Status | What to do | | --- | --- | --- | | `INVALID_DATABASE_SECURITY_GROUPS` | 422 | At most 5 groups, each once | | `DATABASE_SECURITY_GROUP_NOT_FOUND` | 422 | Only your own groups can be attached | | `DATABASE_SECURITY_GROUP_WRONG_ZONE` | 422 | Attach groups from the database's zone | | `DATABASE_SECURITY_GROUP_NOT_USABLE` | 422 | The group is being deleted or has failed | | `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED` | 422 | More than 1,024 ranges in all; remove some | | `SECURITY_GROUP_ATTACHED_TO_DATABASE` | 409 | On deleting a group: detach it from the database named in `detail` first | All the database codes are on the [errors page](https://docs.pantechdynamics.com/databases/errors). --- # Database passwords Source: https://docs.pantechdynamics.com/databases/passwords Set the admin password when you create a database, change it to one you choose, or reset it to a generated one. We never store it. ## We never keep your password A database's admin password is applied to the database and then forgotten. It is never stored in plain text, never shown in the console, never in a log, and no read of the database or its order includes it. Support cannot recover it either. A **generated** password is shown exactly once, in the response that generated it, sent with `Cache-Control: no-store`. A password **you choose** is never returned at all. So: keep it in your own secret store the moment you have it. If it is lost, [reset it](#reset-it). ## The rules A password you choose must be: - 16 to 128 characters, - printable ASCII only, - with no spaces, quotes (`'` or `"`) or backslashes. Those are the characters that break connection strings and shell commands. A generated password is 32 letters and digits. ## Set it at create When you [create a database](https://docs.pantechdynamics.com/databases/create), either: - send `password` to choose it, or - leave it out, and the generated one comes back in that response's `password`, with `password_returned: true`. Until the database is being built, a password you sent is held only encrypted, and it is erased the moment it is handed to the database. ## Change it In the console, the database's **Admin password** card has **Change** to set a password you choose and **Reset** to have one generated and shown once. With the API, set a password you choose: ```console $ curl -X PUT https://api.pantechdynamics.com/public/v1/databases/db_…/password \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"password": "…"}' ``` The database must be running (`409 DATABASE_NOT_RUNNING` otherwise). Follow the returned [operation](https://docs.pantechdynamics.com/api/operations): the new password works once it has `succeeded`. Connections already open stay open. ## Reset it Have a new password generated: ```console $ curl -X POST https://api.pantechdynamics.com/public/v1/databases/db_…/reset-password \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` ```json { "operation_id": "op_…", "resource_id": "db_…", "status": "submitting", "password_returned": true, "password": "q8Vn3TzK1mXw7RbLc4Hs9JdP2fYa6GeU" } ``` The new password is in this response only. It takes effect when the operation has `succeeded`. :::warning[A retry does not repeat the password] Retrying with the same `Idempotency-Key` returns the original operation with `password_returned: false` and `password: null`, never a second copy. If the first response was lost, reset again with a **new** key. ::: ## With the CLI The CLI never takes a password as an argument, where it would land in your shell history: ```console $ pantech db password set orders-db --password-stdin < new-password.txt $ pantech db password reset orders-db ``` `reset` prints the new password once, alone on stdout, so `pantech db password reset orders-db > pw.txt` captures just it. ## From infrastructure as code Tools that keep state, like Terraform, should never write the password into it. The [Terraform](https://docs.pantechdynamics.com/terraform#databases) `pantechdynamics_database` resource takes it as a write-only attribute, `password_wo`, which Terraform 1.11 and later never saves. Raise `password_wo_version` to set a new one: ```hcl resource "pantechdynamics_database" "orders" { # … password_wo = var.orders_db_password password_wo_version = 2 # was 1: apply sends the new password } ``` ## If it goes wrong | `code` | Status | What to do | | --- | --- | --- | | `INVALID_DATABASE_PASSWORD` | 422, on `password` | 16 to 128 printable characters, no spaces, quotes or backslashes | | `DATABASE_NOT_RUNNING` | 409 | Start the database, then retry | | `DATABASE_PASSWORD_LOST` (on the operation) | | The change could not complete; [reset it](#reset-it) | --- # Database storage Source: https://docs.pantechdynamics.com/databases/storage A database's data disk starts at its plan's size. Choose a bigger one when you create it, and grow it later without a restart. It never shrinks. ## How storage works A database keeps its data on one disk of its own, the **data disk**. Its plan sets the vCPUs and memory, and the plan's disk size (`disk_gb` in [`GET /plans`](https://docs.pantechdynamics.com/api/catalogue#list-plans)) is the data disk's **base size**. You can start with more than that, and grow it at any time after: - **At create**: send `storage_gb` to start bigger than the plan's disk. - **Later**: grow it with `resize-storage`. The filesystem grows online; the database keeps running and serving connections. - **Never smaller.** A data disk only grows. There is no way to shrink one, so grow in steps as you need the space rather than far ahead of it. vCPUs and memory stay the plan's either way: storage is chosen separately from the plan. ## Allowed sizes Each zone's limits, and the price per GB, are in the catalogue, under each engine's `storage` in [`GET /database-engines`](https://docs.pantechdynamics.com/api/databases#list-database-engines). Read them from there in code rather than hard-coding them: ```json { "engine": "postgresql", "storage": [ { "zone_id": "af-abj-2", "min_gb": 0, "min_is_plan_disk": true, "max_gb": 2000, "step_gb": 10, "price_per_gb_month_minor": 15000, "currency": "NGN" } ] } ``` `min_gb` `0` (`min_is_plan_disk: true`) means the plan's own disk is the smallest size. Today both zones are: | | `af-abj-1` and `af-abj-2` | | --- | --- | | Smallest | The plan's own disk size (the base) | | Largest | 2,000 GB | | Step | 10 GB: above the base, a size must be a multiple of 10 | The base size is always allowed, even when it is not itself a multiple of 10. Anything above it must land on a 10 GB step. So for a plan whose disk is 25 GB, `25`, `30`, `40` … `2000` are valid, and `35` is not. A size outside these rules is refused with `422 VALIDATION_FAILED` and, in `errors`, code `INVALID_DATABASE_STORAGE` on the field `storage_gb`. ## Choose the size at create Add `storage_gb` to the [create request](https://docs.pantechdynamics.com/databases/create). Leave it out to get the plan's disk size. ```console $ curl https://api.pantechdynamics.com/public/v1/databases \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "name": "orders-db", "engine": "postgresql", "version": "18", "plan_slug": "starter", "zone_id": "af-abj-2", "subnet_id": "snet_…", "storage_gb": 100 }' ``` The order's `amount_minor`, the upfront payment, is priced for the size you chose, not the plan's. See [pricing](#pricing). ## Grow it later In the console, press **Increase storage** in the database's **Storage** card and choose the new size. It grows online with no restart, and is billed at the old size until the new one lands. The database must be running, with no other change in progress. With the API: ```console $ curl -X POST https://api.pantechdynamics.com/public/v1/databases/db_…/resize-storage \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"storage_gb": 200}' ``` ```json { "operation_id": "op_…", "resource_id": "db_…", "status": "submitting" } ``` The new size must be: - **larger** than the database's current `data_volume_size_gb`, - at most 2,000 GB, - a multiple of 10. And the database must be **running**: the disk is grown first, then the filesystem inside it, which needs the database's machine up. A stopped database is refused with `409 DATABASE_NOT_RUNNING`; [start it](https://docs.pantechdynamics.com/api/databases) first. Follow the [operation](https://docs.pantechdynamics.com/api/operations) until it is `succeeded`. While it runs, the database reads: ```json { "data_volume_size_gb": 100, "pending_data_volume_size_gb": 200 } ``` - `data_volume_size_gb` is the size **in place and billed**. It changes only when the resize has finished, and the database's own filesystem reports the new size. - `pending_data_volume_size_gb` is the size a resize in flight is growing to, or `null` when none is. One resize at a time: another request while one is in flight is `409 DATABASE_STORAGE_RESIZE_IN_PROGRESS`. Retrying the **same** request with the same `Idempotency-Key` is always safe; it returns the operation already running. ### If a resize fails The operation ends `failed` with a `failure.code`, `pending_data_volume_size_gb` goes back to `null`, and `data_volume_size_gb` and the bill are unchanged. Your data is untouched. | `failure.code` | What happened | What to do | | --- | --- | --- | | `DATABASE_STORAGE_RESIZE_FAILED` | The disk could not be grown | Try again. If it fails again, contact support with the operation id | | `DATABASE_STORAGE_GROW_FAILED` | The disk grew but the filesystem inside it did not, or the database could not be reached | Check the database is running, then try again with the same size | In the second case the disk may already be bigger than `data_volume_size_gb`. You are not billed for that extra space until a later resize confirms it. ## With the CLI ```console $ pantech db create --name orders-db --engine postgresql --version 18 --plan starter \ --zone af-abj-2 --subnet web --storage-gb 100 $ pantech db storage resize orders-db --storage-gb 200 Grow the storage of orders-db from 100 GB to 200 GB? It is billed at the new size once grown, and can never shrink. [y/N] y ``` `db storage resize` checks the size is larger than the current one before it asks, then waits for the operation; pass `--no-wait` to get its id at once, and `--yes` to skip the question in scripts. `pantech db get` shows the size and any resize in flight. ## With Terraform In the provider's next release: ```hcl resource "pantechdynamics_database" "orders" { name = "orders-db" engine = "postgresql" version = "18" plan_slug = "starter" zone_id = "af-abj-2" subnet_id = pantechdynamics_subnet.data.id storage_gb = 100 password_wo = var.orders_db_password password_wo_version = 1 } ``` Raising `storage_gb` and applying grows the disk in place. Lowering it is refused at plan time, never a replacement: Terraform will not delete a database to make its disk smaller. Leaving `storage_gb` out keeps the plan's size. ## Pricing Storage is priced per GB-month (`price_per_gb_month_minor` in the catalogue, for a 730-hour month), like [block storage](https://docs.pantechdynamics.com/storage/block-storage), and billed by the hour on the **confirmed** size (`data_volume_size_gb`): - **At create**, the upfront payment covers the plan **with** the size you chose. A database at the plan's own size costs exactly what a VM in a VPC on that plan costs; every GB above it adds the per-GB storage rate. - **Growing later takes no upfront payment**, the same as [resizing a machine](https://docs.pantechdynamics.com/compute/resizing). The larger disk is billed hourly from the moment the resize completes, and invoiced with everything else. Growing is refused only when your account cannot take on new charges (suspended, or with an invoice past due). - **A stopped database still pays for its disk**, at its full size. To price a size before choosing it, estimate the plan with the extra gigabytes as `extra_storage_gb`: ```console $ curl https://api.pantechdynamics.com/public/v1/pricing/estimate \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "currency": "NGN", "machines": [{"plan_slug": "starter", "quantity": 1, "running_hours": 730, "placement": "vpc"}], "extra_storage_gb": 75 }' ``` Here `75` is the size you want minus the plan's `disk_gb`: 100 GB on a plan with a 25 GB disk. ## Watching how full it is A full data disk stops the engine accepting writes. Keep an eye on it from inside the engine: ::::tabs :::tab[PostgreSQL] ```sql SELECT pg_size_pretty(sum(pg_database_size(datname))) FROM pg_database; ``` ::: :::tab[MySQL / MariaDB] ```sql SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024 / 1024, 1) AS gb FROM information_schema.tables; ``` ::: :::: Compare that with `data_volume_size_gb`, and grow the disk well before it is full: write-ahead logs, temporary files and indexes being rebuilt all need room beyond the data itself. --- # Database snapshots Source: https://docs.pantechdynamics.com/databases/snapshots Take a point-in-time copy of a database's data disk, list and delete them. What a crash-consistent snapshot is, and when to take a dump instead. ## What a database snapshot is A snapshot is a copy of a database's **data disk** at one instant. You take it with one API call, while the database is running or stopped, and it is kept, and billed, until you delete it. Three things to know before you rely on one: - **It is crash-consistent.** The engine keeps running while the copy is taken, so the snapshot holds what the disk held at that instant, as if the power had been cut. PostgreSQL, MySQL and MariaDB all recover from that on start, but a transaction that had not committed is not in it. For a quiesced copy, [stop the database](https://docs.pantechdynamics.com/api/databases) first, snapshot it, then start it. - **Restoring from a snapshot is not available yet.** There is no endpoint to create a database, or a volume, from a database snapshot today. Take snapshots now and you will be able to restore them when it arrives, but until then a snapshot is not a backup you can use on your own. - **It is not a logical backup.** For a copy you can restore today, anywhere, into any version, take a [dump](https://docs.pantechdynamics.com/databases/backups#take-a-dump) with `pg_dump`, `mysqldump` or `mariadb-dump`. Use snapshots alongside dumps, not instead of them. A snapshot **outlives its database**: deleting the database does not delete its snapshots, and they stay billed until you delete them yourself. ## Take a snapshot In the console, press **Take snapshot** in the database's **Snapshots** card and name it. The database keeps running. The card lists the database's snapshots. With the API: ```console $ curl https://api.pantechdynamics.com/public/v1/databases/db_…/snapshots \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"name": "before-migration-42"}' ``` ```json { "operation_id": "op_…", "resource_id": "snap_…", "status": "submitting" } ``` - `name` is 1 to 255 characters, and must be unique among that database's snapshots (`409 DATABASE_SNAPSHOT_NAME_TAKEN` otherwise). Name it after why you took it: `before-migration-42`, `pre-upgrade`, `2026-10-05-nightly`. - The database must be **running or stopped**. While it is being created, changing power state or being deleted, the request is `409 DATABASE_SNAPSHOT_NOT_READY`; wait for it to settle. - `resource_id` is the new snapshot's id, `snap_…`. Follow the [operation](https://docs.pantechdynamics.com/api/operations) until it is `succeeded` (its `resource_id` is the snapshot's id too); the snapshot's `observed_state` is then `active` and `completed_at` is set. The database keeps serving connections while the snapshot is taken. ## List and read snapshots A database's own snapshots, oldest first: ```console $ curl https://api.pantechdynamics.com/public/v1/databases/db_…/snapshots \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` ```json { "data": [ { "id": "snap_…", "name": "before-migration-42", "database_id": "db_…", "instance_id": null, "volume_id": null, "trigger": "manual", "size_bytes": 1879048192, "desired_state": "present", "observed_state": "active", "completed_at": "2026-10-05T09:14:02Z", "created_at": "2026-10-05T09:13:40Z", "updated_at": "2026-10-05T09:14:02Z" } ], "next_cursor": null } ``` One snapshot is `GET /databases/{database_id}/snapshots/{snapshot_id}`. A snapshot id that is not one of **this database's** snapshots, such as a volume's snapshot, is `404 DATABASE_SNAPSHOT_NOT_FOUND`. Database snapshots also appear in your organization's [snapshot list](https://docs.pantechdynamics.com/api/snapshots), with `database_id` set and `instance_id` and `volume_id` null. That is how you find the snapshots of a database you have already deleted. `size_bytes` is the storage the snapshot uses, which is what it is billed on. It is `0` until the snapshot is complete. ## Delete a snapshot ```console $ curl -X DELETE https://api.pantechdynamics.com/public/v1/databases/db_…/snapshots/snap_… \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` The snapshot is billed until the deletion completes. After the database itself has been deleted, delete its snapshots with [`DELETE /snapshots/{snapshot_id}`](https://docs.pantechdynamics.com/api/snapshots). ## Pricing A database snapshot is billed like every other [snapshot](https://docs.pantechdynamics.com/storage/volume-snapshots): the storage rate per GB of the space it uses, rounded up to whole GB, for every hour it exists. Nothing is billed while it is still being taken. ## With the CLI ```console $ pantech db snapshots create orders-db --name before-migration-42 $ pantech db snapshots list orders-db $ pantech db snapshots get orders-db snap_… $ pantech db snapshots delete orders-db snap_… ``` `create` and `delete` wait for the operation to finish; pass `--no-wait` to return its id at once. `delete` asks first unless you pass `--yes`. ## With Terraform In the provider's next release: ```hcl resource "pantechdynamics_database_snapshot" "before_migration" { database_id = pantechdynamics_database.orders.id name = "before-migration-42" } ``` Changing either argument replaces the snapshot: a new one is taken and the old one deleted. `terraform destroy` deletes it. Because a snapshot outlives its database, a snapshot resource left in your configuration keeps being billed after the database is gone. ## A routine that works 1. A nightly logical dump with `pg_dump` or `mysqldump`, kept off the database's network. This is your real backup. See [backups](https://docs.pantechdynamics.com/databases/backups). 2. A snapshot before anything risky: a schema migration, a bulk delete, a major application release. 3. Delete snapshots once the change has proved itself, so they stop costing you. --- # Database backups Source: https://docs.pantechdynamics.com/databases/backups There are no automatic backups yet. Take your own dumps, add snapshots of the data disk, and know what each one does and does not give you. ## What exists today :::warning[No automatic backups yet] Databases do not have automatic backups or point-in-time recovery yet. You can take [snapshots](https://docs.pantechdynamics.com/databases/snapshots) of a database's data disk, but restoring one is not available yet. Until it is, **back up your data yourself with a dump**, as below. ::: A database's data is on its own disk, which survives stops, starts and patching. It does not survive [deleting the database](https://docs.pantechdynamics.com/databases/overview#lifecycle), a mistaken `DROP`, or a bug in your application, and there is no second copy of it. ## Take a dump Run the engine's dump tool from any machine that can [connect](https://docs.pantechdynamics.com/databases/connecting) to the database, and keep the output somewhere other than the database's own network: a volume on another machine, or storage of your own. ::::tabs :::tab[PostgreSQL] ```console $ pg_dump "host=db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com user=dbadmin dbname=defaultdb" \ --format=custom --file=defaultdb-$(date +%F).dump ``` Restore into an empty database with `pg_restore`: ```console $ pg_restore --no-owner -d "host=… user=dbadmin dbname=restored" defaultdb-2026-10-05.dump ``` Use a `pg_dump` at least as new as the server: version 18. ::: :::tab[MySQL] ```console $ mysqldump -h db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com -u dbadmin -p \ --single-transaction --routines --triggers --databases app > app-$(date +%F).sql ``` Restore with `mysql -h … -u dbadmin -p < app-2026-10-05.sql`. The admin login cannot write MySQL's own `mysql` schema, so dump your application's databases by name rather than `--all-databases`. ::: :::tab[MariaDB] ```console $ mariadb-dump -h db-7k2q9x4m1a.af-abj-2.db.pantechdynamics.com -u dbadmin -p \ --single-transaction --routines --triggers --databases app > app-$(date +%F).sql ``` Restore with `mariadb -h … -u dbadmin -p < app-2026-10-05.sql`. ::: :::: `--single-transaction` gives a consistent copy of InnoDB tables without locking them. Run dumps on a schedule, from cron on a machine in the same VPC, and test a restore now and then: a backup you have never restored is a guess. ## Dumps and snapshots | | A dump | A [snapshot](https://docs.pantechdynamics.com/databases/snapshots) | | --- | --- | --- | | What it is | Your data as SQL or the engine's archive format | A copy of the whole data disk at one instant | | Consistency | Consistent: one transaction's view | Crash-consistent: like after a power cut, unless the database was stopped | | Where it lives | Wherever you put it | With us, billed per GB-hour until deleted | | Restore | Today, into any database of the same engine | Not available yet | | Take one | `pg_dump`, `mysqldump`, `mariadb-dump` | `POST /databases/{database_id}/snapshots` | A snapshot is taken while the engine is writing. Restoring it will be like starting the database after a power cut: PostgreSQL, MySQL and MariaDB all recover from that, but anything not yet committed is lost. Stop the database first for a quiesced copy. Either way, it is not a substitute for a dump you can restore anywhere: use both. --- # Database pricing Source: https://docs.pantechdynamics.com/databases/pricing A database costs what a VM in a VPC on the same plan costs, plus any storage beyond the plan's disk, paid for up front like a machine. No management fee. ## What a database costs A database is priced exactly as a virtual machine of the same plan **with no public address**, which is what a [VM in a VPC](https://docs.pantechdynamics.com/networking/private-networks) on that plan costs: - the plan's vCPUs and memory, by the hour, - one disk, the database's data disk, at the plan's size, - any storage you add beyond the plan's size, per GB at the [block storage](https://docs.pantechdynamics.com/storage/block-storage) rate. Prices exclude [VAT](https://docs.pantechdynamics.com/billing/vat), which is added to your end-of-period invoice. There is no management fee and no separate price list. A database never has a public address, so it never pays for one, in either zone. To price one before you create it, estimate a machine of the same plan with `placement` `vpc`, and add the gigabytes beyond the plan's disk as `extra_storage_gb` (leave it out at the plan's own size). For 100 GB on a plan with a 25 GB disk: ```console $ curl https://api.pantechdynamics.com/public/v1/pricing/estimate \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Content-Type: application/json" \ -d '{"currency": "NGN", "machines": [{"plan_slug": "starter", "quantity": 1, "running_hours": 730, "placement": "vpc"}], "extra_storage_gb": 75}' ``` ## Paid for first Creating a database places an [order](https://docs.pantechdynamics.com/api/database-orders), and works like [creating a machine](https://docs.pantechdynamics.com/billing/how-billing-works#virtual-machines): 1. The order takes the **upfront payment** (`amount_minor` in the create response), from your credit if there is enough, otherwise from your default card. 2. Only then is the database built. 3. If the payment fails, the order ends `payment_failed` and nothing is built. An order left unpaid for **one hour** expires the same way (`failure_code` `payment_expired`). 4. If the database cannot be built after it was paid for, the order ends `failed` and the payment goes back to your credit. The upfront payment carries no VAT. It covers the data disk at the size you chose with `storage_gb`. After that, usage is metered by the hour and invoiced with everything else. [Database orders](https://docs.pantechdynamics.com/databases/orders) has the details. ## While stopped, and after deletion - **Stopped:** like a stopped machine, a stopped database stops paying for vCPUs and memory, and still pays for its disk. - **Deleted:** billing stops for the database; the hours it ran up to the delete are still billed. As with deleting a machine, the part of its upfront payment not yet deducted from a bill goes back to your credit balance, never to your card, and shows in **Transactions** as **Upfront payment returned after database deleted**. Its [snapshots](https://docs.pantechdynamics.com/databases/snapshots) are kept, and billed, until you delete them. ## Growing storage [Growing the data disk](https://docs.pantechdynamics.com/databases/storage#grow-it-later) takes **no upfront payment**, the same as resizing a machine. The bigger disk is billed by the hour, on its new size, from the moment the resize completes. Until then, and if it fails, you pay for the size you had. ## Snapshots A [database snapshot](https://docs.pantechdynamics.com/databases/snapshots) is billed like any snapshot: per GB of the space it uses, for every hour it exists, until you delete it, even after the database is gone. ## Choosing a size The plan, and so the vCPUs and memory, is fixed when you create a database: moving to a bigger plan is not available yet, so choose one with room to grow. Storage is different: start at what you need now and [grow it](https://docs.pantechdynamics.com/databases/storage) as you go, because it can never shrink. --- # Patching and updates Source: https://docs.pantechdynamics.com/databases/patching We apply security and bug-fix updates to your database engine and its operating system for you. Major versions never change without you. ## We patch it for you We apply security and bug-fix updates to your database engine for you. There is no shell on a database's machine, so you could not, and you do not need to. What we update: - **Minor releases within your version line**, such as PostgreSQL 18.6 to 18.7 or MySQL 8.4.11 to 8.4.12. - **The operating system** underneath. What we never change on our own: the **major version**. A PostgreSQL 18 database stays on 18, and a MySQL 8.4 database stays on 8.4, until you choose otherwise. ## When updates happen | Kind | When | | --- | --- | | Security fixes, in the engine or the operating system | As soon as they are available. We tell you when the update starts and when it finishes | | Other fixes | Bundled into one update at most every two months, announced ahead with what it contains and the expected restart | An update restarts the engine, so expect a short interruption, and have your application reconnect when a connection drops. ## End of life Each version line has an end-of-life date, in [`GET /database-engines`](https://docs.pantechdynamics.com/api/databases#list-database-engines) as `eol_date`. A line stops being offered for new databases at least six months before it. Moving an existing database to a newer major version will be a separate step you start yourself. --- # Database error codes Source: https://docs.pantechdynamics.com/databases/errors Every code the API returns for databases, their storage and snapshots, attached security groups and a VPS's private network interface, and what to do about each. ## Reading an error Every error is a problem document with a stable `code`; the [errors guide](https://docs.pantechdynamics.com/api/errors) explains the shape. Two kinds matter here: - **A refused request.** The API answers with a `4xx` or `5xx` and nothing changes. For an invalid field the answer is `422 VALIDATION_FAILED`, and the field's own code is inside `errors`: ```json { "status": 422, "code": "VALIDATION_FAILED", "title": "Validation failed", "detail": "One or more fields are invalid.", "request_id": "req_…", "errors": [ { "field": "storage_gb", "code": "INVALID_DATABASE_STORAGE", "message": "must be at least the plan's disk size (or the zone's minimum), at most the zone's maximum, a multiple of the zone's step above the plan's size, and larger than the current size when resizing" } ] } ``` - **A failed operation.** The request was accepted (`202`), but the change could not be made. The [operation](https://docs.pantechdynamics.com/api/operations) ends `failed`, and its `failure.code` says why (with a readable `failure.reason`). For a new database the [order](https://docs.pantechdynamics.com/databases/orders) carries a `failure_code` too, such as `payment_expired` (not paid within an hour) or `organization_deleted` (the organization was deleted first, so the order was cancelled and any payment returned to credit). Quote `request_id`, or the operation's id, to support. ## Creating a database Field codes, inside `errors` on a `422`: | `code` | Field | Means | What to do | | --- | --- | --- | --- | | `INVALID_DATABASE_NAME` | `name` | Not 1 to 255 characters | Shorten it | | `INVALID_DATABASE_ENGINE` | `engine` | Not `postgresql`, `mysql` or `mariadb` | Use one of those | | `INVALID_DATABASE_VERSION` | `version` | Not a version line offered for the engine | Pick one from [`GET /database-engines`](https://docs.pantechdynamics.com/api/databases#list-database-engines) | | `INVALID_DATABASE_ZONE` | `zone_id` | Not a zone your organization can use | Use `af-abj-1` or `af-abj-2` | | `DATABASE_VERSION_NOT_AVAILABLE_IN_ZONE` | `zone_id` | That version cannot be created in that zone yet | Check the version's `zones` in the catalogue | | `PLAN_NOT_FOUND` | `plan_slug` | Not an active plan | Pick one from [`GET /plans`](https://docs.pantechdynamics.com/api/catalogue#list-plans) | | `DATABASE_SUBNET_REQUIRED` | `subnet_id` | A database in `af-abj-2` needs a subnet | Send the subnet's id | | `DATABASE_SUBNET_NOT_ALLOWED` | `subnet_id` | A database in `af-abj-1` takes no subnet | Leave `subnet_id` out | | `DATABASE_SUBNET_NOT_FOUND` | `subnet_id` | Not one of your subnets | Check the id | | `DATABASE_SUBNET_WRONG_ZONE` | `subnet_id` | The subnet is in another zone | Use a subnet in `zone_id` | | `DATABASE_SUBNET_NOT_USABLE` | `subnet_id` | The subnet is being deleted or has failed | Use another subnet | | `INVALID_DATABASE_ADMIN_USERNAME` | `admin_username` | Not 3 to 32 lowercase letters, digits and underscores starting with a letter, or a reserved name | Choose another; see [create](https://docs.pantechdynamics.com/databases/create#with-the-api) | | `INVALID_DATABASE_PASSWORD` | `password` | Not 16 to 128 printable characters, or contains a space, quote or backslash | See [the rules](https://docs.pantechdynamics.com/databases/passwords#the-rules) | | `INVALID_DATABASE_STORAGE` | `storage_gb` | Smaller than the plan's disk, over 2,000 GB, or off the 10 GB step | See [allowed sizes](https://docs.pantechdynamics.com/databases/storage#allowed-sizes) | | `INVALID_ACCESS_RULE` | `access_rules` | Not an IPv4 CIDR, wider than a `/8`, or `0.0.0.0/0` | Narrow it | | `TOO_MANY_ACCESS_RULES` | `access_rules` | More than 50 | Use wider ranges, or a security group | | `DUPLICATE_ACCESS_RULE` | `access_rules` | The same CIDR twice | Remove the repeat | Other refusals: | `code` | Status | Means | What to do | | --- | --- | --- | --- | | `INSUFFICIENT_CREDIT` | 402 | Not enough credit, and no card for the upfront payment | [Add funds](https://docs.pantechdynamics.com/billing/adding-funds) or a card | | `DATABASE_NAME_TAKEN` | 409 | You already have a database with that name | Choose another | | `ACCOUNT_SUSPENDED` | 409 | An invoice is overdue | Pay it; nothing new can be created until then | | `IDEMPOTENCY_CONFLICT` | 409 | The `Idempotency-Key` was used for a different request | Use a new key | | `DATABASE_HOSTNAME_UNAVAILABLE` | 503 | A hostname could not be reserved | Retry with a **new** `Idempotency-Key` | | `DATABASE_STORAGE_UNAVAILABLE` | 503 | Database storage is not offered in that zone yet | Try later, or the other zone | | `DATABASES_UNAVAILABLE` | 503 | Databases are not available right now | Try again later | An order can also end `payment_failed` or `failed` after it was accepted: see [why an order failed](https://docs.pantechdynamics.com/databases/orders#why-an-order-failed). ## Reading and changing a database | `code` | Status | Means | What to do | | --- | --- | --- | --- | | `DATABASE_NOT_FOUND` | 404 | No database of yours has that id | Check the id and the API key's organization | | `RESOURCE_NOT_FOUND` | 404 | The id is not a database id at all (`db_…`) | Check the path | | `DATABASE_ORDER_NOT_FOUND` | 404 | No order of yours has that id | Check the id | | `DATABASE_NOT_RUNNING` | 409 | The change needs the database running: a password change, a reset, or a storage resize | Start it, wait for `running`, retry | | `DATABASE_NOT_STOPPED` | 409 | The change needs it stopped | Stop it first | | `INVALID_RESOURCE_STATE` | 409 | It is being deleted, or has been | Nothing more can be changed on it | | `CONCURRENT_UPDATE` | 409 | It changed while the request was being handled | Retry the same request | | `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED` | 422 | Its rules plus its security groups' ranges would exceed 1,024 | Remove some ranges | ## Security groups on a database From [`PUT /databases/{database_id}/security-groups`](https://docs.pantechdynamics.com/databases/access-rules#attach-security-groups). Field codes, on `security_group_ids`: | `code` | Means | What to do | | --- | --- | --- | | `INVALID_DATABASE_SECURITY_GROUPS` | More than 5 groups, or one listed twice | Send at most 5 distinct ids | | `DATABASE_SECURITY_GROUP_NOT_FOUND` | Not one of your security groups | Check the ids | | `DATABASE_SECURITY_GROUP_WRONG_ZONE` | The group is in another zone | Attach groups from the database's zone | | `DATABASE_SECURITY_GROUP_NOT_USABLE` | The group is being deleted, or failed | Use another group | From the security group's side: | `code` | Status | Means | What to do | | --- | --- | --- | --- | | `SECURITY_GROUP_ATTACHED_TO_DATABASE` | 409 | Deleting a group a database uses; `detail` names the database | Detach it from that database first | | `DATABASE_ACCESS_RANGE_LIMIT_EXCEEDED` | 422, on `rules` | A rule change would give an attached database more than 1,024 ranges | Use fewer, wider ranges | A rule of an attached group that does not apply to the database is not an error: it is listed in the database's `ignored_security_group_rules` with a [reason](https://docs.pantechdynamics.com/databases/access-rules#attach-security-groups). ## Storage From [`POST /databases/{database_id}/resize-storage`](https://docs.pantechdynamics.com/databases/storage#grow-it-later): | `code` | Status | Means | What to do | | --- | --- | --- | --- | | `INVALID_DATABASE_STORAGE` | 422, on `storage_gb` | Not larger than the current size, over 2,000 GB, or not a multiple of 10 | Pick a valid size | | `DATABASE_NOT_RUNNING` | 409 | The database is stopped | Start it first | | `DATABASE_STORAGE_RESIZE_IN_PROGRESS` | 409 | A resize is already running | Wait for it, then retry | | `ACCOUNT_SUSPENDED`, `ACCOUNT_NOT_ACTIVE` | 409 | The account cannot take on new charges | Settle billing first | | `DATABASE_STORAGE_UNAVAILABLE` | 503 | Storage changes are not offered in that zone yet | Try later | A resize operation can fail with: | `failure.code` | Means | What to do | | --- | --- | --- | | `DATABASE_STORAGE_RESIZE_FAILED` | The disk could not be grown. Nothing changed | Retry; contact support if it repeats | | `DATABASE_STORAGE_GROW_FAILED` | The disk grew, but the filesystem could not be, or the database could not be reached. Size and bill unchanged | Check it is running, then retry the same size | ## Snapshots From [database snapshots](https://docs.pantechdynamics.com/databases/snapshots): | `code` | Status | Means | What to do | | --- | --- | --- | --- | | `INVALID_DATABASE_SNAPSHOT_NAME` | 422, on `name` | Empty, or over 255 characters | Fix the name | | `DATABASE_SNAPSHOT_NAME_TAKEN` | 409 | The database already has a snapshot of that name | Choose another | | `DATABASE_SNAPSHOT_NOT_READY` | 409 | The database is not running or stopped: it is being created, changing state, or being deleted | Wait for it to settle | | `DATABASE_SNAPSHOT_NOT_FOUND` | 404 | No snapshot of **this** database has that id | Check both ids; a snapshot of a volume or a machine is not under a database | | `ACCOUNT_SUSPENDED` | 409 | The account cannot take on new charges | Settle billing first | ## A VPS's private network interface From [`POST` and `DELETE /instances/{instance_id}/private-network`](https://docs.pantechdynamics.com/databases/standard-vps): | `code` | Status | Means | What to do | | --- | --- | --- | --- | | `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | 409 | The VPS's security group admits some of `10.250.0.0/20`; `detail` names the rules | [Narrow them](https://docs.pantechdynamics.com/databases/standard-vps#step-1-narrow-the-security-group) | | `PRIVATE_NETWORK_NOT_AVAILABLE` | 409 | The VPS is in a VPC, or its zone has no database network | Only for standard VPSes in `af-abj-1` | | `NOT_FOUND` | 404 | Private network interfaces are not offered on this platform yet | Try again later | | `INSTANCE_NOT_SETTLED` | 409 | The VPS is starting, stopping, resizing, restoring or being deleted | Wait until it is running or stopped | | `PRIVATE_NETWORK_ALREADY_ATTACHED` | 409 | It has the interface, or one is being added | Nothing to do | | `PRIVATE_NETWORK_NOT_ATTACHED` | 409 | Removing an interface it does not have | Nothing to do | | `PRIVATE_NETWORK_CHANGE_IN_PROGRESS` | 409 | An attach or detach is still running | Follow that operation | | `ACCOUNT_SUSPENDED` | 409 | Attaching from a suspended account | Pay the overdue invoice; detaching is always allowed | While a VPS has the interface, its security group is held to the same rule: | `code` | Status | Means | | --- | --- | --- | | `RULE_ALLOWS_PRIVATE_NETWORK` | 422, on `rules` | A rule change on the group would admit `10.250.0.0/20` | | `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | 422, on `security_group_id` | Moving the VPS to a group that admits it | An attach or detach operation can fail with: | `failure.code` | Means | What to do | | --- | --- | --- | | `SECURITY_GROUP_ALLOWS_PRIVATE_NETWORK` | The group's rules changed to admit the range before the interface was added. Nothing was added | Narrow them, then retry | | `INSTANCE_BUSY` | The VPS stayed busy with another change | Retry with a new `Idempotency-Key` | | `INSTANCE_PRIVATE_NETWORK_ATTACH_FAILED` | The interface could not be added. Nothing changed | Retry; contact support if it repeats | | `INSTANCE_PRIVATE_NETWORK_DETACH_FAILED` | The interface could not be removed. It is still attached | Retry | ## Operations on a database Other `failure.code`s a database operation can end with: | `failure.code` | Means | What to do | | --- | --- | --- | | `DATABASE_DEPLOY_FAILED`, `DATABASE_SETUP_TIMED_OUT` | A new database could not be built. Its order ends `failed` and the payment returns to your credit | Order again | | `DATABASE_START_FAILED`, `DATABASE_STOP_FAILED` | A power change did not complete | Retry | | `DATABASE_DELETE_FAILED` | Deletion did not complete | Retry the delete | | `DATABASE_NOT_READY`, `DATABASE_GUEST_COMMAND_FAILED`, `DATABASE_GUEST_RETRIES_EXHAUSTED` | A change inside the database (access rules, a password, storage) could not be applied; the database kept its previous settings | Check it is running, then retry | | `DATABASE_EFFECTIVE_RULES_EXCEEDED` | The allow-list came to more than 1,024 ranges, so nothing was changed | Remove ranges from its rules or groups | | `DATABASE_PASSWORD_LOST` | A password change could not be completed and its password is gone | [Reset the password](https://docs.pantechdynamics.com/databases/passwords#reset-it) | | `DATABASE_DELETED` | The database was deleted while the change was waiting | Nothing to do | | `PROVISIONING_CAPACITY_UNAVAILABLE`, `PROVISIONING_ADDRESSES_EXHAUSTED`, `PROVISIONING_IMAGE_UNAVAILABLE` | The zone had no room, address or image for it right now | Try another plan or zone, or try again later | | `PROVISIONING_LIMIT_EXCEEDED` | Your account reached a limit | Delete what you no longer use, or contact support | | `PROVISIONING_RESOURCE_BUSY`, `PROVISIONING_UNAVAILABLE` | The platform was busy or briefly unavailable | Retry in a few minutes | | `PROVISIONING_RESOURCE_CONFLICT`, `PROVISIONING_RESOURCE_NOT_FOUND`, `PROVISIONING_INVALID_REQUEST`, `PROVISIONING_INTERNAL_ERROR`, `PROVISIONING_FAILED` | The platform could not make the change. See [every provisioning code](https://docs.pantechdynamics.com/api/errors#when-an-operation-fails) | Follow `failure.reason`; contact support with the operation id if it repeats | New codes may be added. Treat one you do not know by its HTTP status, or as a failed operation you can retry. --- # Managed Kubernetes Source: https://docs.pantechdynamics.com/kubernetes/overview Run a Kubernetes cluster whose nodes are VMs in your account. Create one, connect with kubectl, scale it or let the autoscaler do it, limit who reaches its API server, upgrade it, and expose your services. ## What it is A managed Kubernetes cluster is a set of virtual machines in your account that the platform sets up and joins into one cluster for you: - **Control nodes** run the cluster's API and its state. A cluster has 1, or 3 for a highly available control plane where the zone offers it. - **Worker nodes** run your workloads. A cluster has 1 to 10: a fixed number you change when you like, or a range the [autoscaler](#autoscaling) moves within as your pods need. Every node, control and worker, is a VM of the **node plan** you choose, and is billed as one. There is no separate fee for the control plane. You get an admin kubeconfig and use `kubectl`, Helm or anything else that speaks to Kubernetes, as with any other cluster. ## Zones and placement A cluster lives in one [zone](https://docs.pantechdynamics.com/getting-started/facility-and-region#zones), chosen when you create it, and cannot move. | Zone | Network | Control plane | | --- | --- | --- | | **VPC zone** | The nodes go in a [subnet](https://docs.pantechdynamics.com/networking/private-networks) you choose, so they reach your other VMs and [databases](https://docs.pantechdynamics.com/databases/overview) in the VPC privately | 1 control node, or 3 where offered | | **Standard zone** | No subnet: leave it out | 1 control node | A highly available control plane (3 control nodes) keeps the cluster's API up when one control node fails or is replaced. It is offered only in some zones: the create form shows it only where it can be created, and the API lists those zones as `ha_zone_ids` on [the versions list](https://docs.pantechdynamics.com/api/kubernetes#list-kubernetes-versions). The number of control nodes is fixed once the cluster exists. In a VPC zone, the subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls) applies to the nodes like any other VM in it. ## Create a cluster 1. In the console, choose **Kubernetes** in the sidebar, then press **Create cluster**. 2. **Zone**: where the cluster runs. 3. **VPC and subnet** (VPC zone only): the dedicated subnet the nodes go in. Each cluster requires its own dedicated subnet (`/24` or `/26` recommended) to prevent IP collisions with standard VMs or other clusters. Create a [VPC and subnet](https://docs.pantechdynamics.com/networking/private-networks) first if you have none. 4. **Version**: the Kubernetes version, newest first. See [versions and upgrades](#versions-and-upgrades). 5. **Node plan**: the size of every node. It must have at least the version's minimum vCPU and memory. 6. **Control plane**: 1 control node, or 3 for high availability where the zone offers it. 7. **Workers**: a fixed number from 1 to 10, or turn on **Autoscaling** and set **Minimum workers** and **Maximum workers**. See [autoscaling](#autoscaling). 8. **API server access** (VPC zone only): **Any address**, or **Only these addresses** and list them. See [API server access](#api-server-access). 9. **Name**: 1 to 40 lowercase letters, digits and hyphens, starting with a letter, like `prod-web`. Press **Generate** if you want a random memorable name. It must be unique in your organization. The summary shows how many nodes you are paying for (with autoscaling, as many as the maximum could run). Confirm. The cluster's page follows it from **provisioning** to **running**, which takes several minutes. Once it is running, [download its kubeconfig](#download-the-kubeconfig). :::info[Nothing is paid up front, but you need a card or credit] Unlike creating a VM, creating a cluster takes no upfront payment: each node is billed by the hour as a VM of the node plan. To create it you need a saved card, or enough credit to cover 40% of a month for every node it can run. With autoscaling on, that counts the maximum number of workers. Without either, the cluster is refused with `402 INSUFFICIENT_CREDIT` and nothing is built. ::: A cluster must also fit your account's limits, with every node it could have counted (up to the autoscaling maximum). One that does not fit fails before anything is created: its operation ends `failed` with `failure.code` `PROVISIONING_LIMIT_EXCEEDED`, and no node is built or billed. Delete what you no longer use, ask for fewer workers or a lower maximum, or [contact support](https://docs.pantechdynamics.com/help/support-tickets) to raise your limits. ## Versions and upgrades Each zone offers a few Kubernetes versions. A version is withdrawn from new clusters before it reaches its upstream end of life; clusters already on it keep running it, but you should plan to upgrade. - **Upgrades are yours to start.** A cluster is never upgraded on its own. - **One step at a time.** A cluster can move to the next patch release, or to the next minor version (for example 1.31 to 1.32), never further in one go. To go from 1.30 to 1.32, upgrade twice. - **Never down.** There is no downgrade. Test an upgrade on a separate cluster first if your workloads depend on APIs that change between versions. - **The cluster must be running.** Nodes are replaced one at a time, so workloads running more than one replica, spread across workers, can keep serving. On the cluster's page, the **Upgrade** button lists the versions the cluster can move to now. In the API they are the cluster's `available_upgrades`. ## Node plans and billing Every node is a VM of the node plan, billed exactly as a VM of that plan: a cluster of 1 control node and 2 workers costs three VMs. The plan has to meet the version's minimum vCPU and memory; smaller plans are refused with `INVALID_KUBERNETES_NODE_PLAN`. The plan is chosen once; to change it, create a new cluster. | While the cluster is | You pay for | | --- | --- | | Running | Every node, as a running VM of the plan, and the cluster's volume storage | | Stopped | The nodes' disks, at the plan's stopped rate, and the cluster's volume storage | | Deleted | Nothing: billing stops when you delete it | A public or dual cluster also pays for its own public address, as a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses#what-it-costs), for every hour it holds it; a private cluster has none. With [autoscaling](#autoscaling), you pay for the workers that are running, hour by hour, never for the maximum unless it runs. Storage your workloads claim through [persistent volumes](#persistent-volumes) is billed as storage, with the nodes' disks. There is no control-plane fee. See [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). ## Scale the workers On the cluster's page, press **Change nodes**, set **Workers** to any number from 1 to 10 and confirm. Added workers are billed from when they exist; removed workers are drained first, so their pods move to the others, and stop billing. The cluster must be running, and must not be in the middle of another change. Its state shows **updating** until the change is done: once every added worker has joined the cluster and is ready to run pods, about two minutes after it starts. Added workers take no upfront payment; they are checked against your funding (a saved card, or enough credit) and your account limits. Setting the same number again changes nothing. While autoscaling is on you cannot set a fixed number: turn autoscaling off in the same dialog, and the cluster keeps the count you choose. ## Autoscaling With autoscaling on, the platform's autoscaler checks the cluster about once a minute. It adds a worker when a pod that a worker could run is waiting for room, and removes the newest worker once it has run nothing but DaemonSet pods for 10 minutes. It moves one worker at a time, waits for a new worker to join before taking another step, and always stays between the **minimum** and the **maximum** you set. Node pools with autoscaling on are scaled the same way, for pods that select the pool and tolerate its taints. - **Turn it on** when you create the cluster (**Autoscaling** in the create form), or later on the cluster's page under **Change nodes**. Set **Minimum workers** and **Maximum workers**. - **The range** is 1 to 10, with the minimum no more than the maximum. A new cluster starts at the minimum. Changing the range on a running cluster moves the current count into it straight away. - **Billing**: workers are billed as VMs of the node plan while they run, so you pay for what the autoscaler runs, hour by hour. Your funding (a saved card, or enough credit), and your account limits, are checked for the maximum when you turn autoscaling on or raise the maximum, so the autoscaler can always reach it. Nothing is paid up front. - **The worker count** you see is what the autoscaler chose; it changes on its own. You cannot set a fixed count while autoscaling is on. - **Turn it off** under **Change nodes** by choosing a fixed number of workers. The cluster keeps that many. The autoscaler reacts to pods' resource requests, not to CPU use: give your pods `resources.requests` so it knows when the cluster is full. ## Node pools A node pool is a group of workers beside the cluster's own, with its own plan, worker count (or autoscaling range), Kubernetes labels and taints: for example bigger nodes for a database, or nodes only some workloads may use. Every pool worker carries the label `pantech.io/node-pool=`; give a workload a `nodeSelector` (and tolerations for the pool's taints) to run it there. Each worker is billed as a VM of the pool's plan once it has joined the cluster; the pool shows how many have (`observed_workers`). Deleting a pool removes its workers from the cluster (their pods move to nodes that can run them) and then destroys them. In **af-abj-2**, every Kubernetes node is currently built at one fixed size, **2 vCPU and 4 GB**, so clusters, plan changes and node pools there take plans of that size only (`KUBERNETES_PLAN_NOT_IN_ZONE` otherwise). You are billed for exactly the size you get. ## Add-ons | Add-on | What it is | | --- | --- | | `metrics-server` | CPU and memory metrics, for `kubectl top` and the Horizontal Pod Autoscaler. On by default | | `csi-driver` | Persistent volumes on the node's own disk (StorageClass `local-path`). On by default. The data stays on that node, so use it for data you can rebuild or that your workload replicates | | `ingress-controller` | ingress-nginx, the default IngressClass. In a VPC zone it is served on 80 and 443 of the cluster's public address | Turn them on or off on the cluster's **Add-ons** tab, with `pantech kubernetes addons enable|disable`, or the [API](https://docs.pantechdynamics.com/api/kubernetes). The platform keeps your choice applied, and each add-on's status (`installing`, `active`, `removing`, `disabled`) shows when it has taken effect. ## API server access The API server is where `kubectl` and every other tool connect. Its URL is the cluster's **endpoint**, on the cluster's page under **API server** (`endpoint` in the API, null until the cluster is running), and the kubeconfig already points at it. In a **VPC zone**, a cluster's **endpoint access** decides where the API server can be reached from (`endpoint_access` in the API): | Endpoint access | Reached from | Public address | | --- | --- | --- | | **Private** (the default) | Your VPC only: its VMs, and anything connected to it | None | | **Public** | The internet | The cluster's own, billed as a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses#what-it-costs) for every hour it holds it | | **Dual** | Both | As Public | Choose it when you create the cluster, or change it later with **API server access** on the cluster's page. A public or dual cluster gets a public address of its own: its API server is on port 6443 of it (`public_ip` and `public_port` in the API), and the services you [expose](#expose-your-services) are on it too. Switching a cluster to **private** releases that address: the change finishes only once the API server and every exposed service are no longer reachable from the internet, and the address stops being billed then. Every request still needs the kubeconfig's credential, whatever the endpoint access. A public or dual cluster can also allow only the addresses you list: 1. On the cluster's page, press **API server access** (or choose it when you create the cluster). 2. Choose **Only these addresses**, and list up to 20 IPv4 ranges, such as your office's `203.0.113.0/24` or one machine's `198.51.100.7/32`. 3. Save. The list you save replaces the old one; choose **Any address** to remove the limit again. The list is enforced on the cluster's control node, against the address a connection really comes from. List the address your network uses on the internet: an office or home connection can go out from more than one, and only the ones listed get through. Traffic from inside your VPC and the cluster itself always gets through. The list only limits who reaches the API server: workloads inside the cluster, and services you expose, are not affected. Keep an address you will use to manage the cluster on the list, or you lock yourself out of `kubectl` until you change it again. Clusters created before 22 October 2026 cannot take a list; create a new cluster to use one. Clusters in a standard zone cannot have a list; asking for one is refused with `API_ALLOWED_CIDRS_VPC_ONLY`. ## Download the kubeconfig The kubeconfig is the file `kubectl` reads to find and log in to your cluster. On the cluster's page, once it is running, press **Download kubeconfig**. Only owners and admins can download it. :::warning[Treat the kubeconfig as a password] It holds a long-lived cluster-admin credential: anyone with the file has full control of the cluster. Do not commit it, paste it into tickets or share it in chat. The platform does not keep a copy; each download fetches it fresh from the cluster. ::: Through the API it is `POST /kubernetes-clusters/{cluster_id}/kubeconfig`, which needs a `write` [API key](https://docs.pantechdynamics.com/getting-started/api-keys), so a read-only key can never fetch it. Before the cluster is running it answers `409 KUBECONFIG_UNAVAILABLE`. With the [CLI](https://docs.pantechdynamics.com/cli#kubernetes), `pantech kubernetes kubeconfig prod -o ~/.kube/prod.yaml` writes it to a file only you can read. ## Connect with kubectl Install [kubectl](https://kubernetes.io/docs/tasks/tools/), ideally the same minor version as the cluster, and point it at the file: ```console $ chmod 600 ~/Downloads/prod-web-kubeconfig.yaml $ export KUBECONFIG=~/Downloads/prod-web-kubeconfig.yaml $ kubectl get nodes NAME STATUS ROLES AGE VERSION prod-web-ctrl-1 Ready control-plane 12m v1.31.2 prod-web-wrk-1 Ready 10m v1.31.2 prod-web-wrk-2 Ready 10m v1.31.2 ``` Node names in your cluster will differ. To use several clusters, merge their files or keep one `KUBECONFIG` per shell. ## Persistent volumes Persistent volumes work out of the box: the cluster has a default storage class, so a `PersistentVolumeClaim` gets a volume without any setup. ```yaml title="data.yaml" apiVersion: v1 kind: PersistentVolumeClaim metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 20Gi ``` The storage your claims use is the cluster's **volume storage**, on the cluster's page (`volume_storage_gb` in the API). It is billed as storage, with the nodes' disks, by the hour for as long as the volumes exist, whether the cluster is running or stopped. Delete claims you no longer need to stop paying for them. Deleting the cluster deletes them all, so copy out any data you need to keep first. ## Expose your services In a VPC zone, route a public port of the cluster's address to a `Service`: **Expose** on the cluster's Workloads tab, `pantech kubernetes expose --port 80`, or [`POST …/expose-service`](https://docs.pantechdynamics.com/api/kubernetes). The service's port becomes a NodePort and the route is created on the cluster's own public address; the subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls) must let the port in. Only a public or dual cluster has a public address, so a private cluster cannot expose services, and switching a cluster to private takes its exposed services off the internet. The cluster is the record of what is exposed: an exposed service carries the `pantechdynamics.com/public-port` annotation, so removing it with kubectl and running **Sync public routing** (`pantech kubernetes sync-routing`) takes the route away. A cluster has no cloud load-balancer integration: a `Service` of type `LoadBalancer` gets no address. A good pattern is to run an ingress controller as a NodePort service, expose that one port, and route every app through ingress rules. To spread traffic across several clusters or VMs, put a [load balancer](https://docs.pantechdynamics.com/networking/load-balancers) in front. Services that only need to be reached from your VPC can stay private: in a VPC zone, other VMs in the VPC reach the nodes on their private addresses, subject to the subnet's network ACL. ## Stop and start On the cluster's page, press **Stop** to stop every node. While stopped, only the nodes' disks are billed, and nothing in the cluster runs. Press **Start** to bring it back; its nodes are billed for compute again once it runs. You cannot scale or upgrade a stopped cluster, or download its kubeconfig. ## Delete a cluster On the cluster's page, press **Delete** and confirm. - **At once**, the cluster disappears from your lists and stops billing. Its name, its subnet and its place in your [cluster limit](#limits) are freed for reuse, and a public address it held is released. - **Within minutes**, its nodes are stopped and destroyed with their disks, one node at a time. They cannot be recovered. - **Volumes your workloads created** through persistent volume claims are deleted with it. Copy out any data you need first. A subnet that still has clusters in it cannot be deleted: the API answers `409 SUBNET_HAS_KUBERNETES_CLUSTERS`. Delete the clusters first. ## Limits - **5 clusters per organization** by default; a cluster you delete stops counting at once. [Contact support](https://docs.pantechdynamics.com/help/support-tickets) to raise it. - **1 to 10 workers** per cluster, fixed or as an autoscaling range. - **Account limits**: every node is a VM in your account and counts against its limits, up to the autoscaling maximum. A cluster or a scale that does not fit fails with `PROVISIONING_LIMIT_EXCEEDED` before anything is created. - **20 addresses or ranges** on the API server's allow-list, for a public or dual cluster in a VPC zone. - **1 or 3 control nodes**, 3 only where offered; fixed once created. - **Fixed once created**: the name, the zone, the subnet, the node plan and the number of control nodes. To change any of them, create a new cluster. - **Changeable**: the number of workers or the autoscaling range, the endpoint access and the API server's allow-list, and the version (upwards, one step at a time). ## Troubleshooting | Code | Status | What it means and what to do | | --- | --- | --- | | `KUBERNETES_UNAVAILABLE` | 503 | Managed Kubernetes is not open on the platform yet. Try again later | | `INSUFFICIENT_CREDIT` | 402 | You have no saved card, and your credit does not cover 40% of a month for every node, or for the added workers (with autoscaling, up to the maximum). Top up or add a card | | `KUBERNETES_CLUSTER_NAME_TAKEN` | 409 | You already have a cluster with this name. Choose another | | `KUBERNETES_CLUSTER_LIMIT` | 409 | Your organization has reached its cluster limit. Delete a cluster, or contact support | | `KUBERNETES_CLUSTER_NOT_FOUND` | 404 | No cluster with this id in your organization. A deleted cluster answers this at once | | `KUBERNETES_CLUSTER_NOT_RUNNING` | 409 | Scaling, autoscaling, API server access and upgrades need a running cluster. Start it first | | `KUBERNETES_CLUSTER_BUSY` | 409 | The cluster is already changing. Wait until it is running or stopped | | `KUBERNETES_CLUSTER_NOT_CHANGEABLE` | 409 | The cluster cannot make this change now: it is stopped, being deleted, or failed | | `KUBECONFIG_UNAVAILABLE` | 409 | The cluster is not running yet. Wait until it is | | `SUBNET_HAS_KUBERNETES_CLUSTERS` | 409 | You tried to delete a subnet that still has clusters. Delete them first | | `PROVISIONING_UNAVAILABLE` | 503 | Retry in a few seconds with the same `Idempotency-Key` | A create or scale that is accepted can still fail: its operation then ends `failed` with a `failure.code`. `PROVISIONING_LIMIT_EXCEEDED` means the cluster, counting every node it could have, does not fit your account's limits; nothing was created. Ask for fewer workers or a lower maximum, delete what you no longer use, or contact support to raise the limits. See [when an operation fails](https://docs.pantechdynamics.com/api/errors#when-an-operation-fails) for the other codes. On a `422 VALIDATION_FAILED`, each entry of `errors` names the field and its code: | Field | Code | Fix | | --- | --- | --- | | `name` | `INVALID_KUBERNETES_CLUSTER_NAME` | 1 to 40 lowercase letters, digits and hyphens, starting with a letter | | `zone_id` | `INVALID_KUBERNETES_ZONE` | A zone your organization can use | | `kubernetes_version_id` | `INVALID_KUBERNETES_VERSION` | A version offered in this zone, from the versions list | | `kubernetes_version_id` | `KUBERNETES_UPGRADE_NOT_ALLOWED` | Upgrade to one of the cluster's `available_upgrades` | | `node_plan` | `INVALID_KUBERNETES_NODE_PLAN` | An active plan with at least the version's minimum vCPU and memory | | `workers` | `INVALID_KUBERNETES_WORKER_COUNT` | 1 to 10 | | `workers` | `REQUIRED` | Send `workers`, or `autoscaling` with `enabled: true`. A change must send at least one field | | `autoscaling` | `INVALID_KUBERNETES_AUTOSCALING` | `min_workers` and `max_workers` from 1 to 10, the minimum no more than the maximum, and `workers` within them. Do not send `workers` while autoscaling is on | | `api_allowed_cidrs` | `INVALID_API_ALLOWED_CIDRS` | At most 20 IPv4 ranges, like `203.0.113.0/24` | | `api_allowed_cidrs` | `API_ALLOWED_CIDRS_VPC_ONLY` | Only clusters in a VPC zone can limit API server access | | `control_nodes` | `INVALID_KUBERNETES_CONTROL_NODES` | 1 or 3 | | `control_nodes` | `KUBERNETES_HA_NOT_AVAILABLE` | 3 control nodes are not offered in this zone; use 1 | | `subnet_id` | `KUBERNETES_SUBNET_REQUIRED` | A VPC zone needs a subnet | | `subnet_id` | `KUBERNETES_SUBNET_NOT_ALLOWED` | A standard zone takes no subnet; leave it out | | `subnet_id` | `KUBERNETES_SUBNET_NOT_FOUND` | An active subnet of yours, in the cluster's zone | A cluster whose state is **failed** carries a `failure_code` and a readable `failure_reason`. The platform treats failed clusters as deleted, leaving them out of your cluster list so their name and subnet are immediately freed to create another cluster. If creation continues to fail, [open a ticket](https://docs.pantechdynamics.com/help/support-tickets) with the operation's id. ## With the CLI The [CLI](https://docs.pantechdynamics.com/cli#kubernetes) does every step from your terminal: ```console $ pantech kubernetes versions --zone af-abj-2 $ pantech kubernetes create --name prod-web --zone af-abj-2 --version 1.31.2 \ --subnet snet_… --node-plan s-2vcpu-4gb --workers 2 $ pantech kubernetes kubeconfig prod-web -o ~/.kube/prod-web.yaml $ pantech kubernetes configure prod-web --autoscale 2:5 $ pantech kubernetes configure prod-web --api-allow 203.0.113.0/24 $ pantech kubernetes upgrade prod-web --version 1.32.0 $ pantech kubernetes apply prod-web -f app.yaml $ pantech kubernetes workloads prod-web $ pantech kubernetes expose prod-web web --port 80 $ pantech kubernetes stop prod-web $ pantech kubernetes delete prod-web ``` `kubeconfig` refuses to print the credential unless you pass `--stdout`. `configure --no-autoscale --workers 3` turns the autoscaler off at a fixed count, and `--api-allow-any` removes the allow-list. ## With the API Every step is in the [Kubernetes API](https://docs.pantechdynamics.com/api/kubernetes). Using the `pt` and `wait_op` helpers from the [API examples](https://docs.pantechdynamics.com/api/examples), with a subnet of a VPC in the VPC zone in `$SUBNET`: ```sh source pantech.sh # 1. Pick the newest version in the zone. ZONE=af-abj-2 VERSION=$(pt GET "/kubernetes-versions?zone_id=$ZONE" | jq -r '[.data[] | select(.status == "available")][0].id') # 2. Create the cluster: 1 control node, 2 workers. OP=$(pt POST /kubernetes-clusters "$(jq -n --arg z "$ZONE" --arg v "$VERSION" --arg s "$SUBNET" \ '{name: "prod-web", zone_id: $z, kubernetes_version_id: $v, subnet_id: $s, node_plan: "s-2vcpu-4gb", control_nodes: 1, workers: 2}')") K8S=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" # 3. Download the kubeconfig (a write key) and use it. pt POST "/kubernetes-clusters/$K8S/kubeconfig" | jq -r .kubeconfig > kubeconfig.yaml chmod 600 kubeconfig.yaml KUBECONFIG=kubeconfig.yaml kubectl get nodes ``` Use a node plan from `GET /plans` that meets the version's `min_cpu` and `min_memory_mb`. To create it with the autoscaler instead, send `autoscaling` and leave `workers` out (it starts at `min_workers`); in a VPC zone you can limit the API server to your addresses at the same time: ```sh OP=$(pt POST /kubernetes-clusters "$(jq -n --arg z "$ZONE" --arg v "$VERSION" --arg s "$SUBNET" \ '{name: "prod-api", zone_id: $z, kubernetes_version_id: $v, subnet_id: $s, node_plan: "s-2vcpu-4gb", autoscaling: {enabled: true, min_workers: 2, max_workers: 5}, api_allowed_cidrs: ["203.0.113.0/24"]}')") ``` `PATCH /kubernetes-clusters/{cluster_id}` changes the cluster: send any of `workers`, `autoscaling` and `api_allowed_cidrs`, and the rest stay as they are. An empty `operation_id` means nothing changed: ```sh # A fixed number of workers (autoscaling must be off, or turned off in the same request). OP=$(pt PATCH "/kubernetes-clusters/$K8S" '{"workers": 3}') OPID=$(echo "$OP" | jq -r .operation_id) [ -n "$OPID" ] && wait_op "$OPID" # Let the autoscaler keep 2 to 5 workers. pt PATCH "/kubernetes-clusters/$K8S" '{"autoscaling": {"enabled": true, "min_workers": 2, "max_workers": 5}}' # Turn it off and keep 3 workers. pt PATCH "/kubernetes-clusters/$K8S" '{"autoscaling": {"enabled": false, "min_workers": 0, "max_workers": 0}, "workers": 3}' # Replace the API server's allow-list (VPC zone); [] allows any address again. pt PATCH "/kubernetes-clusters/$K8S" '{"api_allowed_cidrs": ["203.0.113.0/24", "198.51.100.7/32"]}' ``` `GET /kubernetes-clusters/{cluster_id}` reports `autoscaling`, `api_allowed_cidrs` (empty for any address), `endpoint` (the API server's URL) and `volume_storage_gb`. Each `PATCH` returns an operation to follow, like the first one above. To upgrade, pick one of the cluster's `available_upgrades`: ```sh NEXT=$(pt GET "/kubernetes-clusters/$K8S" | jq -r '.available_upgrades[0].id') OP=$(pt POST "/kubernetes-clusters/$K8S/upgrade" "$(jq -n --arg v "$NEXT" '{kubernetes_version_id: $v}')") wait_op "$(echo "$OP" | jq -r .operation_id)" ``` `POST /kubernetes-clusters/{cluster_id}/stop` and `/start` stop and start it, and `DELETE /kubernetes-clusters/{cluster_id}` deletes it. Each returns an operation to follow. ## With Terraform With the [Terraform provider](https://docs.pantechdynamics.com/terraform), a VPC, a subnet, a cluster of 1 control node whose workers the autoscaler keeps between 2 and 5, with its API server open only to your office, and its kubeconfig written to a file: ```hcl title="main.tf" resource "pantechdynamics_network" "main" { name = "main" cidr = "10.0.0.0/16" } resource "pantechdynamics_subnet" "k8s" { network_id = pantechdynamics_network.main.id name = "k8s" cidr = "10.0.2.0/24" } # Let kubectl reach the cluster's API from your office. resource "pantechdynamics_firewall_rule" "kube_api" { subnet_id = pantechdynamics_subnet.k8s.id number = 100 protocol = "tcp" port_start = 6443 cidr = "203.0.113.0/24" } data "pantechdynamics_kubernetes_versions" "zone" { zone_id = pantechdynamics_subnet.k8s.zone } resource "pantechdynamics_kubernetes_cluster" "prod" { name = "prod-web" zone_id = pantechdynamics_subnet.k8s.zone subnet_id = pantechdynamics_subnet.k8s.id kubernetes_version_id = data.pantechdynamics_kubernetes_versions.zone.versions[0].id # newest first node_plan = "s-2vcpu-4gb" control_nodes = 1 # or 3 where the zone is in ha_zone_ids # The autoscaler keeps 2 to 5 workers. Leave workers out while it is on. autoscaling = { enabled = true min_workers = 2 max_workers = 5 } # For a fixed count instead: remove autoscaling and set workers = 2. # VPC zone only: who may reach the API server. Leave out for any address. api_allowed_cidrs = ["203.0.113.0/24"] timeouts = { create = "60m" update = "60m" delete = "20m" } } resource "local_sensitive_file" "kubeconfig" { filename = "${path.module}/kubeconfig.yaml" content = pantechdynamics_kubernetes_cluster.prod.kube_config file_permission = "0600" } output "kubernetes_version" { value = pantechdynamics_kubernetes_cluster.prod.kubernetes_version } output "endpoint" { value = pantechdynamics_kubernetes_cluster.prod.endpoint } ``` - Set either `workers` (a fixed count, 1 to 10) or `autoscaling` with `enabled = true`, not both; the plan says so if you do. While autoscaling is on, `workers` reports the count the autoscaler chose and never shows as a change. To go back to a fixed count, remove `autoscaling` and set `workers`. - Changing `workers`, `autoscaling` or `api_allowed_cidrs` changes the cluster in place, in one request. Removing `api_allowed_cidrs` allows any address again. - Changing `kubernetes_version_id` upgrades it in place. Set it to one of the cluster's `available_upgrade_ids`; a downgrade is refused. - Changing `name`, `zone_id`, `subnet_id`, `node_plan` or `control_nodes` replaces the cluster: Terraform deletes it and creates a new one, and everything in it is lost. - `kube_config` is sensitive, and lands in your Terraform state. Keep the state somewhere private, encrypted at rest. - `observed_state`, `in_sync`, `nodes`, `node` (its `vcpu`, `memory_mb` and `disk_gb`), `network_id`, `endpoint` and `volume_storage_gb` are read from the cluster. - The `pantechdynamics_kubernetes_cluster` data source looks a cluster up by `id` or `name`, without its kubeconfig. It also reports `autoscaling`, `api_allowed_cidrs`, `endpoint` and `volume_storage_gb`. - Import one with its id: `terraform import pantechdynamics_kubernetes_cluster.prod k8s_06gk3x9tq7m2v8r4y1c5d0zp8w`. --- # Kubernetes runbook and tutorials Source: https://docs.pantechdynamics.com/kubernetes/runbook Step-by-step production runbook for deploying multi-tier workloads, managing clusters with kubectl, configuring VPC gateway port forwarding, and accessing the Kubernetes Dashboard. ## Overview Pantech Cloud provides sovereign, managed Kubernetes clusters where both control plane and worker nodes run as dedicated virtual machines in your private [VPC network](https://docs.pantechdynamics.com/networking/private-networks). This runbook walks you through the entire lifecycle: 1. Creating a cluster and setting up your local `kubectl` environment. 2. Deploying real-world, multi-tier application blueprints (including stateful databases, Redis caches, REST APIs, and modern web frontends). 3. Exposing containerized services publicly using **VPC gateway port forwarding** and **NodePort** services. 4. Accessing the official **Kubernetes Dashboard Web UI** for live workload inspection and real-time pod telemetry. --- ## Step 1: Create a cluster Before deploying workloads, create a managed Kubernetes cluster in your target VPC: 1. In the console, navigate to **Kubernetes** in the left sidebar and click **Create cluster**. 2. **Zone**: Select your deployment zone (for example, `af-abj-2`). 3. **VPC and Subnet**: Choose the private VPC network and subnet where your nodes will reside (for example, `k8s-subnet` in `prod-vpc`). 4. **Kubernetes Version**: Choose the latest stable release (such as `1.31.1`). 5. **Node Plan**: Choose the VM specification for your nodes (for example, `std-2c-4g` for 2 vCPUs and 4 GB RAM). 6. **Worker Nodes**: Choose the desired worker count (for example, 2 nodes). 7. **Cluster Name**: Provide a unique, lowercase name (for example, `production-apps`). 8. Review the configuration and click **Create cluster**. The cluster provisions automatically in several minutes. Once the cluster phase displays **Running**, you can download your cluster credentials. --- ## Step 2: Connect with `kubectl` The kubeconfig contains the cluster-admin credential required to authenticate and interact with your cluster's API server. ### 1. Download and secure your kubeconfig On your cluster's detail page, click **Download kubeconfig** in the top action bar (or in the **Cluster details** section). Save the YAML file to your local computer: ```console $ chmod 600 ~/Downloads/production-apps-kubeconfig.yaml $ export KUBECONFIG=~/Downloads/production-apps-kubeconfig.yaml ``` :::warning[Keep credentials private] The kubeconfig holds full cluster-admin rights. Never commit it to git repositories, paste it into public forums, or share it in chat. ::: ### 2. Verify cluster nodes Test connectivity to the Kubernetes API server and verify all nodes are `Ready`: ```console $ kubectl get nodes -o wide NAME STATUS ROLES AGE VERSION INTERNAL-IP OS-IMAGE production-apps-control-1 Ready control-plane 10m v1.31.1 10.0.1.201 Ubuntu 24.04 LTS production-apps-worker-1 Ready worker 8m v1.31.1 10.0.1.202 Ubuntu 24.04 LTS production-apps-worker-2 Ready worker 8m v1.31.1 10.0.1.203 Ubuntu 24.04 LTS ``` --- ## Step 3: Application Blueprints Below are complete, production-ready manifests for multi-tier applications tested on Pantech Kubernetes. ### Blueprint A: Full-Stack Social Media Platform This architecture demonstrates: - **Persistent Storage**: PostgreSQL 16 database running as a `StatefulSet` with an automated `PersistentVolumeClaim`. - **In-Memory Cache**: Redis 7.2 for sub-millisecond session state and timeline caching. - **Backend API**: Node.js/TypeScript REST API exposed on NodePort `30085`. - **Frontend UI**: Next.js 15 responsive web interface exposed on NodePort `30084`. Save the following manifest as `social-media-app.yaml`: ```yaml title="social-media-app.yaml" apiVersion: v1 kind: Namespace metadata: name: social-media --- # PostgreSQL Database (StatefulSet with Persistent Volume Claim) apiVersion: apps/v1 kind: StatefulSet metadata: name: postgres namespace: social-media spec: serviceName: postgres replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16-alpine env: - name: POSTGRES_DB value: social_db - name: POSTGRES_USER value: pantech_user - name: POSTGRES_PASSWORD value: SovereignCloudSecurePass2026! ports: - containerPort: 5432 name: postgres volumeMounts: - name: postgres-data mountPath: /var/lib/postgresql/data volumeClaimTemplates: - metadata: name: postgres-data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 10Gi --- apiVersion: v1 kind: Service metadata: name: postgres namespace: social-media spec: selector: app: postgres ports: - port: 5432 targetPort: 5432 --- # Redis In-Memory Cache apiVersion: apps/v1 kind: Deployment metadata: name: redis namespace: social-media spec: replicas: 1 selector: matchLabels: app: redis template: metadata: labels: app: redis spec: containers: - name: redis image: redis:7.2-alpine ports: - containerPort: 6379 name: redis --- apiVersion: v1 kind: Service metadata: name: redis namespace: social-media spec: selector: app: redis ports: - port: 6379 targetPort: 6379 --- # Backend REST API Service apiVersion: apps/v1 kind: Deployment metadata: name: api-service namespace: social-media spec: replicas: 2 selector: matchLabels: app: api-service template: metadata: labels: app: api-service spec: containers: - name: api image: node:20-alpine command: ["sh", "-c"] args: - | cat << 'EOF' > server.js const http = require('http'); const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }); if (req.url === '/health') { res.end(JSON.stringify({ status: 'healthy', database: 'connected', cache: 'connected', timestamp: new Date() })); } else if (req.url === '/api/feed') { res.end(JSON.stringify({ platform: 'Pantech Sovereign Cloud', posts: [ { id: 1, author: 'Ada Lovelace', content: 'Deployed our sovereign Kubernetes cluster in af-abj-2!', likes: 42 }, { id: 2, author: 'Cloud Architect', content: 'PostgreSQL 16 and Redis 7 running at 1ms latency.', likes: 19 } ] })); } else { res.end(JSON.stringify({ message: 'Social Media API online' })); } }); server.listen(8080, () => console.log('API listening on 8080')); EOF node server.js ports: - containerPort: 8080 --- apiVersion: v1 kind: Service metadata: name: api-service namespace: social-media spec: type: NodePort selector: app: api-service ports: - port: 8080 targetPort: 8080 nodePort: 30085 --- # Frontend Web UI (Next.js / HTML5) apiVersion: apps/v1 kind: Deployment metadata: name: web-ui namespace: social-media spec: replicas: 2 selector: matchLabels: app: web-ui template: metadata: labels: app: web-ui spec: containers: - name: web image: nginx:alpine command: ["sh", "-c"] args: - | cat << 'EOF' > /usr/share/nginx/html/index.html Pantech Social Media

Pantech Social Platform

Live on K8s

Sovereign Kubernetes deployment running in VPC af-abj-2.

Ada Lovelace · 5m ago

PostgreSQL 16 StatefulSet with automated PVC storage mounted successfully.

Platform Engineer · 12m ago

Redis 7.2 timeline cache operating with sub-millisecond response time.

EOF nginx -g 'daemon off;' ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: web-ui namespace: social-media spec: type: NodePort selector: app: web-ui ports: - port: 80 targetPort: 80 nodePort: 30084 ``` Apply the manifest using `kubectl`: ```console $ kubectl apply -f social-media-app.yaml namespace/social-media created statefulset.apps/postgres created service/postgres created deployment.apps/redis created service/redis created deployment.apps/api-service created service/api-service created deployment.apps/web-ui created service/web-ui created ``` Verify that all pods reach the `Running` state: ```console $ kubectl get pods -n social-media -o wide NAME READY STATUS RESTARTS AGE IP NODE api-service-789bfb8d5d-9kx2j 1/1 Running 0 45s 10.244.1.25 production-apps-worker-1 api-service-789bfb8d5d-m4pl9 1/1 Running 0 45s 10.244.2.14 production-apps-worker-2 postgres-0 1/1 Running 0 45s 10.244.1.24 production-apps-worker-1 redis-6c58fb944f-q8n9v 1/1 Running 0 45s 10.244.2.13 production-apps-worker-2 web-ui-54bf4d7bc9-5x289 1/1 Running 0 45s 10.244.1.26 production-apps-worker-1 web-ui-54bf4d7bc9-92kl1 1/1 Running 0 45s 10.244.2.15 production-apps-worker-2 ``` --- ## Step 4: Expose Services & Port Forwarding By default, workloads running in a VPC subnet have only private IP addresses. To access your services from the internet or your web browser, Pantech Cloud provides three exposure options. ### Option A: Automated Sync via the Console Pantech Cloud features automated public routing synchronization for Kubernetes clusters. 1. Navigate to **Kubernetes** in the console and open your cluster. 2. Select the **Workloads & Services** tab. 3. In the **Public Network Routing & Port Forwarding** card, click **Sync Public Routing**. 4. The platform automatically scans your active NodePort services and synchronizes VPC gateway forwarding rules and firewall ingress entries on your cluster's public gateway IP (for example `102.211.122.155`). ### Option B: Manual Gateway Port Forwarding via Static IPs If you wish to configure or customize port forwarding manually: 1. **Locate the Gateway IP in the Console**: - In the left sidebar, click **Network → Static IPs**. - Select the shared Static IP allocated to your cluster or VPC (e.g., `102.211.122.155`). 2. **Add a Port Forwarding Rule**: - In the **Port forwarding** section, click **Add forward**. - **Target VM**: Select one of your cluster's worker node VMs (e.g., `production-apps-worker-1`). - **Protocol**: `TCP`. - **Public Port**: Enter the port you want accessible on the internet (e.g., `8084` for web UI, `8085` for API). - **To VM Port**: Enter the service's `nodePort` defined in your manifest (e.g., `30084` or `30085`). - Click **Add forward**. 3. **Configure Subnet Network ACL**: - Port forwarding passes traffic through the gateway, but the subnet's firewall must allow ingress. - Go to **Network → VPCs** and click your VPC. - Click the subnet hosting your Kubernetes nodes (e.g., `k8s-subnet`). - In the **Network ACL** section, ensure there is an ingress rule permitting inbound traffic on the target port range (e.g., rule allowing TCP on port `30084` and `30085` from `0.0.0.0/0`). Once configured, your application is publicly accessible at: - Web Storefront / UI: `http://102.211.122.155:8084` - REST API Health Endpoint: `http://102.211.122.155:8085/health` ### Option C: Administrative Tunnels via `kubectl port-forward` For development, testing, or database administration without exposing ports to the public internet, use standard `kubectl port-forward`: ```console # Forward PostgreSQL database locally $ kubectl -n social-media port-forward svc/postgres 5432:5432 # Forward Web UI locally $ kubectl -n social-media port-forward svc/web-ui 8080:80 ``` Open `http://localhost:8080` in your browser to view the application securely over the encrypted `kubectl` tunnel. --- ## Step 5: Visual Workload Monitoring with Kubernetes Dashboard Every Pantech Kubernetes cluster includes the official Kubernetes Dashboard pre-installed for rich visual monitoring of workloads, pods, PVCs, and logs. ### 1. Start the local proxy In your terminal, start the secure `kubectl` proxy: ```console $ kubectl proxy Starting to serve on 127.0.0.1:8001 ``` Keep this terminal open while using the dashboard. ### 2. Generate an admin bearer token In a second terminal window, request an authentication token for the `admin-user` service account: ```console $ kubectl -n kubernetes-dashboard create token admin-user ``` Copy the generated bearer token string. ### 3. Open the Dashboard in your browser Open the following URL in your web browser: ``` http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/ ``` Select **Token**, paste the copied bearer token into the prompt, and click **Sign in**. From the Dashboard, you can: - View live CPU and memory utilization per pod and node. - Stream real-time container stdout/stderr logs. - Open interactive shell sessions inside running containers (`exec`). - Edit deployment replicas, ConfigMaps, and secrets in place. --- ## Troubleshooting ### Pods remain in `Pending` state Check if persistent volume claims or node resources are constrained: ```console $ kubectl describe pod -n ``` - **Insufficient CPU/Memory**: If the message says `0/2 nodes are available: 2 Insufficient memory`, scale your worker nodes under **Change nodes** in the console. - **Storage Volume Binding**: Check that your PVC is bound with `kubectl get pvc -n `. ### Public port forward returns connection refused or timeout 1. Verify that your service is of type `NodePort` and that the pods are running on the node targeted by the port forwarding rule: ```console $ kubectl get svc -n $ kubectl get pods -n -o wide ``` 2. Check your VPC subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls) rules. An ingress rule must explicitly permit inbound traffic to the NodePort (e.g. `30080` or `30084`) from `0.0.0.0/0`. 3. Click **Sync Public Routing** in your cluster's **Workloads & Services** tab in the console to verify all gateway port forwarding rules are active. --- # Security groups Source: https://docs.pantechdynamics.com/networking/firewalls The firewall in front of a standard VPS. Create a group of rules, apply it to machines, and narrow it without locking yourself out. ## How a security group works A security group is a set of rules for what can reach your virtual machines, and what they can reach in turn. It applies to [standard VPSes](https://docs.pantechdynamics.com/getting-started/concepts#two-kinds-of-virtual-machine); a VM in a VPC is filtered by its subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls) instead. - **Inbound rules** decide what traffic can reach the machines. Anything no rule allows is blocked. - **Outbound rules** decide what the machines can reach. With none, they cannot reach anything, including package mirrors and your own APIs. - **A machine uses one security group at a time**, and you can only change which one while it is stopped. - **One group can cover many machines**, and editing a rule changes all of them at once. There is no per-machine override. Security groups are free. ## Create a security group Go to **Security groups** and press **Create security group**. 1. **Name**: up to 255 characters. Name it after what it protects, not what it allows: `api-internal`, not `open-8080`. 2. **Inbound rules**: press **Add rule** for each one. A new group starts with none. 3. **Outbound rules**: a new group starts with one rule allowing all traffic out, which most machines need. The summary says whether the group is **Exposed to the internet** or **Internal only**. A group needs at least one rule, inbound or outbound. Press **Create security group**. You can also create one from the **Network** section of the [create form](https://docs.pantechdynamics.com/compute/virtual-machines#4-network), starting from a preset: _Web server_, _Internal service_ or _Locked down_. ## Writing a rule Each rule has four parts: | Field | What to put | | --- | --- | | **Type** | A common service (SSH, HTTP, HTTPS, RDP, MySQL/MariaDB, PostgreSQL, MS SQL, Redis, MongoDB, DNS, SMTP, SMTPS, IMAPS, NFS), which fills in the protocol and port. Or _Custom TCP_ or _Custom UDP_ for any port, or _All TCP_, _All UDP_, _All ICMP_ or _All traffic_ | | **Protocol** | Set by the type | | **Port range** | A port, like `8080`, or a range, like `8000-8100` | | **Source** (inbound) or **Destination** (outbound) | **Anywhere** (`0.0.0.0/0`), **My IP** (the address you are using the console from) or **Custom**, any address or block in CIDR form, like `203.0.113.0/24` | :::warning[Keep login ports off Anywhere] SSH, RDP or a database port open to `0.0.0.0/0` means every host on the internet can try to log in. Key-only login blocks entry but not the attempts. Allow the addresses you work from instead; the console warns you when a rule does this. ::: ## Apply it to a machine A machine has to be **stopped** to change its security group, and the new rules apply when it starts again. - **When you create a machine**, choose the group in the **Network** section. - **From the machine**: power it off, then on its **Networking** tab use **Change security group**. - **From the group**: on its page, under **Applied to**, press **Attach a VM** and choose a stopped machine. It moves off whatever group it had. ## Edit rules Open the group and press **Edit inbound rules** or **Edit outbound rules**. Add, change or delete rows, then **Save rules**. The page tells you which machines the change will reach before you save, and the group shows _Applying…_ until it is in place. If a change does not apply, the group's page says so; check the rules and save again. ## Narrow SSH without locking yourself out 1. Make sure you have another way in: the [serial console](https://docs.pantechdynamics.com/compute/serial-console) works whatever the rules say. 2. Edit the inbound rules and change the SSH rule's source from **Anywhere** to **My IP**, or to the block your office uses. 3. Save, then open a new SSH session before you close the one you have. If your address changes, as it often does on home broadband or mobile data, update the rule, or connect through the serial console to fix things. ## Read a group at a glance The **Security groups** list shows each group's inbound and outbound rules, how many machines use it, and its exposure in plain words, rather than a rule count. "3 rules" tells you nothing about whether you are open to the internet. On a group's page, **What this security group actually allows** spells it out. ## Machines with a private database interface A standard VPS can have a second interface on the private database network, `10.250.0.0/20`, to [reach managed databases](https://docs.pantechdynamics.com/databases/standard-vps). A group applies to every interface of a machine, so while any machine on a group has that interface, the group may not admit **anything** from `10.250.0.0/20`: no inbound rule from `0.0.0.0/0`, `10.0.0.0/8` or an address inside the range, ICMP included. The default group's _ICMP from anywhere_ rule is one of these. [Narrowing a group](https://docs.pantechdynamics.com/databases/standard-vps#step-1-narrow-the-security-group) shows the ranges to use instead of `0.0.0.0/0`. ## Groups as a source for databases You can also attach a group to a [managed database](https://docs.pantechdynamics.com/databases/access-rules#attach-security-groups). There it is only a list of address ranges: the inbound rules that admit TCP on the database's port count, and nothing is opened on any machine. ## Delete a security group On the group's page, press **Delete** and confirm. The dialog says how many machines use it right now; move them to another group first. A group attached to a database cannot be deleted either: detach it from the database first. Deleting cannot be undone. --- # VPCs and subnets Source: https://docs.pantechdynamics.com/networking/private-networks A private network for virtual machines that talk to each other. Plan the address range, add subnets, and launch machines into them. ## What a VPC is A VPC is a private network. Virtual machines in it talk to each other over private addresses, and reach the internet through the VPC. - A VPC is divided into **subnets**. Every VM in a VPC lives in one subnet, chosen when it is created, for its whole life. - Each subnet has a [network ACL](https://docs.pantechdynamics.com/networking/network-acls), which acts as the firewall for every VM in it. - A VM in a VPC has no public address of its own. To reach it from the internet, give it a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses), forward a port to it, or put it behind a [load balancer](https://docs.pantechdynamics.com/networking/load-balancers). - A VPC cannot span regions, and a standard VPS cannot join one. A VPC costs ₦5,000 a month, excluding [VAT](https://docs.pantechdynamics.com/billing/vat), billed by the hour for as long as it exists; subnets are free. Each VPC also takes one of the region's few public addresses for its outbound traffic, so plan on one VPC per environment (production, staging and so on) rather than one per machine. ## Plan the address range A VPC's range is written in CIDR form, like `10.3.0.0/16`. - **Private ranges only**: inside `10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`. - **Between a /24 and a /16**: from 256 to 65,536 addresses. - **Leave room.** Subnets are carved out of this range and cannot overlap, so a /16 with /24 subnets gives you plenty of space to grow. Each subnet must sit inside the VPC's range, be at least a /28, and start on its own boundary: `10.3.1.0/24`, not `10.3.1.5/24`. Five addresses in every subnet are reserved, the gateway among them, so a /24 has 251 usable. ## Create a VPC Go to **VPCs** and press **Create VPC**. 1. **Name** the VPC, like `production`. 2. **IPv4 CIDR block**: the whole range, like `10.3.0.0/16`. A preview shows how much of it the first subnet will use. 3. **First subnet name** and **First subnet block**: like `app` and `10.3.1.0/24`. The form tells you how many usable addresses it holds. The summary shows the region, the range and the cost. Press **Create VPC**. The VPC's page follows the build and fills in when it is ready. ## Add a subnet Put machines with different exposure in different subnets, since every VM in a subnet shares its network ACL: `web` for machines the internet reaches, `db` for ones it should not. On the VPC's page, press **Add subnet**, give it a name and a block inside the VPC's range that does not overlap another subnet, and confirm. A new subnet lets ping in and everything out. All other inbound traffic is blocked until you [add rules](https://docs.pantechdynamics.com/networking/network-acls) to its network ACL. ## Launch a machine into a subnet On the [create form](https://docs.pantechdynamics.com/compute/virtual-machines#1-name-and-region), choose **VM in a VPC**, then under **Network** pick the VPC and the subnet. The machine gets a private address from the subnet and keeps it for life. ## What the pages show - **VPCs** lists each VPC with its range, status, region and how many machines it holds. - A **VPC's page** maps how its range is allocated to subnets, and lists the subnets with their range, usable addresses, gateway and machines. - A **subnet's page** shows its addresses (reserved, in use and free), which machine holds each one, and its network ACL. ## Where is port forwarding configured? Port forwarding is managed per public IP address, rather than on the VPC network itself. To expose private VMs or Kubernetes worker nodes in a VPC to the internet: 1. Go to **Network → Static IPs** in the sidebar. 2. Press **Reserve static IP** and select **Shared, with port forwarding**. 3. Open the reserved address and press **Add forward** to map a public port (such as `2222` or `8080`) to the VM's private IP and port. 4. Ensure the VM's subnet [network ACL](https://docs.pantechdynamics.com/networking/network-acls) permits inbound traffic on the private port. See [Static IPs and port forwarding](https://docs.pantechdynamics.com/networking/ip-addresses#share-an-address-with-port-forwarding) for full configuration details. ## Delete a subnet or a VPC - **A subnet** can be deleted from its page once no machine uses it and no [Kubernetes cluster](https://docs.pantechdynamics.com/kubernetes/overview) runs in it. Destroy or move the machines and delete the clusters first; a subnet that still has clusters is refused with `409 SUBNET_HAS_KUBERNETES_CLUSTERS`. - **A VPC** can be deleted once it has no subnets. Its public address goes back to the pool. This cannot be undone. --- # Network ACLs Source: https://docs.pantechdynamics.com/networking/network-acls The firewall on a VPC subnet. Numbered allow and deny rules, checked lowest first, that apply to every machine in the subnet. ## How a network ACL works Every subnet in a [VPC](https://docs.pantechdynamics.com/networking/private-networks) has a network ACL, and it applies to every virtual machine in that subnet. It differs from a [security group](https://docs.pantechdynamics.com/networking/firewalls) in three ways: - **Rules are numbered**, and checked from the lowest number up. **The first rule that matches decides**; later ones are not looked at. - **Rules can allow or deny.** Anything no rule allows is denied. - **Rules are stateless.** A reply is matched like any other traffic. If an inbound rule lets a request in, the reply still has to get out, and an outbound deny can cut it off. A new subnet allows ping in and everything out. Every other inbound connection is blocked until you add a rule for it. ## Add a rule Open the subnet (from **VPCs**, open the VPC, then the subnet) or a VM's **Networking** tab, and find **Network ACL**. Press **Add rule**: | Field | What to put | | --- | --- | | **Number** | From 100 to 9999, unique in the subnet. Lower numbers are checked first; numbers below 100 are the platform's | | **Action** | **Allow** or **Deny** | | **Direction** | **Inbound** or **Outbound** | | **Protocol** | **TCP**, **UDP**, **ICMP (ping)** or **All traffic** | | **Ports** | A port from 1 to 65535, or a range like `8000-8100` | | **Source** (inbound) or **Destination** (outbound) | An address or block, like `203.0.113.0/24`. `0.0.0.0/0` is anywhere. **Your IP** and **Anywhere** fill it in for you | The rule applies to every VM in the subnet within seconds. ## Leave gaps between numbers Number rules in steps, such as 100, 200, 300, so you can slot a new rule between two old ones later. ## Edit a rule To modify an existing rule, click the **Edit** icon on its row in the Network ACL table. You can adjust the rule number, action, direction, protocol, port ranges, and CIDR block in place. The console updates the rule seamlessly while preserving your desired ordering. ## Example: a web subnet | # | Action | Direction | Protocol | Ports | Source | | --- | --- | --- | --- | --- | --- | | 100 | Allow | Inbound | TCP | 22 | your office, like `203.0.113.0/24` | | 200 | Allow | Inbound | TCP | 80 | `0.0.0.0/0` | | 300 | Allow | Inbound | TCP | 443 | `0.0.0.0/0` | The platform's allow-all outbound rule lets the replies out. ## Example: a Kubernetes subnet For subnets running Managed Kubernetes clusters or container workloads: | # | Action | Direction | Protocol | Ports | Source | | --- | --- | --- | --- | --- | --- | | 100 | Allow | Inbound | TCP | 22 | your management IP (SSH node access) | | 200 | Allow | Inbound | TCP | 6443 | `0.0.0.0/0` (Kubernetes API server) | | 300 | Allow | Inbound | TCP | 30000-32767 | `0.0.0.0/0` (NodePort microservices & ingress) | ## Example: block one address To shut out one abusive address while keeping a port open to everyone else, give the deny a lower number than the allow: | # | Action | Direction | Protocol | Ports | Source | | --- | --- | --- | --- | --- | --- | | 150 | Deny | Inbound | TCP | 443 | `198.51.100.7/32` | | 300 | Allow | Inbound | TCP | 443 | `0.0.0.0/0` | ## Delete a rule Press delete on the rule's row and confirm. Every VM in the subnet stops matching it within seconds. ## An address is not an open door Giving a VM a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses) makes it reachable, not open. Traffic to it still has to pass the subnet's network ACL, so add the rules before you expect a connection to work. --- # Static IPs and port forwarding Source: https://docs.pantechdynamics.com/networking/ip-addresses Give a VM in a VPC a public address of its own, move it to another VM without losing it, share one address between several VMs by forwarding ports, or put load balancers on it. ## Who needs one **Only VMs in a VPC.** A [standard VPS](https://docs.pantechdynamics.com/getting-started/concepts#two-kinds-of-virtual-machine) comes with its own public IPv4 address and needs nothing here. A VM in a VPC has only a private address. It can reach the internet through its VPC, but nothing on the internet can reach it until you give it a static IP or forward a port to it. There are three ways to use an address, chosen when you reserve it: | Use | What it does | | --- | --- | | **One VM** | Every port of the address goes to one VM. A VM can have one | | **Shared, with port forwarding** | Each forward sends a port or range to one VM, so several VMs share the address | | **Load balancer** | Each [load balancer](https://docs.pantechdynamics.com/networking/load-balancers) on it spreads one port across several VMs in a subnet | Either way, an address makes a VM reachable, not open: traffic still has to pass the subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls). ## Addresses are few Public addresses are limited in each zone. Each VPC takes one for its outbound traffic, and each static IP takes another. When a VM only needs a port or two, such as SSH or a single web port, share one address with port forwarding rather than giving every VM its own. Your organization can also hold at most **20 static IPs** at a time by default, counting every use (one VM, shared with port forwarding, and load balancer) and detached addresses too. Past that, reserving another is refused (in the API, `403 PUBLIC_IP_LIMIT_EXCEEDED`). Release an address you no longer need, share one with port forwarding, or [contact support](https://docs.pantechdynamics.com/help/support-tickets) to raise the limit. If the zone has none left, reserving one says so. Release an address you no longer need, or share one. ## Give one VM an address The quickest way is from the VM: on its **Networking** tab, next to **Public IPv4**, press **Add static IP**. Or from **Static IPs**, press **Reserve static IP**: 1. **VPC**: an address belongs to one VPC and can only reach VMs in it. 2. **Use**: **One VM**. 3. **VM**: choose the machine. Only VMs that do not already have an address are listed. Confirm. The address's page follows the allocation, and once it is done the address shows on the VM's **Networking** tab and **Overview**. The VM keeps its private address as well. ### Reserve one for later A **One VM** address does not need a VM yet. Leave **VM** empty and the address is reserved, **detached**, ready to [attach](#move-an-address-to-another-vm) when the machine exists. Use it to put the address in DNS or an allow-list before the server is built. A detached address is billed like any other: see [what it costs](#what-it-costs). ## Move an address to another VM A **One VM** address is not tied to the machine it points at. Detach it and it stays yours, with the same IP; attach it to another VM in the same VPC and traffic to the address goes there instead. Use it to: - **Replace a server without changing its address.** Build the new VM, test it on its private address, then move the address to it. DNS records and anyone's allow-list keep working. - **Keep an address while you rebuild.** Detach it, destroy the old VM, create the new one, attach it again. To move an address: 1. Open the address from **Static IPs**. 2. Press **Attach to VM** and choose the machine. Only VMs in the address's VPC without a static IP of their own are listed. If the address points at another VM, it moves: that VM keeps running on its private address. 3. Confirm. To take it off its VM without choosing another, press **Detach** on the same page. The address shows as **Detached** until you attach it again or release it. While a change applies, which takes a few seconds, the address shows **Applying** (in the API, `in_sync: false`) and traffic to it may drop. Attaching an address to the VM it already points at, or detaching one that is already detached, changes nothing. The rules: - Only a **One VM** address moves. A shared address's forwards and a load balancer's targets are changed on their own pages instead (in the API, `409 PUBLIC_IP_NOT_STATIC_NAT`). - A VM has at most one static IP of its own. Detach or release the one it has before attaching another (in the API, `409 INSTANCE_ALREADY_HAS_PUBLIC_IP`). - The VM must be in the address's VPC (in the API, `422 VALIDATION_FAILED` with `INSTANCE_NOT_IN_NETWORK` on `instance_id`). - The address must be ready, and not being released (in the API, `409 INVALID_RESOURCE_STATE`). ### With the CLI With the [CLI](https://docs.pantechdynamics.com/cli), name the address by its IP or its id, and the VM by its name or id: ```console $ pantech public-ips create --network prod $ pantech public-ips attach 102.211.122.80 --vm web-2 $ pantech public-ips detach 102.211.122.80 $ pantech public-ips list ``` `create` without `--vm` reserves a detached address. `attach` and `detach` wait until the change has reached the address; pass `--no-wait` to get the operation's id straight away. `pantech public-ips list` and `get` show which VM each address points at, or that it is detached, and whether a change is still applying. ### With the API Using the `pt` and `wait_op` helpers from the [API examples](https://docs.pantechdynamics.com/api/examples), with the address in `$PIP` and the new VM in `$VM`: ```sh source pantech.sh # Point the address at $VM. If it pointed at another VM, it moves. OP=$(pt POST "/public-ips/$PIP/attach" "$(jq -n --arg v "$VM" '{instance_id: $v}')") OPID=$(echo "$OP" | jq -r .operation_id) [ -n "$OPID" ] && wait_op "$OPID" # an empty operation_id means nothing changed # Take it off its VM. It stays yours, with instance_id null. OP=$(pt POST "/public-ips/$PIP/detach") OPID=$(echo "$OP" | jq -r .operation_id) [ -n "$OPID" ] && wait_op "$OPID" pt GET "/public-ips/$PIP" | jq '{address, instance_id, in_sync}' ``` To reserve an address with no VM, leave `instance_id` out: `pt POST /public-ips "$(jq -n --arg n "$NET" '{network_id: $n}')"`. See [attach](https://docs.pantechdynamics.com/api/public-ips#attach-a-public-ip-to-an-instance) and [detach](https://docs.pantechdynamics.com/api/public-ips#detach-a-public-ip-from-its-instance) in the API reference. ### With Terraform With the [Terraform provider](https://docs.pantechdynamics.com/terraform), `instance_id` on a `static_nat` address can change in place: point it at another VM to attach the address there, or remove it to detach the address. Terraform keeps the address rather than replacing it, and waits until the change has applied. ```hcl title="main.tf" resource "pantechdynamics_public_ip" "web" { network_id = pantechdynamics_network.main.id instance_id = pantechdynamics_instance.web_v2.id # was web_v1: the address moves } ``` Leave `instance_id` out to reserve a detached address. `in_sync` is `false` while a change is still applying. A `port_forwarding` or `load_balancer` address takes no `instance_id`. ## Share an address with port forwarding Port forwarding lets multiple virtual machines (or Kubernetes worker nodes) inside a VPC share a single public IP address by mapping different public ports to private machine ports. :::note[Where to find port forwarding in the console] In the Pantech console, port forwarding rules are attached to an allocated **Static IP**, located under **Network → Static IPs** in the left sidebar. Port forwarding is not configured on the VPC network page itself. ::: To set up port forwarding: 1. **Reserve a shared address**: In the console sidebar, go to **Network → Static IPs** and press **Reserve static IP**. - Select your **VPC**. - Under **Use**, select **Shared, with port forwarding**. - Press **Reserve static IP**. The address is allocated from the region's public pool within seconds. 2. **Open the address**: On the **Static IPs** list, click the reserved address (for example `102.211.122.155`). 3. **Add a forward rule**: In the **Port forwarding** card, press **Add forward**: - **Target VM**: Select the machine in the VPC (a standard VM or a Kubernetes worker node). - **Protocol**: **TCP** or **UDP**. - **Public ports**: The port exposed to the internet, such as `2222`, `8080`, or a range like `8000-8100`. - **To VM port**: The internal port the service listens on on the machine, such as `22` for SSH or `30080` for a Kubernetes NodePort service. - Press **Add forward**. 4. **Allow the port in the subnet's network ACL**: Port forwarding directs incoming packets to the VM's private IP, but the traffic is still subject to the subnet's firewall. Go to **Network → VPCs**, click your VPC, select the subnet holding the VM, and ensure its [network ACL](https://docs.pantechdynamics.com/networking/network-acls) has an ingress rule allowing traffic to the private port (for example, protocol TCP, port `30080`, source CIDR `0.0.0.0/0`). With a forward from `2222` to a VM's `22`, connect with (as `ubuntu` on Ubuntu, `debian` on Debian): ```console $ ssh -i ~/.ssh/ada-macbook-pro.pem -p 2222 ubuntu@ ``` A range keeps its width on the VM: forwarding `8000-8010` to `9000` sends to `9000-9010`. Public ranges cannot overlap on the same protocol, but the same port on TCP and on UDP is fine. To remove a forward, press **Delete** next to the rule on the address's page. ## Put load balancers on an address To spread a port across several VMs rather than send it to one, reserve an address with the use **Load balancer**, then create [load balancers](https://docs.pantechdynamics.com/networking/load-balancers) on it, one per public port. Such an address carries nothing else: no VM of its own, no port forwards. It is billed like any other static IP; the load balancers on it cost nothing more. ## What it costs A static IP is billed monthly from the moment it is reserved until you release it, whatever it is used for and whether it points at a VM or not. Moving it between VMs does not bill it again: it is one address, billed once for as long as you hold it. Prices exclude [VAT](https://docs.pantechdynamics.com/billing/vat), which is added to your end-of-period invoice. A **detached** address is still billed. If you no longer need it, release it. ## Release an address Releasing gives the address back to the shared pool, and you will almost certainly not get the same one back. Anything still pointing at it, such as DNS records or someone's allow-list, stops working. The VM keeps running on its private address. To keep the address for another VM, [detach](#move-an-address-to-another-vm) it instead; it stays yours, and billed, until you release it. Delete a shared address's forwards first, and an address's load balancers first; until then, releasing it is refused (in the API, `409 PUBLIC_IP_HAS_PORT_FORWARDS` or `409 PUBLIC_IP_HAS_LOAD_BALANCERS`). Then on the address's page press **Release**, and type the address to confirm. ## Destroying a VM with an address A VM that a static IP points at cannot be destroyed until the address is off it (in the API, `409 INSTANCE_HAS_PUBLIC_IP`). [Detach](#move-an-address-to-another-vm) the address to keep it for another VM, or release it if you are done with it, then destroy the VM. The same goes for port forwards to the VM (`409 INSTANCE_HAS_PORT_FORWARDS`): delete them first. A VM that is a load balancer's target cannot be destroyed until you remove it from the load balancer's targets (in the API, `409 INSTANCE_BEHIND_LOAD_BALANCER`). ## Errors | In the API | Means | What to do | | --- | --- | --- | | `403 PUBLIC_IP_LIMIT_EXCEEDED` | Your organization holds as many static IPs as it may, 20 by default, detached ones included | Release one, or contact support | | `409 PUBLIC_IP_NOT_STATIC_NAT` | Attach and detach are for **One VM** addresses only | Change a shared address's forwards, or a load balancer's targets, instead | | `409 INSTANCE_ALREADY_HAS_PUBLIC_IP` | The VM already has a static IP of its own | Detach or release that one first | | `422 INSTANCE_NOT_IN_NETWORK` | The VM is not in the address's VPC | Choose a VM in the same VPC | | `409 INSTANCE_HAS_PUBLIC_IP` | Destroying a VM that a static IP still points at | Detach the address, or release it, then destroy the VM | | `409 PUBLIC_IP_HAS_PORT_FORWARDS`, `409 PUBLIC_IP_HAS_LOAD_BALANCERS` | Releasing an address that still carries forwards or load balancers | Delete them first | --- # Load balancers Source: https://docs.pantechdynamics.com/networking/load-balancers Spread incoming traffic across virtual machines with VPC-native and appliance load balancers. ## What a load balancer is A load balancer takes incoming connections and distributes them across several virtual machines, its **targets**, so traffic keeps flowing when one of them is busy or down for maintenance. Pantech offers two kinds of load balancers: - **VPC-native (`vpc_native`)**: for virtual machines in a [VPC subnet](https://docs.pantechdynamics.com/networking/private-networks). It takes a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses) reserved for load balancing and spreads connections to one public port across targets in that subnet. - **Appliance (`appliance`)**: for standard virtual machines in a security group zone. It is a load balancer VM with its own public IP address, created for you and billed as that VM, and it spreads one TCP port across targets in the same zone. ## Two architectures | Feature | VPC-native (`vpc_native`) | Appliance (`appliance`) | | --- | --- | --- | | **Zone** | VPC-enabled zone (`af-abj-2`) | Standard security group zone (`af-abj-1`) | | **Address** | You reserve a static IP with purpose `load_balancer` | Automatically allocated by the platform | | **Protocols** | `tcp` | `tcp` | | **Firewall / Security** | Subnet [network ACL](https://docs.pantechdynamics.com/networking/network-acls) | [Security groups](https://docs.pantechdynamics.com/networking/firewalls) | | **Network** | Bound to one VPC subnet | Standard public/guest network | | **Ports** | One public port to one target port | Configured public to target ports | ## Set one up ### Option A: Set up a VPC-native load balancer You need a VPC with a subnet, and the machines to balance across already running in that subnet. ### 1. Reserve a static IP for load balancing On **Static IPs**, press **Reserve static IP**, choose the VPC, and choose **Load balancer** as its use. Wait for the address to be ready. It can carry load balancers for any subnet of that VPC. ### 2. Create the load balancer Go to **Load balancers** and press **Create load balancer**: 1. **Name**: 1 to 63 characters, like `web`. Press **Generate** if you want a random memorable name. 2. **Kind**: choose **VPC-native**. 3. **Static IP**: an address reserved for load balancing. 4. **Subnet**: the subnet your targets are in. 5. **Public port**: the port clients connect to, like `443`. No other load balancer on the address may use it. 6. **Target port**: the port your app listens on, on every target. It defaults to the public port. 7. **Algorithm**: how a target is picked for each connection. See [algorithms](#algorithms). 8. **Allowed sources** (optional): up to 20 IPv4 blocks, like `203.0.113.0/24`, that may connect. Leave it empty for anywhere. 9. **Targets**: the VMs in the subnet to send traffic to. You can add them later. Confirm. The load balancer's page follows the change, and shows each target as **active** once it receives traffic. ### 3. Let the traffic into the subnet The subnet's [network ACL](https://docs.pantechdynamics.com/networking/network-acls) still decides what reaches the machines. Add an inbound rule that allows **TCP** on the **target port**: | # | Action | Direction | Protocol | Ports | Source | | --- | --- | --- | --- | --- | --- | | 200 | Allow | Inbound | TCP | 8443 | `0.0.0.0/0` | Without it, the load balancer is in place but connections never reach your app. To limit who can connect, use the load balancer's allowed sources. --- ### Option B: Set up an application (appliance) load balancer For virtual machines running in a standard security group zone: 1. Go to **Load balancers** and press **Create load balancer**. 2. **Name**: 1 to 63 characters, or press **Generate**. 3. **Kind**: choose **Appliance**. 4. **Zone**: choose the standard zone (`af-abj-1`). 5. **Protocol**: choose `http`, `https`, or `tcp`. 6. **Public port** and **Target port**: the port clients connect to and the port your app listens on. 7. **Algorithm**: choose `roundrobin`, `leastconn`, or `source`. 8. **Targets**: choose the running virtual machines in the zone. Confirm. The platform allocates a dedicated public IP address and provisions the appliance. Inbound connections to the assigned public IP will be routed to your backend machines according to the chosen algorithm. Ensure that target machines allow inbound traffic from the appliance in their [security group](https://docs.pantechdynamics.com/networking/firewalls). ## Algorithms | Algorithm | How it picks a target | Good for | | --- | --- | --- | | **Round robin** (`roundrobin`, the default) | Each in turn | Targets of the same size serving short requests, like most web apps | | **Least connections** (`leastconn`) | The one with the fewest open connections | Long or uneven connections, like WebSockets, uploads or database proxies | | **Source** (`source`) | Always the same one for the same client address, while the targets stay the same | Apps that keep a session on the machine rather than in a shared store | You can change the algorithm at any time; it applies to new connections. ## Change the targets On the load balancer's page, under **Targets**, add or remove VMs. Every target must be in the load balancer's subnet (for VPC-native) or zone (for appliance), and there can be at most 50. A removed VM keeps running; it just stops getting new connections from the load balancer. To take a machine down for maintenance, remove it from the targets first, then add it back when it is ready. You can also rename a load balancer. Everything else is fixed: see [limits](#limits). ## Limits - **Up to 50 targets**, all in the load balancer's subnet or zone. - **Up to 20 allowed sources**, as IPv4 blocks. - **Fixed once created**: the zone, kind, static IP, subnet, ports and allowed sources. To change any of them, create a new load balancer and delete the old one. - **Changeable**: the name, the algorithm and the targets. ## Deleting, and what it blocks Delete a load balancer from its page. Traffic to its port stops once it is gone. For VPC-native load balancers, the targets and the static IP stay in your account. While a load balancer exists, two things are refused: - **Destroying a VM that is one of its targets.** Remove it from the targets first. The API answers `409 INSTANCE_BEHIND_LOAD_BALANCER`. - **Releasing its static IP.** Delete the address's load balancers first. The API answers `409 PUBLIC_IP_HAS_LOAD_BALANCERS`. ## Billing - **VPC-native load balancers**: There is no extra charge for the load balancer itself. The static IP it uses is billed like any other [static IP](https://docs.pantechdynamics.com/networking/ip-addresses), monthly, whether it carries one load balancer or several. The targets are billed as the machines they are. - **Appliance load balancers**: The appliance VM and public IP are billed as standard compute and network resources while active. Target virtual machines are billed as their own plans. ## With the CLI With the [CLI](https://docs.pantechdynamics.com/cli#load-balancers), and the subnet's id in place of `snet_…`: ```console $ pantech public-ips create --network main --purpose load_balancer $ pantech load-balancers create --name web --public-ip 102.211.122.80 --subnet snet_… \ --port 443 --private-port 8443 --target web-1,web-2 $ pantech load-balancers update web --targets web-1,web-2,web-3 $ pantech load-balancers get web $ pantech load-balancers delete web ``` `--algorithm` takes `roundrobin` (the default), `leastconn` or `source`, and `--allow` limits the source ranges. `update --targets` replaces the whole set; `--no-targets` removes them all. The subnet's network ACL must still allow the target port. ## With the API Every step is in the [load balancers API](https://docs.pantechdynamics.com/api/load-balancers). Using the `pt` and `wait_op` helpers from the [API examples](https://docs.pantechdynamics.com/api/examples), with a VPC in `$NET`, its subnet in `$SUBNET` and two VMs in it in `$VM1` and `$VM2`: ```sh source pantech.sh # 1. A static IP for load balancing. It takes no instance. OP=$(pt POST /public-ips "$(jq -n --arg n "$NET" '{network_id: $n, purpose: "load_balancer"}')") PIP=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" # 2. The load balancer: 443 on the address to 8443 on each target (VPC-native). OP=$(pt POST /load-balancers "$(jq -n --arg p "$PIP" --arg s "$SUBNET" --arg a "$VM1" --arg b "$VM2" \ '{name: "web", kind: "vpc_native", public_ip_id: $p, subnet_id: $s, public_port: 443, private_port: 8443, algorithm: "roundrobin", instance_ids: [$a, $b]}')") LB=$(echo "$OP" | jq -r .resource_id); wait_op "$(echo "$OP" | jq -r .operation_id)" # Or create an application load balancer in a standard zone: OP_APP=$(pt POST /load-balancers "$(jq -n --arg z "af-abj-1" --arg a "$VM1" \ '{name: "app-lb", kind: "appliance", zone_id: $z, public_port: 80, private_port: 80, algorithm: "roundrobin", instance_ids: [$a]}')") LB_APP=$(echo "$OP_APP" | jq -r .resource_id); wait_op "$(echo "$OP_APP" | jq -r .operation_id)" # 3. Let the target port into the subnet. OP=$(pt POST "/subnets/$SUBNET/firewall-rules" \ '{"number": 200, "protocol": "tcp", "port_start": 8443, "cidr": "0.0.0.0/0"}') wait_op "$(echo "$OP" | jq -r .operation_id)" pt GET "/load-balancers/$LB" | jq '{address: .public_ip_address, observed_state, members}' ``` To change the targets, send the whole new set; leaving `instance_ids` out keeps them as they are, and `[]` removes them all: ```sh OP=$(pt PATCH "/load-balancers/$LB" "$(jq -n --arg a "$VM1" '{instance_ids: [$a]}')") OPID=$(echo "$OP" | jq -r .operation_id) [ -n "$OPID" ] && wait_op "$OPID" # an empty operation_id means nothing changed ``` The refusals to expect: `409 PUBLIC_IP_NOT_FOR_LOAD_BALANCER` (the address is not an active one reserved for load balancing), `409 PORT_IN_USE` (another load balancer on the address has that port), `409 LOAD_BALANCER_NOT_CHANGEABLE` (it is being deleted, or has failed), and `422 VALIDATION_FAILED` naming the field, for example a target outside the subnet. ## With Terraform With the [Terraform provider](https://docs.pantechdynamics.com/terraform): ```hcl title="main.tf" resource "pantechdynamics_network" "main" { name = "main" cidr = "10.0.0.0/16" } resource "pantechdynamics_subnet" "web" { network_id = pantechdynamics_network.main.id name = "web" cidr = "10.0.1.0/24" } resource "pantechdynamics_instance" "web" { count = 2 name = "web-${count.index + 1}" plan_slug = "individual" image_slug = "ubuntu-24-04" ssh_key_id = pantechdynamics_ssh_key.admin.id subnet_id = pantechdynamics_subnet.web.id } # An address for load balancing takes no instance. resource "pantechdynamics_public_ip" "lb" { network_id = pantechdynamics_network.main.id purpose = "load_balancer" } # The subnet's network ACL must let the target port in. resource "pantechdynamics_firewall_rule" "app" { subnet_id = pantechdynamics_subnet.web.id number = 200 protocol = "tcp" port_start = 8443 cidr = "0.0.0.0/0" } resource "pantechdynamics_load_balancer" "web" { name = "web" public_ip_id = pantechdynamics_public_ip.lb.id subnet_id = pantechdynamics_subnet.web.id public_port = 443 private_port = 8443 # defaults to public_port algorithm = "roundrobin" # or "leastconn", "source" # cidr_list = ["203.0.113.0/24"] # leave out for anywhere instance_ids = pantechdynamics_instance.web[*].id } output "address" { value = pantechdynamics_load_balancer.web.public_ip_address } ``` - Changing `name`, `algorithm` or `instance_ids` updates the load balancer in place. - Changing `public_ip_id`, `subnet_id`, `public_port`, `private_port` or `cidr_list` replaces it: Terraform deletes it and creates a new one, and the port is closed in between. - Import one with its id: `terraform import pantechdynamics_load_balancer.web lb_06gk3x9tq7m2v8r4y1c5d0zp8w`. --- # How billing works Source: https://docs.pantechdynamics.com/billing/how-billing-works Credit, the upfront payment for a new VM or database, hourly usage and the invoice at the end of each period, and what costs money while it sits idle. ## The short version 1. Your organization holds a **credit balance**, which you top up by card, or by bank transfer if you are billed in naira. 2. **Creating a virtual machine or database takes an upfront payment** of 40% of its monthly estimate. It comes from your credit if your credit covers the whole amount, otherwise your saved card is charged the whole amount. It is never split between the two. Kubernetes nodes take no upfront payment; see [Kubernetes clusters](#kubernetes-clusters). 3. **Usage is metered by the hour**, for every started hour, and rated through the billing period. There is no monthly cap: you pay for the hours you use. 4. **When the period closes, usage is invoiced**, with [VAT](https://docs.pantechdynamics.com/billing/vat) of 7.5% added on top. Your upfront payments are deducted from the invoice, not added on top. Your credit comes off what is left, and your saved card is charged for the rest. VAT is charged on that invoice only: not on activation credits, top-ups or upfront payments. Prices and estimates exclude VAT. See [VAT](https://docs.pantechdynamics.com/billing/vat). The **Billing** page shows your balance, what you have spent this period, what it projects you will spend by the end of it, and when the period closes. ## Your billing date A period closes, and its invoice is issued, on your billing date: the last day of the month, or a fixed day of the month. Owners and Admins can change it: on **Billing**, open the **Billing date** card, choose **The last day of the month** or **A fixed day of the month**, and save. The new date starts after the current period, so nothing is billed twice; until then the card shows it as pending. ## Your currency The currency is set by the country your organization is registered in, when you set it up, and cannot be changed afterwards. Organizations billed in naira pay through Flutterwave; see [paying in naira](https://docs.pantechdynamics.com/billing/paying-in-naira). ## Activation credits To activate a new organization you add **activation credits** to its credit balance: ₦1,000 if it is registered in Nigeria, billed in naira, or $1 anywhere else, billed in US dollars. This is not a fee, and carries no VAT. Activation credits are credit like any top-up, and pay for anything billed to the organization: virtual machines, databases, Kubernetes clusters and invoices. They do not expire while your account is active. Like all credit, they cannot be refunded or withdrawn as cash, and they are lost if the organization is deleted. ## Virtual machines The create form's summary shows three figures before you commit, all excluding VAT. The database form shows the same ones; the Kubernetes form shows **Upfront payment: None** (see [Kubernetes clusters](#kubernetes-clusters)). - **Upfront payment (40%)**: 40% of what the machine costs if it runs all month, paid when you press **Create VM**. If your credit covers the whole amount it is taken from there; otherwise your saved card is charged. With no saved card and too little credit, the machine is not created. The payment is deducted from your next bill, not added on top, and anything left over carries to the bills after it. If the build fails, the part not yet used goes back to your credit balance, never to your card. - **If it runs all month**: an estimate for running it around the clock for an average month of 730 hours. It is not a cap: you are billed for the hours it runs, so a 31-day month at 24/7 costs a little more, and a machine that runs for part of a month costs less. - **If it’s stopped all month**: disk storage only, because a stopped machine keeps its disk and your data. Depending on the size, that is roughly a sixth to a quarter of the running price. Billing is by the hour, so you are not committing to a month. vCPU, memory and the machine’s IPv4 address are billed only while it runs; its disk is billed for as long as it exists, running or stopped. Deleting a machine stops its billing, and the part of its upfront payment not yet deducted from a bill goes back to your credit balance, where it shows in **Transactions** as **Upfront payment returned after VM deleted**. A [database](https://docs.pantechdynamics.com/databases/pricing)'s upfront payment works the same way: it goes back to your credit balance if the build fails, and when you delete a database, the part not yet deducted from a bill goes back to your credit balance too, never to your card. The hours it ran up to the delete are still billed. ### An example A **Starter** machine (1 vCPU, 2 GB memory, 25 GB disk) shows: | Figure | Amount | | --- | --- | | Upfront payment (40%) | ₦6,813.60 | | If it runs all month | ≈ ₦17,034 | | If it’s stopped all month | ₦4,200 | You create it, paying ₦6,813.60 from your credit, and delete it after 10 days of running around the clock. You pay for those 240 hours, about ₦5,600. The upfront payment covers that, and the remaining ₦1,213 or so goes back to your credit balance. ## Kubernetes clusters A [Kubernetes cluster](https://docs.pantechdynamics.com/kubernetes/overview) has no fee of its own. Each of its nodes, control and worker, is a VM of the cluster's node plan and is billed as one, by the hour, so a cluster of 1 control node and 2 workers costs three VMs. **Nothing is paid up front**, neither when you create a cluster nor when you add workers. Your account's funding is checked instead. To create a cluster you need a saved card, or enough credit to cover 40% of a month for every node it can run: its control nodes plus its workers, or the autoscaling maximum. Adding workers, or raising the maximum, is checked the same way for the nodes added. Each node is priced as a VM of the node plan without a public IP. Without either, the change is refused with `402 INSUFFICIENT_CREDIT` and nothing is built. - **Volume storage**: the storage your workloads claim through persistent volumes is billed as storage, with the nodes' disks, by the hour for as long as the volumes exist. The cluster's page shows it as **Volume storage**. - **Autoscaling**: you pay for the workers the autoscaler runs, hour by hour. Your funding is checked for the maximum number of workers when you turn autoscaling on or raise the maximum, so the cluster can always grow to it. - A **stopped** cluster pays for its nodes' disks and its volume storage. - A **public or dual** cluster's own public address is billed as a [static IP](https://docs.pantechdynamics.com/networking/ip-addresses#what-it-costs), by the hour, for as long as the cluster holds it. A private cluster has none. - **Deleting** a cluster stops its billing at once; its nodes are destroyed within minutes. ## What costs money while idle The overview's **Needs attention** panel adds these up as _idle spend_: | Resource | Billed | | --- | --- | | A stopped virtual machine | At its stopped rate, for its disk | | A stopped Kubernetes cluster | At its nodes' stopped rate, for their disks, plus its volume storage | | A volume | For its full size, from the moment it exists until you delete it, attached or not | | A [VPC](https://docs.pantechdynamics.com/networking/private-networks) | ₦5,000 a month, by the hour, for as long as it exists | | A [static IP](https://docs.pantechdynamics.com/networking/ip-addresses#what-it-costs) | Monthly, from when it is reserved until you release it, pointing at a VM or detached. Moving it between VMs does not bill it twice | | A snapshot | Until it is removed, at the storage rate, for its physical size: the space it actually takes up | Destroy what you no longer need, rather than stopping it. ## What is free Subnets, security groups, network ACLs, load balancers, the Kubernetes control plane (beyond its nodes) and the serial console cost nothing. The static IP a load balancer uses is billed as a static IP. ## Related ::::cards :::card[Add funds]{href=/billing/adding-funds} Top up your credit by card or, in naira, by bank transfer. ::: :::card[VAT]{href=/billing/vat} 7.5%, on the end-of-period invoice only. ::: :::card[Payment methods]{href=/billing/payment-methods} The card charged when credit runs out. ::: :::card[Usage and transactions]{href=/billing/usage-and-transactions} Where the money went. ::: :::: --- # Add funds Source: https://docs.pantechdynamics.com/billing/adding-funds Top up your organization's credit with your saved card, or through Flutterwave's checkout for accounts billed in naira. ## Why top up Credit pays the upfront payment when you create a virtual machine or database, as long as it covers the whole amount, and comes off each invoice before your card is charged. An upfront payment comes back to your credit if the build fails, and so does the unused part when you delete a virtual machine or database. Without a saved card, credit is also what lets you create a Kubernetes cluster. Topping up ahead means fewer card charges and nothing held up for want of a payment. See [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). Credit added to your balance **cannot be refunded**, so add what you expect to use. A top-up carries **no VAT**: the full amount goes on your balance. VAT is added to your end-of-period invoice instead, and credit applied to it pays the total, VAT included. See [VAT](https://docs.pantechdynamics.com/billing/vat). ## Add funds 1. Go to **Billing** and press **Add funds**, or press **⌘K** and type "add funds". 2. Choose an amount, or type your own under **Or enter an amount**. The minimum is $5, or ₦5,000. 3. The summary shows your current balance, the payment and your balance after it. Press **Pay**. 4. Confirm. A saved card is charged straight away; Flutterwave's checkout opens for you to finish paying. How you pay depends on your account: | Account | How it is paid | | --- | --- | | Billed in dollars | Your saved card, the one you paid your activation credits with when you set up billing | | Billed in naira | Flutterwave's checkout, which opens in the console. See [paying in naira](https://docs.pantechdynamics.com/billing/paying-in-naira) | ## If your bank asks to check Some banks confirm a card payment themselves, usually with a code sent to your phone. Their check opens in a window on the page. Finish it there and the window closes by itself once the payment goes through. If it does not load, use **Open it in a new tab**. ## After you pay The page waits for the payment to settle, usually a few seconds: - **Payment successful**: done. The credit is on your balance and comes off your next invoice before your card is charged. **Add more** starts another top-up. - **Still waiting on the payment**: it can take a few minutes. You can leave the page; the credit lands either way, and your balance on **Billing** updates when it does. - **The charge didn't go through**: your balance has not changed. Try again, or [check your card](https://docs.pantechdynamics.com/billing/payment-methods). ![Payment successful: +$25.00 added to your balance, paid with the saved card](https://docs.pantechdynamics.com/images/billing/funds-added.png) Every top-up is listed under [transactions](https://docs.pantechdynamics.com/billing/usage-and-transactions). ## If adding funds is unavailable Some accounts are not set up to top up by card. **Add funds** then says so, and usage is invoiced at the end of each billing period instead. --- # Paying in naira Source: https://docs.pantechdynamics.com/billing/paying-in-naira Organizations registered in Nigeria are billed in naira and pay through Flutterwave's checkout, without leaving the console. ## Who is billed in naira Your currency is set by the country your organization is registered in, chosen in the **Organization** step when you first set up your account. An organization registered in Nigeria is billed in naira. The currency cannot be changed afterwards, so pick the country the organization is actually registered in. Prices on the create forms, the **Billing** page and your transactions are all shown in naira. Prices exclude [VAT](https://docs.pantechdynamics.com/billing/vat), which is 7.5% for naira accounts too, added to the end-of-period invoice. ## Activation credits The **Funding** step of setup activates your account with ₦1,000 in **activation credits**. This is not a fee, and carries no VAT: the full amount goes into your organization's credit balance and pays for what you use, like any other credit. You pay it on Flutterwave's checkout, which opens inside the console, by card or bank transfer. Paying in naira does not save a card. When the payment goes through, the console takes over and your account is active. If you close the checkout before paying, nothing is charged; try again when you are ready. If you paid but the console has not caught up, it can take a few minutes for the payment to reach us, so check again in a moment. ## Adding funds On **Billing**, press **Add funds**: 1. Pick ₦200,000, ₦500,000 or ₦1,000,000, or type your own amount. The minimum is ₦5,000. 2. Press **Pay with Flutterwave** and confirm. 3. Finish paying on Flutterwave's checkout, which opens on the page. It closes by itself once the payment goes through. The credit lands once the payment settles, and comes off your next invoice before your card is charged. A top-up carries no VAT. Credit cannot be refunded. ## Pantech never sees your card Card details go straight to the payment provider. We hold only a token for a saved card, never its number. --- # Payment methods Source: https://docs.pantechdynamics.com/billing/payment-methods The cards saved to your organization, which one is charged, and how to add or remove one. ## Your saved cards If your organization is billed in dollars, the card you pay your activation credits with is saved to it. Organizations billed in naira pay through Flutterwave's checkout, which does not save a card. **Billing** → **Payment methods** lists every saved card with its details, when it was added and its status. Pantech never sees or stores a card number. Card details go straight to our payment provider, and we keep only a token from them. ## The default card The **default** card is the one charged when a payment falls to a card, such as an invoice your credit does not cover, or the upfront payment for a new virtual machine or database when your credit does not cover the whole amount. A saved card also lets you create a Kubernetes cluster without holding credit for its nodes. To change it, open another card's **⋯** menu and choose **Make default**. An expired card cannot be made the default. ## Add a card On **Payment methods**, press **Add card** and enter the card in the secure form. Your bank may ask you to confirm it, usually with a code sent to your phone; that check opens on the page. Once it is done, the card is listed with your others. ## Change a card's number A saved card cannot be edited: we hold a token, not the number. To replace a card, add the new one, make it the default, then remove the old one. ## Remove a card Open the card's **⋯** menu and choose **Remove**, then confirm. It is removed here and from the payment provider. If it was the default, another saved card takes its place. You cannot remove your only card. Add another first. --- # Receipts Source: https://docs.pantechdynamics.com/billing/receipts The receipt you get every time money moves on your account, how to download it from the console or the API, and which Pantech company issues it. ## When you get a receipt A receipt is issued every time money moves on your account: | When | Receipt | | --- | --- | | You pay, including when you add activation credits to set up your account | **Receipt**, marked **Paid** | | You top up your credit | **Receipt**, marked **Paid**, showing the credit added | | An invoice is paid, by card or from your credit | **Receipt**, marked **Paid**, naming the invoice | | Credit is taken for a new VM's or database's upfront payment | **Debit receipt**, marked **Debited** | | Money is refunded to your credit | **Refund receipt**, marked **Refunded**. A refund of an invoice is a credit note showing the refunded subtotal, the [VAT (7.5%)](https://docs.pantechdynamics.com/billing/vat#refunds) refunded and the total | Activation credits, top-ups and upfront payments carry no VAT, so neither do their receipts. VAT is on the invoice, and the receipt for paying an invoice shows the amount paid, VAT included. See [VAT](https://docs.pantechdynamics.com/billing/vat). A receipt usually appears within a minute of the payment settling. Each one is numbered `PTD-RCT--`, in order through the year, for example `PTD-RCT-2026-000123`. ## By email The receipt is attached as a PDF to the email about the payment: the payment, top-up or invoice-paid email you already get, or its own receipt email for debits and refunds. It goes to your billing email if you set one in **Billing** settings, and otherwise to the organization's owners and admins. ## In the console - **Billing** → **Receipts** lists every receipt, newest first. Open one to see its details, or press **Download** for the PDF. - On **Billing** → **Transactions**, a movement that has a receipt shows its number under the description. Select it to open the receipt. - After a top-up, the confirmation shows **View receipt** and **Download receipt** once the payment has settled. Any member of the organization can see and download its receipts. ## With the API ```console $ curl -H "Authorization: Bearer $PANTECH_API_KEY" https://api.pantechdynamics.com/public/v1/billing/receipts $ curl -H "Authorization: Bearer $PANTECH_API_KEY" -o receipt.pdf \ https://api.pantechdynamics.com/public/v1/billing/receipts/rcp_.../pdf ``` A settled credit purchase names its receipt in `receipt`: `{ "id": "rcp_...", "number": "PTD-RCT-2026-000123" }`, or `null` until it is issued. See [Billing](https://docs.pantechdynamics.com/api/billing) in the API reference. Reading receipts needs the `read` scope. ## Who issues it The company on the receipt follows how you paid: - **Pantech Dynamics, Inc.**, with its San Francisco address and US phone number, for payments taken by Stripe (in US dollars). - **Pantech Dynamics Ltd**, with its Abuja address and Nigerian phone number, for payments taken by Flutterwave (in naira). Debits from credit and refunds, which no card provider handles, follow your account's currency: US dollars for the Inc., naira for the Ltd. Organizations in Nigeria are billed in naira; everyone else in US dollars. ## Checking a receipt is genuine Every receipt carries a **verification code** on its tear-off stub, such as `7F3A 91C2 0B4E`. Pantech keeps the original of every receipt it issues. If you need to confirm a copy is genuine, for an audit or an expense claim, send the receipt number and the code to [support](https://docs.pantechdynamics.com/help/support-tickets) and we will check it against the original. --- # VAT Source: https://docs.pantechdynamics.com/billing/vat VAT of 7.5% is added to the invoice at the end of each billing period, and to nothing else. Activation credits, top-ups and upfront payments carry none. ## What is charged VAT is **7.5%**, the same for every organization, in Nigeria or anywhere else, whether you are billed in naira or US dollars. It is added to one thing only: the **invoice issued when your billing period closes**. The invoice adds VAT on top of the period's usage, then is collected as usual: from your credit first, then your saved card, with the usual retries. ## What carries no VAT - **Activation credits**, the payment that sets up your account. - **Adding funds**: a top-up goes into your credit at its full amount. - **The upfront payment** for a new virtual machine or database. Credit and upfront payments are not taxed when you pay them. When they are applied to an invoice, they pay its total, VAT included. VAT is charged once, on the invoice, never on the credit itself. ## How it is worked out VAT is worked out once per invoice, on the period's usage (the subtotal), in kobo or cents, and rounded half up to the nearest kobo or cent: | Subtotal | 7.5% | VAT | Total | | --- | --- | --- | --- | | ₦10,000.00 | ₦750.00 | ₦750.00 | ₦10,750.00 | | $12.34 | $0.9255 | $0.93 | $13.27 | ## On your invoice An invoice lists, in order: | Line | What it is | | --- | --- | | **Subtotal** | Usage this period | | **VAT (7.5%)** | 7.5% of the subtotal | | **Total** | Subtotal plus VAT | | **Brought forward** | Anything left unpaid from an earlier invoice | | **Credit applied** | Your credit and upfront payments taken off | | **Amount due** | What is left, charged to your saved card | Invoices issued before VAT was introduced show no VAT. ## Refunds If any part of a paid invoice is refunded, the refund returns VAT in the invoice's own proportion. A refund of ₦1,000.00 from an invoice of ₦10,000.00 plus ₦750.00 VAT returns ₦930.23 of usage and ₦69.77 of VAT, which is ₦1,000.00 × 750 / 10,750, rounded half up to the kobo. Each refund is worked out on the running total refunded from that invoice, so several partial refunds that together refund the whole invoice return exactly its VAT: never a kobo more or less. Refunds go to your credit balance. The refund receipt is a **credit note** against the invoice. It shows the **refunded subtotal**, the **VAT (7.5%) refunded**, and the **total refunded**. Money that was never taxed, such as activation credits, top-ups and upfront payments, returns no VAT. When Pantech's VAT registration number is set, it is printed on every receipt, credit note and invoice email. ## Prices and estimates Every price in the console, on the pricing pages, and in quotes and estimates, such as a create form's **Upfront payment (40%)** and **If it runs all month**, **excludes VAT**. The estimate of your next invoice, which your credit is compared against, **includes VAT**. ## In the API Money is in minor units, as everywhere in the [API](https://docs.pantechdynamics.com/api). - An invoice has `tax_minor`, `tax_rate_bps` (`750` is 7.5%) and `total_minor`, which is `subtotal_minor` plus `tax_minor`. `total_due_minor` is `total_minor` plus `brought_forward_minor`, less `credit_applied_minor`. - The [usage](https://docs.pantechdynamics.com/api/usage) summary has `tax_rate_bps`, `estimated_tax_minor` and `estimated_total_minor`. - The [credit outlook](https://docs.pantechdynamics.com/api/billing)'s `estimated_next_invoice_minor` includes VAT. - A refund receipt for an invoice lists the VAT it returns among its `facts`. --- # Usage and transactions Source: https://docs.pantechdynamics.com/billing/usage-and-transactions Where to see what you are spending, by resource and by kind, every movement on your credit balance, your invoices, and how to export them. ## This period's spend The **Billing** page shows, for the current billing period: - **Balance**: your credit right now. - **Current spend**: usage rated so far this period. - **Projected spend**: where the period is heading, from your average day so far. It appears once a full day has been rated. These figures are usage before VAT. The estimate of your next invoice, which your credit is compared against, includes [VAT](https://docs.pantechdynamics.com/billing/vat). - **Spent this period**, broken down **by resource** (machines, storage and the rest), so you can see which one moves the total. - **Recent activity**: the latest top-ups, payments and usage. Usage lines are rated live, so they are estimates until the period closes and they are invoiced. ## Usage by resource **Usage by resource**, on the **Spent this period** card, opens **Billing** → **Usage**: what each machine, volume and database cost over **7 days**, **30 days** or **This month**, grouped by **Resource** or by **Kind**. Each table downloads as CSV. **Metered this month** shows the month's metered totals. Reserved IP addresses and snapshot storage aren't rated per resource, so they aren't listed there. The overview's **Compute spend** chart and **Needs attention** panel are the quick version: spend over time, and what you are paying for without using. ## Transactions **Billing** → **Transactions** lists every movement on your credit balance, newest first: | Column | Shows | | --- | --- | | **Date** | When it happened | | **Description** | What it was, such as a top-up, a virtual machine's upfront payment, or its unused part returned after a VM or database is deleted | | **Type** | The kind of movement. Filter the list by it | | **Amount** | What moved. Top-ups add to the balance; payments take from it | | **Balance after** | Your credit right after it. Read down the column to see the balance change | A movement with a receipt shows the receipt's number under its description; select it to open the receipt and download its PDF. See [Receipts](https://docs.pantechdynamics.com/billing/receipts). Usage is not listed line by line here. It is invoiced when the period closes, with your credit taken off the invoice before your card is charged. ## Export to CSV On **Transactions**, press **Export CSV** to download the whole ledger, for your accountant or your own records. If your role does not include billing exports, the page says so; ask an Owner. ## Invoices **Invoices**, on the **How billing works** card, lists every invoice, newest first, 50 a page. Filter it by status. Each invoice shows its period, when it was issued and paid, and its amounts: the **Subtotal** (usage this period), **VAT (7.5%)**, the **Total**, then anything **Brought forward**, the **Credit applied**, and the **Amount due**. Invoices issued before VAT was introduced show no VAT; see [VAT](https://docs.pantechdynamics.com/billing/vat). The first invoice is issued when your first billing period closes. Press **Export CSV** to download every invoice matching the filter. As with transactions, your role needs to include billing exports. --- # Webhooks Source: https://docs.pantechdynamics.com/webhooks/overview Have Pantech send a signed HTTPS request to your own URL when a machine finishes creating, a change fails, an invoice is issued or credit runs low. A webhook is a URL of yours that Pantech sends events to. Instead of polling, your system is told when a change finishes or fails, when an invoice is issued or paid, and when credit runs low. Each event is a JSON `POST`, signed with a secret only you and Pantech know. Webhooks are free. An organization can have up to 10. ## Before you start - **Who can do what.** Owners and admins create, change, test and delete webhooks. Every member can see them and their deliveries. An [API key](https://docs.pantechdynamics.com/getting-started/api-keys) acts as the member who created it, so a `write` key made by a member who is not an owner or admin cannot change webhooks. - **A URL on the public internet.** It must be `https`, at most 2,048 characters, and have no user name or password in it (`https://user:pass@…` is refused). Addresses that are not on the public internet are refused too: private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback, link-local (and so cloud metadata addresses), carrier-grade NAT (`100.64.0.0/10`), multicast and reserved ranges, in IPv4 and IPv6, as well as `localhost` and names ending in `.local` or `.internal`. This is checked against the address Pantech actually connects to, at every delivery, so a name that later resolves to a private address is refused then. - **A receiver that checks signatures.** Anyone can send a request to a public URL. [Verify the signature](https://docs.pantechdynamics.com/webhooks/verify-signatures) on every delivery before you act on it. ## Create a webhook **In the console:** open the account menu, choose **Webhooks** (or **Organization › Manage webhooks**), then **Add webhook**. Enter the URL, pick the events, and copy the signing secret it shows you once. Each webhook's page lets you turn it on or off, edit it, send a test event, rotate the secret, and see every delivery with the exact body sent. **With the API:** send the URL and what to subscribe to. `description` is a note for yourself. ```console $ curl https://api.pantechdynamics.com/public/v1/webhooks \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": ["instance.*", "invoice.paid", "credit.low"]}' ``` ```json { "id": "whk_06gk3x9tq7m2v8r4y1c5d0zp8w", "url": "https://example.com/hooks/pantech", "description": "Production alerts", "events": ["instance.*", "invoice.paid", "credit.low"], "enabled": true, "disabled_reason": null, "failing_since": null, "created_at": "2026-10-22T09:30:00Z", "updated_at": "2026-10-22T09:30:00Z", "secret": "whsec_…" } ``` :::warning[The secret is shown once] `secret` is in this response and nowhere else. Put it straight into your receiver's secret store. If you lose it, [rotate it](#rotate-the-secret) to get a new one. Retrying the create with the same `Idempotency-Key` returns the same webhook with `secret: null`. ::: Then [send a test event](#test-it) to check your receiver. Every endpoint, with its fields and errors, is in the [webhooks API reference](https://docs.pantechdynamics.com/api/webhooks). ## Choose events `events` takes 1 to 100 entries, each one of: - **An exact type**, such as `instance.created` or `invoice.paid`. - **Every type of one resource**: `.*`, such as `instance.*` or `database.*`. A type added to that resource later is included. - **Everything**: `*`. Infrastructure events are named for how the change ended: `.` when it finished (`volume.attached`), and `._failed` when it failed (`volume.attach_failed`). Billing events have their own names (`invoice.issued`, `credit.low`). [Event types](https://docs.pantechdynamics.com/webhooks/event-types) lists every one, and so does the API: ```console $ curl https://api.pantechdynamics.com/public/v1/webhook-event-types \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` ## What is sent Every delivery is a `POST` with a JSON body, the same shape for every event: ```json { "id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "type": "instance.created", "api_version": "2026-10-22", "created_at": "2026-10-22T09:31:12Z", "organization_id": "org_06gk3x9tq7m2v8r4y1c5d0zp8w", "project_id": "acc_06gk3x9tq7m2v8r4y1c5d0zp8w", "data": { "resource_type": "instance", "resource_id": "vm_06gk3x9tq7m2v8r4y1c5d0zp8w", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "action": "create", "status": "succeeded" } } ``` | Field | What it is | | --- | --- | | `id` | The event's id (`evt_…`). The same in every delivery of this event, including retries and redeliveries: deduplicate on it. | | `type` | The event type, such as `instance.created`. | | `api_version` | The shape of this body, as a date. It is `2026-10-22`. A change that would break a receiver comes with a new version. | | `created_at` | When it happened (RFC 3339, UTC). | | `organization_id` | Your organization. | | `project_id` | The project the resource is in (`acc_…`). | | `data` | What happened. Its fields depend on `type`. | ### Infrastructure events `data` names the resource and the change, and nothing more: - `resource_type`: `instance`, `volume`, `database`, and so on. - `resource_id`: its id. Read it through the API (`GET /instances/{instance_id}`) for its current state. - `operation_id`: the [operation](https://docs.pantechdynamics.com/api/operations) that made the change. - `action`: the verb that was asked for, such as `create`, `attach` or `resize_storage`. - `status`: `succeeded` or `failed`. - `error`: on failure only, `{ "code": "…", "message": "…" }`. The same safe code and message the operation's `failure` has, fit to show your own users. ```json { "resource_type": "volume", "resource_id": "vol_06gk3x9tq7m2v8r4y1c5d0zp8w", "operation_id": "op_06gk3x9tq7m2v8r4y1c5d0zp8w", "action": "attach", "status": "failed", "error": { "code": "PROVISIONING_RESOURCE_BUSY", "message": "The resource was busy with another change. Wait for it to settle, then try again." } } ``` ### Billing events `data` carries the facts of the event, with amounts as integers in minor units (kobo for NGN, cents for USD) and a `currency`. For `invoice.paid`, `amount_minor: 1504000` with `currency: "NGN"` is NGN 15,040.00. [Event types](https://docs.pantechdynamics.com/webhooks/event-types#billing) lists each event's fields. ### Headers | Header | Value | | --- | --- | | `Content-Type` | `application/json` | | `User-Agent` | `Pantech-Webhooks/1.0` | | `Pantech-Signature` | `t=,v1=`, with a second `v1=` for 24 hours after a secret rotation. See [Verify signatures](https://docs.pantechdynamics.com/webhooks/verify-signatures). | | `Pantech-Event-Id` | The event's `id`. | | `Pantech-Event-Type` | The event's `type`. | | `Pantech-Delivery-Id` | This delivery (`whd_…`). One event sent to two of your webhooks is two deliveries with the same event id. | ## Answer quickly A delivery succeeds when your URL answers any `2xx` status within 10 seconds. Anything else is a failure and is retried: - Any other status, `3xx` included. Redirects are not followed: give the final URL. - No answer within 10 seconds, a refused connection, a name that does not resolve, or a TLS error. Your certificate must be valid for the host name. So verify the signature, store the event, answer `200` or `204`, and do the work afterwards, in a queue or a background job. The body you answer with is not used, except that its first 1,024 characters are kept on the delivery to help you debug. ## Retries A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: eight attempts in all, over about 28 hours. After the eighth it is marked `failed`, and you can still [send it again](#see-and-resend-deliveries). | Attempt | Sent after the one before | | ---: | --- | | 1 | At once | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 5 hours | | 7 | 10 hours | | 8 | 10 hours | ### A webhook that keeps failing is turned off When every delivery to a webhook has failed for three days without a single success, the webhook is disabled: `enabled` becomes `false` and `disabled_reason` is `failing`. Deliveries still waiting are cancelled. `failing_since` on the webhook shows when the failures started, so you can see trouble coming. Fix the receiver, then turn the webhook back on. That clears its failure history and gives it a fresh three days: ```console $ curl -X PATCH https://api.pantechdynamics.com/public/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"enabled": true}' ``` Setting `enabled` to `false` yourself pauses a webhook the same way, with `disabled_reason: "disabled_by_user"`. Events that happen while a webhook is disabled are not sent to it later. ## Duplicates and order Delivery is at least once, and not in order: - **The same event can arrive more than once**, for example when your receiver handled it but its answer was lost. Keep the `id` of each event you have handled (30 days is enough) and skip one you have seen. - **Events can arrive out of order.** A retried `instance.created` can arrive after the `instance.stopped` that followed it. Use `created_at` to order them, or treat an event as a cue and read the resource's current state from the API. ## See and resend deliveries Every event sent to a webhook, or waiting to be, is a delivery, kept for 30 days. The list is newest first, with each one's `status` (`pending`, `succeeded`, `failed` or `canceled`), `attempts`, and how the last attempt went: `last_status_code`, `last_error`, `last_duration_ms` and the start of `last_response`. ```console $ curl https://api.pantechdynamics.com/public/v1/webhooks/$WEBHOOK_ID/deliveries \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` `last_error` says why an attempt failed: | `last_error` | Meaning | | --- | --- | | `http_` | Your URL answered with that status, such as `http_500` or `http_301`. | | `timeout` | No answer within 10 seconds. | | `connection_failed` | The connection was refused or dropped. | | `dns_failed` | The host name did not resolve. | | `tls_failed` | The TLS handshake failed: check the certificate and its chain. | | `blocked_address` | The host name resolved to an address that is not on the public internet. | | `invalid_url` | The URL is no longer valid. | | `endpoint_disabled`, `endpoint_deleted` | The webhook was disabled or deleted before the delivery was sent. | `GET /webhooks/{webhook_id}/deliveries/{delivery_id}` returns one delivery with `payload`, the exact body sent. To send a delivery that succeeded, failed or was cancelled again, `POST /webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver`: it starts again from the first attempt, with the same event `id`, so a receiver that deduplicates is safe. The webhook must be enabled, and a delivery still being attempted answers `409 WEBHOOK_DELIVERY_IN_PROGRESS`. ## Test it ```console $ curl -X POST https://api.pantechdynamics.com/public/v1/webhooks/$WEBHOOK_ID/test \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` This queues a `webhook.test` event to the webhook, whatever it subscribes to, and returns the delivery. It is sent within a few seconds, signed and retried like any other: ```json { "id": "evt_…", "type": "webhook.test", "api_version": "2026-10-22", "created_at": "2026-10-22T09:32:00Z", "organization_id": "org_…", "project_id": "acc_…", "data": { "endpoint_id": "whk_…", "message": "This is a test event from Pantech." } } ``` Read the delivery a few seconds later to see how your receiver answered. ## Rotate the secret If the secret may have leaked, or you rotate secrets on a schedule, ask for a new one: ```console $ curl -X POST https://api.pantechdynamics.com/public/v1/webhooks/$WEBHOOK_ID/rotate-secret \ -H "Authorization: Bearer $PANTECH_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" ``` The response has the new `secret`, shown this once. For the next 24 hours every delivery is signed with both the new and the old secret, as two `v1` values, so you can update your receiver without dropping an event. A receiver that accepts any matching `v1` keeps working throughout, whichever secret it holds. After 24 hours only the new secret signs. Rotating again within the 24 hours starts a new overlap: only the latest secret and the one just before it sign. ## Change or delete a webhook `PATCH /webhooks/{webhook_id}` changes `url`, `description`, `events` or `enabled`. What you leave out stays as it is, and `events` replaces the whole list. `DELETE /webhooks/{webhook_id}` deletes the webhook and its deliveries; nothing more is sent to it. ## From the CLI and Terraform The [Pantech CLI](https://docs.pantechdynamics.com/cli) manages webhooks with `pantech webhooks`: ```console $ pantech webhooks create --url https://example.com/hooks/pantech \ --event 'instance.*' --event invoice.paid --description "Production alerts" $ pantech webhooks test whk_06gk3x9tq7m2v8r4y1c5d0zp8w $ pantech webhooks deliveries list whk_06gk3x9tq7m2v8r4y1c5d0zp8w ``` | Command | What it does | | --- | --- | | `pantech webhooks list`, `get` | List your webhooks, or show one | | `pantech webhooks create --url URL --event TYPE [--description TEXT]` | Create a webhook; repeat `--event` for each type, `.*` or `*`. Prints the secret once | | `pantech webhooks update`, `delete` | Change a webhook (URL, events, description, enabled), or delete it | | `pantech webhooks test` | Send a `webhook.test` event | | `pantech webhooks rotate-secret` | Replace the signing secret; prints the new one once | | `pantech webhooks event-types` | Every event type you can subscribe to | | `pantech webhooks deliveries list`, `get`, `redeliver` | See deliveries, one with its payload, or send one again | Run `pantech webhooks --help` for every flag. With [Terraform](https://docs.pantechdynamics.com/terraform), a webhook is the `pantechdynamics_webhook` resource, and the `pantechdynamics_webhook_event_types` data source lists the event types. The signing secret is a sensitive attribute of the resource, so keep your state somewhere private. ## Errors | Code | Status | What to do | | --- | --- | --- | | `VALIDATION_FAILED` | `422` | A field is invalid; `errors` names it. `INVALID_WEBHOOK_URL`: give an `https` URL on the public internet, with no user name or password. `INVALID_WEBHOOK_EVENTS`: give 1 to 100 known types, `.*` or `*`. | | `WEBHOOK_LIMIT_REACHED` | `403` | Your organization has 10 webhooks. Delete one first, or point one webhook at a receiver that fans out. | | `FORBIDDEN` | `403` | Only owners and admins can change webhooks. Use a key created by one. | | `WEBHOOK_NOT_FOUND` | `404` | No webhook with this id in your organization. | | `WEBHOOK_DISABLED` | `409` | Testing and resending need the webhook enabled. Turn it on first. | | `WEBHOOK_DELIVERY_IN_PROGRESS` | `409` | The delivery is being sent right now. Wait for it to finish. | | `WEBHOOKS_UNAVAILABLE` | `503` | Webhooks are not available yet. Try again later. | The [webhooks API reference](https://docs.pantechdynamics.com/api/webhooks) lists every code each endpoint returns. --- # Verify webhook signatures Source: https://docs.pantechdynamics.com/webhooks/verify-signatures Check that a webhook delivery came from Pantech and was not changed or replayed, with complete examples in Node.js, Python, Go and PHP. Your webhook URL is public, so anyone can send it a request. Every delivery from Pantech is signed with your webhook's secret: check the signature before you act on an event, and refuse anything that does not match. ## How the signature works Each delivery carries a `Pantech-Signature` header: ```http Pantech-Signature: t=1792687211,v1=1a5d101f814e8d6654baea274139a16ec4eea3a5f7619322edbfabf799cabb0e ``` - `t` is when the delivery was signed, in Unix seconds. - `v1` is the hex-encoded HMAC-SHA256 of the string `.`, keyed with the webhook's secret. The secret is the whole string you were given, `whsec_` included, used as UTF-8 bytes. Do not strip the prefix or decode it. For 24 hours after you [rotate the secret](https://docs.pantechdynamics.com/webhooks/overview#rotate-the-secret) the header has two `v1` values, one made with the new secret and one with the old: ```http Pantech-Signature: t=1792687211,v1=1a5d…bb0e,v1=c54f…708e ``` To verify a delivery: 1. Read the **raw body**, the exact bytes received, before any framework parses it. Parsing JSON and serializing it again changes the bytes (spacing, key order, escapes) and the signature no longer matches. 2. Split the header on `,`, then each part on its first `=`. Keep `t`, and every `v1`. Ignore anything else, so a scheme added later does not break you. 3. Refuse the delivery if `t` is more than five minutes from your clock. This stops a captured delivery from being replayed later. Keep your server's clock synced with NTP. 4. Compute HMAC-SHA256 of `t`, a `.`, and the raw body, keyed with your secret, as lowercase hex. 5. Accept the delivery if it equals **any** `v1` value. Compare in constant time, never with `==`, so timing does not leak the signature. Answer `400` when verification fails. A delivery you refuse is retried like any other failure, so a receiver with the wrong secret shows up in the webhook's deliveries as `http_400`. ## Examples Each example is a complete receiver: it reads the secret from `PANTECH_WEBHOOK_SECRET`, verifies the signature over the raw body, and answers `204` straight away. Do the real work after answering: a delivery that takes longer than 10 seconds to answer is retried. ::::tabs :::tab[Node.js] With [Express](https://expressjs.com). `express.raw()` keeps the body as a `Buffer` of the exact bytes received. Mount it on the webhook route and do not let `express.json()` run before it, or the raw bytes are gone. ```js title="server.mjs" import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.PANTECH_WEBHOOK_SECRET; // whsec_…, the whole string const TOLERANCE_SECONDS = 5 * 60; // True if `header` holds a valid signature of `rawBody` (a Buffer) made with // `secret` within the last five minutes. function verifyPantechSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) { if (typeof header !== "string") return false; let timestamp = null; const signatures = []; for (const part of header.split(",")) { const eq = part.indexOf("="); if (eq === -1) continue; const key = part.slice(0, eq).trim(); const value = part.slice(eq + 1).trim(); if (key === "t") timestamp = value; else if (key === "v1") signatures.push(value); } if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false; if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false; const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest(); return signatures.some((signature) => { const given = Buffer.from(signature, "hex"); return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } const app = express(); app.post("/hooks/pantech", express.raw({ type: "application/json" }), (req, res) => { if (!verifyPantechSignature(req.body, req.get("Pantech-Signature"), SECRET)) { return res.status(400).send("invalid signature"); } const event = JSON.parse(req.body.toString("utf8")); res.sendStatus(204); // answer first, then work handle(event).catch((err) => console.error("webhook", event.id, err)); }); async function handle(event) { // Skip an event.id you have already handled: delivery is at least once. console.log(event.id, event.type, event.data); } app.listen(3000); ``` ::: :::tab[Python (Flask)] With [Flask](https://flask.palletsprojects.com). `request.get_data()` returns the raw bytes; call it before anything reads `request.json`. ```python title="app.py" import hashlib import hmac import json import os import time from typing import Optional from flask import Flask, abort, request SECRET = os.environ["PANTECH_WEBHOOK_SECRET"] # whsec_…, the whole string TOLERANCE_SECONDS = 5 * 60 def verify_pantech_signature(raw_body: bytes, header: Optional[str], secret: str, now: Optional[float] = None) -> bool: """True if `header` holds a valid signature of `raw_body` made with `secret` in the last five minutes.""" if not header: return False timestamp, signatures = None, [] for part in header.split(","): key, sep, value = part.strip().partition("=") if not sep: continue if key == "t": timestamp = value elif key == "v1": signatures.append(value) if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures: return False if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures) app = Flask(__name__) @app.post("/hooks/pantech") def pantech_webhook(): raw_body = request.get_data() if not verify_pantech_signature(raw_body, request.headers.get("Pantech-Signature"), SECRET): abort(400) event = json.loads(raw_body) # Queue the event for a worker, and skip an event["id"] you have already handled. print(event["id"], event["type"], event["data"]) return "", 204 ``` ::: :::tab[Python (FastAPI)] With [FastAPI](https://fastapi.tiangolo.com). Take the `Request` and `await request.body()` for the raw bytes; do not declare a Pydantic model for the body, which parses it first. ```python title="main.py" import hashlib import hmac import json import os import time from typing import Optional from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, Response SECRET = os.environ["PANTECH_WEBHOOK_SECRET"] # whsec_…, the whole string TOLERANCE_SECONDS = 5 * 60 def verify_pantech_signature(raw_body: bytes, header: Optional[str], secret: str, now: Optional[float] = None) -> bool: """True if `header` holds a valid signature of `raw_body` made with `secret` in the last five minutes.""" if not header: return False timestamp, signatures = None, [] for part in header.split(","): key, sep, value = part.strip().partition("=") if not sep: continue if key == "t": timestamp = value elif key == "v1": signatures.append(value) if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures: return False if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_SECONDS: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures) app = FastAPI() def handle(event: dict) -> None: # Skip an event["id"] you have already handled: delivery is at least once. print(event["id"], event["type"], event["data"]) @app.post("/hooks/pantech", status_code=204) async def pantech_webhook(request: Request, background: BackgroundTasks) -> Response: raw_body = await request.body() if not verify_pantech_signature(raw_body, request.headers.get("pantech-signature"), SECRET): raise HTTPException(status_code=400, detail="invalid signature") background.add_task(handle, json.loads(raw_body)) # runs after the response is sent return Response(status_code=204) ``` ::: :::tab[Go] With the standard library's `net/http` (Go 1.22 or later, for the method in the route pattern). The handler reads the body itself, so it has the raw bytes. ```go title="main.go" package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "log" "net/http" "os" "strconv" "strings" "time" ) const tolerance = 5 * time.Minute // verifyPantechSignature checks the Pantech-Signature header against the raw // request body. It returns nil when one of the v1 signatures matches and the // timestamp is within five minutes of now. func verifyPantechSignature(body []byte, header, secret string, now time.Time) error { var timestamp string var signatures []string for _, part := range strings.Split(header, ",") { key, value, ok := strings.Cut(strings.TrimSpace(part), "=") if !ok { continue } switch key { case "t": timestamp = value case "v1": signatures = append(signatures, value) } } seconds, err := strconv.ParseInt(timestamp, 10, 64) if err != nil || len(signatures) == 0 { return errors.New("malformed Pantech-Signature header") } if age := now.Sub(time.Unix(seconds, 0)); age > tolerance || age < -tolerance { return errors.New("timestamp outside the tolerance") } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(body) expected := mac.Sum(nil) for _, signature := range signatures { given, err := hex.DecodeString(signature) if err == nil && hmac.Equal(given, expected) { return nil } } return errors.New("no matching signature") } type event struct { ID string `json:"id"` Type string `json:"type"` Data json.RawMessage `json:"data"` } func main() { secret := os.Getenv("PANTECH_WEBHOOK_SECRET") // whsec_…, the whole string http.HandleFunc("POST /hooks/pantech", func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } if err := verifyPantechSignature(body, r.Header.Get("Pantech-Signature"), secret, time.Now()); err != nil { http.Error(w, "invalid signature", http.StatusBadRequest) return } var e event if err := json.Unmarshal(body, &e); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) // Hand e to a queue or worker here, and skip an e.ID you have already handled. log.Printf("event %s %s %s", e.ID, e.Type, e.Data) }) log.Fatal(http.ListenAndServe(":8080", nil)) } ``` ::: :::tab[PHP] Plain PHP. `php://input` is the raw body; `$_POST` is empty for a JSON request. In Laravel or Symfony, `$request->getContent()` returns the same bytes. ```php title="webhook.php" PANTECH_TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } $secret = getenv('PANTECH_WEBHOOK_SECRET'); // whsec_…, the whole string $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_PANTECH_SIGNATURE'] ?? null; if ($secret === false || !verify_pantech_signature($rawBody, $header, $secret)) { http_response_code(400); exit('invalid signature'); } $event = json_decode($rawBody, true); // Queue $event for a worker, and skip an $event['id'] you have already handled. error_log(sprintf('event %s %s', $event['id'], $event['type'])); http_response_code(204); ``` ::: :::: ## Try your receiver Before you point a webhook at it, sign a request yourself with the same algorithm and send it to your receiver running locally: ```console $ export PANTECH_WEBHOOK_SECRET=whsec_test $ body='{"id":"evt_local","type":"webhook.test","api_version":"2026-10-22","data":{}}' $ t=$(date +%s) $ sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$PANTECH_WEBHOOK_SECRET" -hex | sed 's/^.* //') $ curl -i http://localhost:3000/hooks/pantech \ -H "Content-Type: application/json" \ -H "Pantech-Signature: t=$t,v1=$sig" \ --data-binary "$body" HTTP/1.1 204 No Content ``` Change one character of `$body` after signing and send it again: the receiver should answer `400`. Then deploy it, create the webhook, and [send a test event](https://docs.pantechdynamics.com/webhooks/overview#test-it): a `webhook.test` delivery signed with the real secret. ## Rotating without dropping events 1. [Rotate the secret](https://docs.pantechdynamics.com/webhooks/overview#rotate-the-secret). From now on deliveries carry two `v1` values, one from each secret. 2. Put the new secret in your receiver and deploy it. Your old receiver still matches the old secret's `v1`; the new one matches the new secret's. 3. Within 24 hours, before the old secret stops signing, make sure every instance of your receiver runs with the new secret. A receiver that checks every `v1`, as the examples do, needs no special handling for a rotation. ## When verification fails - **Every delivery fails.** Check you have the secret of this webhook, the whole `whsec_…` string, and that it was not rotated since. Check you sign the raw body: if a framework parsed the JSON first, the bytes you sign are not the bytes sent. - **Some deliveries fail.** Check the server's clock: a clock more than five minutes out rejects every delivery, or only some if it drifts. Check a proxy in front of the receiver does not rewrite the body, for example by changing the character encoding. - **Compare the delivery.** `GET /webhooks/{webhook_id}/deliveries/{delivery_id}` returns `payload`, the body that was sent, and `last_status_code` and `last_response` show what your receiver answered. --- # Webhook event types Source: https://docs.pantechdynamics.com/webhooks/event-types Every event a webhook can subscribe to, grouped by resource, with what each one means and the fields billing events carry. A webhook subscribes to exact types, to every type of one resource with `.*`, or to everything with `*`. The same list, with a one-line description of each type, comes from the API: ```console $ curl https://api.pantechdynamics.com/public/v1/webhook-event-types \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Every event arrives in the same envelope, described in [What is sent](https://docs.pantechdynamics.com/webhooks/overview#what-is-sent). Only `data` differs. ## Infrastructure Every change to a resource is an [operation](https://docs.pantechdynamics.com/api/operations), and every operation that ends sends one event: `.` when it finished, `._failed` when it failed. Both kinds carry the same `data`: | Field | What it is | | --- | --- | | `resource_type` | The resource, as in the event type: `instance`, `volume`, `kubernetes_cluster`… | | `resource_id` | The resource's id (`vm_…`, `vol_…`, `db_…`). | | `operation_id` | The operation that made the change (`op_…`). | | `action` | The verb that was asked for, as in the `_failed` type: `create`, `attach`, `resize_storage`… | | `status` | `succeeded` or `failed`. | | `error` | Only when `status` is `failed`: `code` and `message`, the same as the operation's `failure`. | Events are deliberately thin. To know more, such as a machine's address after `instance.created`, read the resource through the [API](https://docs.pantechdynamics.com/api) with `resource_id`. ### Instances Subscribe to all of them with `instance.*`. | Finished | Failed | What happened | | --- | --- | --- | | `instance.created` | `instance.create_failed` | A machine was created and is running. | | `instance.started` | `instance.start_failed` | A machine was started. | | `instance.stopped` | `instance.stop_failed` | A machine was stopped. | | `instance.rebooted` | `instance.reboot_failed` | A machine was rebooted. | | `instance.renamed` | `instance.rename_failed` | A machine was renamed. | | `instance.rebuilt` | `instance.rebuild_failed` | A machine's root disk was rebuilt from an image. | | `instance.deleted` | `instance.delete_failed` | A machine was deleted. | | `instance.deletion_protection_enabled` | `instance.enable_deletion_protection_failed` | Deletion protection was turned on. | | `instance.deletion_protection_disabled` | `instance.disable_deletion_protection_failed` | Deletion protection was turned off. | | `instance.restored` | `instance.restore_failed` | A machine was restored from one of its snapshots. | | `instance.resized` | `instance.resize_failed` | A machine was moved to another plan. | | `instance.ssh_key_changed` | `instance.change_ssh_key_failed` | A machine's SSH key was changed. | | `instance.security_group_changed` | `instance.change_security_group_failed` | A machine was moved to another security group. | | `instance.private_network_attached` | `instance.attach_private_network_failed` | A machine was given an interface on its zone's private database network. | | `instance.private_network_detached` | `instance.detach_private_network_failed` | That interface was removed. | ### Volumes Subscribe to all of them with `volume.*`. | Finished | Failed | What happened | | --- | --- | --- | | `volume.created` | `volume.create_failed` | A volume was created. | | `volume.attached` | `volume.attach_failed` | A volume was attached to a machine. | | `volume.detached` | `volume.detach_failed` | A volume was detached. | | `volume.resized` | `volume.resize_failed` | A volume was grown. | | `volume.deleted` | `volume.delete_failed` | A volume was deleted. | ### Snapshots Subscribe to all of them with `snapshot.*`. | Finished | Failed | What happened | | --- | --- | --- | | `snapshot.created` | `snapshot.create_failed` | A snapshot was taken, by hand or by a schedule. | | `snapshot.deleted` | `snapshot.delete_failed` | A snapshot was deleted. | ### VPC networks Subscribe to all of them with `network.*`. | Finished | Failed | What happened | | --- | --- | --- | | `network.created` | `network.create_failed` | A VPC was created. | | `network.deleted` | `network.delete_failed` | A VPC was deleted. | ### Subnets Subscribe to all of them with `subnet.*`. | Finished | Failed | What happened | | --- | --- | --- | | `subnet.created` | `subnet.create_failed` | A subnet was created. | | `subnet.deleted` | `subnet.delete_failed` | A subnet was deleted. | ### Firewall rules Subscribe to all of them with `firewall_rule.*`. | Finished | Failed | What happened | | --- | --- | --- | | `firewall_rule.created` | `firewall_rule.create_failed` | A rule was added to a subnet's firewall. | | `firewall_rule.deleted` | `firewall_rule.delete_failed` | A rule was removed. | ### Security groups Subscribe to all of them with `security_group.*`. | Finished | Failed | What happened | | --- | --- | --- | | `security_group.created` | `security_group.create_failed` | A security group was created. | | `security_group.rules_updated` | `security_group.update_rules_failed` | A security group's rules were changed. | | `security_group.deleted` | `security_group.delete_failed` | A security group was deleted. | ### Public IPs Subscribe to all of them with `public_ip.*`. | Finished | Failed | What happened | | --- | --- | --- | | `public_ip.created` | `public_ip.create_failed` | A public IP was reserved. | | `public_ip.deleted` | `public_ip.delete_failed` | A public IP was released. | | `public_ip.attached` | `public_ip.attach_failed` | A public IP was attached to a machine. | | `public_ip.detached` | `public_ip.detach_failed` | A public IP was taken off its machine. | ### Port forwarding rules Subscribe to all of them with `port_forwarding_rule.*`. | Finished | Failed | What happened | | --- | --- | --- | | `port_forwarding_rule.created` | `port_forwarding_rule.create_failed` | A port forward was added to a public IP. | | `port_forwarding_rule.deleted` | `port_forwarding_rule.delete_failed` | A port forward was removed. | ### Load balancers Subscribe to all of them with `load_balancer.*`. | Finished | Failed | What happened | | --- | --- | --- | | `load_balancer.created` | `load_balancer.create_failed` | A load balancer was created. | | `load_balancer.updated` | `load_balancer.update_failed` | A load balancer's name, algorithm or targets were changed. | | `load_balancer.deleted` | `load_balancer.delete_failed` | A load balancer was deleted. | ### Databases Subscribe to all of them with `database.*`. | Finished | Failed | What happened | | --- | --- | --- | | `database.created` | `database.create_failed` | A database was created and is running. | | `database.started` | `database.start_failed` | A database was started. | | `database.stopped` | `database.stop_failed` | A database was stopped. | | `database.deleted` | `database.delete_failed` | A database was deleted. | | `database.access_rules_updated` | `database.update_access_rules_failed` | The CIDRs allowed to connect were changed. | | `database.password_changed` | `database.change_password_failed` | The admin password was changed to one you chose. | | `database.password_reset` | `database.reset_password_failed` | The admin password was reset to a generated one. | | `database.storage_resized` | `database.resize_storage_failed` | The data disk was grown. | ### Kubernetes clusters Subscribe to all of them with `kubernetes_cluster.*`. | Finished | Failed | What happened | | --- | --- | --- | | `kubernetes_cluster.created` | `kubernetes_cluster.create_failed` | A cluster was created and is running. | | `kubernetes_cluster.scaled` | `kubernetes_cluster.scale_failed` | The number of workers was changed. | | `kubernetes_cluster.upgraded` | `kubernetes_cluster.upgrade_failed` | A cluster was upgraded to a newer Kubernetes version. | | `kubernetes_cluster.started` | `kubernetes_cluster.start_failed` | A cluster was started. | | `kubernetes_cluster.stopped` | `kubernetes_cluster.stop_failed` | A cluster was stopped. | | `kubernetes_cluster.deleted` | `kubernetes_cluster.delete_failed` | A cluster was deleted. | | `kubernetes_cluster.configured` | `kubernetes_cluster.configure_failed` | A cluster's settings were changed. | | `kubernetes_cluster.nodes_resized` | `kubernetes_cluster.resize_nodes_failed` | The nodes were moved to another plan. | | `kubernetes_cluster.network_upgraded` | `kubernetes_cluster.upgrade_network_failed` | The cluster's pod network was upgraded. | ## Billing Amounts are integers in minor units of `currency` (kobo for `NGN`, cents for `USD`): `amount_minor: 1504000` in `NGN` is NGN 15,040.00. Times are RFC 3339, UTC. Subscribe to every event of one kind with its prefix, such as `invoice.*` or `credit.*`. See [how billing works](https://docs.pantechdynamics.com/billing/how-billing-works). | Event | When | `data` | | --- | --- | --- | | `invoice.issued` | A billing period closed and its invoice was issued. | `invoice_id`, `invoice_number`, `currency`, `subtotal_minor`, `tax_minor`, `tax_rate_bps` (VAT in basis points: 750 is 7.5%), `total_minor`, `credit_applied_minor`, `brought_forward_minor`, `total_due_minor` | | `invoice.paid` | An invoice was paid, by card or from credit. | `invoice_id`, `invoice_number`, `currency`, `amount_minor`, `paid_with` (`payment` or `credit`), `receipt_id` when paid by card | | `payment.received` | A payment was received. | `receipt_id`, `purpose` (such as `invoice_collection`, `credit_purchase`, `verification` or `initial_payment`), `currency`, `amount_minor` | | `payment.failed` | Collecting an invoice failed; another attempt is scheduled. | `currency`, `amount_due_minor`, `attempt_number`, `next_retry_at` (left out when no automatic retry remains), `past_due_on` | | `credit.low` | Credit no longer covers the estimated next invoice. | `currency`, `available_minor`, `prepaid_minor`, `projected_invoice_minor`, `next_invoice_at`, `has_payment_method` | | `credit.depleted` | Credit has run out. | The same fields as `credit.low` | | `credit_purchase.succeeded` | A credit purchase was paid and added to the balance. | `currency`, `amount_minor`, `new_balance_minor` and `receipt_id` when known | | `credit_purchase.failed` | A credit purchase failed. | `currency`, `amount_minor` | | `receipt.issued` | A receipt was issued. | `receipt_number`, `kind` (`payment`, `invoice_payment`, `credit_purchase`, `wallet_debit` or `refund`), `currency`, `amount_minor` | | `account.activated` | The account's verification payment succeeded and it can create resources. | Empty | | `account.past_due` | The account is past due. | `reason_code`, `currency`, `amount_owed_minor` | | `account.current` | The account is no longer past due. | Empty | | `spend_cap.reached` | Unbilled usage reached the account's spend cap. | `currency`, `accrued_minor`, `cap_minor` | ```json { "id": "evt_06gk3x9tq7m2v8r4y1c5d0zp8w", "type": "invoice.paid", "api_version": "2026-10-22", "created_at": "2026-11-01T00:12:40Z", "organization_id": "org_06gk3x9tq7m2v8r4y1c5d0zp8w", "project_id": "acc_06gk3x9tq7m2v8r4y1c5d0zp8w", "data": { "invoice_id": "inv_06gk3x9tq7m2v8r4y1c5d0zp8w", "invoice_number": "INV-d0zp8w-0007", "currency": "NGN", "amount_minor": 1504000, "paid_with": "payment", "receipt_id": "rct_06gk3x9tq7m2v8r4y1c5d0zp8w" } } ``` ## Test | Event | When | `data` | | --- | --- | --- | | `webhook.test` | You asked for a [test event](https://docs.pantechdynamics.com/webhooks/overview#test-it). It is sent whatever the webhook subscribes to, and you cannot subscribe to it. | `endpoint_id` (the webhook's id), `message` | ## New event types New types are added from time to time, and a `.*` or `*` subscription receives them without a change. Ignore a type you do not recognise and answer `2xx` anyway, or it is retried. A new field in `data` is not a breaking change either: ignore fields you do not use. A change that would break a receiver comes with a new `api_version`. --- # Sign up and sign in Source: https://docs.pantechdynamics.com/getting-started/authentication Creating an account, confirming your email, signing in with a password or Google, and getting back in when you forget your password. ## Create an account Go to [console.pantechdynamics.com/signup](https://console.pantechdynamics.com/signup) and either: - enter your **work email** and a **password**, or - press **Continue with Google**. A password must be at least 12 characters. There are no rules about symbols or digits; instead we check it against known breached passwords and refuse one that has leaked. We email you a six-digit code, and the console opens **Verify your email**. Enter the code there to confirm the address: that signs you in. If it has not arrived, check spam. After a minute you can use **send a new code**, which replaces the old one. You can't use an account until its email is verified. Nothing is set up until you enter the code, and closing the page doesn't skip the step: the next time you sign in, we email a new code and ask for it first. Signing up with Google doesn't need a code, because Google has already confirmed the address. Once the email is verified, the console walks you through setting up your profile, two-factor, your organization and a payment method. The [quickstart](https://docs.pantechdynamics.com/getting-started/quickstart#2-set-up-your-account) covers each step. ## Sign in At [console.pantechdynamics.com/login](https://console.pantechdynamics.com/login), enter your email and password, or press **Continue with Google** if that is how you signed up. **Keep me signed in** keeps you signed in on that browser for 30 days. Leave it unticked on a shared computer, and you are signed out when the browser closes. If you never confirmed your email, signing in with your password doesn't sign you in yet. We email a six-digit code and show **Verify your email**. Enter the code and you continue. Codes last 10 minutes, and you can ask for a new one once a minute. The sign-in itself expires after 15 minutes or 5 wrong codes; then enter your email and password again and we'll send a fresh code. If you were already signed in on a browser before confirming, the console signs you out and asks you to verify the same way. If you have [two-factor](https://docs.pantechdynamics.com/account/two-factor) on, you are asked for the six digits from your authenticator app next, or **Use a recovery code** if you do not have your phone. If you take too long, the sign-in times out; enter your email and password again. ## Forgotten password 1. On the sign-in page, press **Forgot password?** 2. Enter your email and press **Send reset link**. 3. Open the link in the email and choose a new password, twice. We say a link is sent "if an account exists" for any address, so the form cannot be used to find out who has an account. The link works once and expires 30 minutes after it was sent. If it has expired, request a new one. Setting a new password signs you out everywhere else. ## Accepting an invite If a teammate invited you, the email links to an invite page showing the organization, your role and when the invite expires. - **New to Pantech?** Choose a password and press **Accept invite**. You're signed in straight away. You don't need a separate code: the invite reached your inbox, so accepting it confirms your email. - **Already have an account?** Press **Log in first**, then accept. If you never confirmed that account's email, signing in asks for the six-digit code first, as above, and then brings you back to the invite. Accepting confirms your email only when the invite was sent to that address. If you accept while signed in to an account with a different email, you still join, but that account's email stays as it was. **Decline** turns it down. An invite that has expired or been used says so; ask whoever invited you to send another. See [team and roles](https://docs.pantechdynamics.com/account/team). ## Signing out Open the account menu at the top of the console and choose **Log out**. --- # API keys Source: https://docs.pantechdynamics.com/getting-started/api-keys Let scripts, CI and Terraform call the Pantech API on behalf of your organization, with read-only or read-and-write access. An API key acts for your organization, not for the person who made it. Every member can see every key; owners and admins create and revoke them. ## Before you start - You need to be an owner or admin of the organization. - [Two-factor authentication](https://docs.pantechdynamics.com/account/two-factor) (MFA) must be on for your account. You cannot create an API key until it is. - You need to have signed in recently. If it has been a while, the console asks you to sign in again and brings you back. :::warning[Set up two-factor first] The console will not create an API key for an account without two-factor authentication. Go to **Profile** from the account menu, find **Two-factor** and turn it on, then come back here. [Two-factor authentication](https://docs.pantechdynamics.com/account/two-factor) walks through it. ::: ## Create a key 1. Open **Organization → API keys**. It is also in the account menu. 2. Press **Create key**. 3. Name it after what will use it, like `github-actions` or `terraform-prod`, so you know what to revoke later. 4. Choose the access: **Read only**, or **Read and write**. 5. Choose when it expires: 7, 30, 60, 90, 180 or 365 days. It defaults to 90. There is no key that never expires. 6. Press **Create API key**. :::warning[The key is shown once] Copy it straight into your CI's secret store or password manager. We never store the full key, so it cannot be shown again. If you lose it, revoke it and create another. ::: An organization can have up to 25 active keys. ## Keys for AI apps When you [connect an AI app](https://docs.pantechdynamics.com/ai/connect-your-ai), such as Claude or Cursor, the console makes a key for it too. Any member can connect one, not only owners and admins, and the app's key can never do more than that member can. These keys are listed under **Organization › AI apps**, not on the API keys page, and they do not count toward the 25 above: an organization can have up to 25 connected AI apps as well. An AI app's key can have a third scope, `delete`, which only AI apps use. [Access levels for AI apps](https://docs.pantechdynamics.com/ai/ai-apps-security#access-levels). ## Use a key Keys start with `PAN_`. Send yours as a bearer token with each request: ```http Authorization: Bearer PAN_… ``` Keep it in an environment variable rather than in your code. A key belongs to one organization, so it is the only thing a request needs to say who it is for. This lists your virtual machines: ```console $ export PANTECH_API_KEY=PAN_… $ curl https://api.pantechdynamics.com/public/v1/instances \ -H "Authorization: Bearer $PANTECH_API_KEY" ``` Requests that change something, like creating or deleting a machine, also need an `Idempotency-Key` header: a fresh UUID per action, reused if you retry it, so a retry never does the work twice. A key works until it expires or is revoked. It cannot create or manage other keys; that only works from the console. A key can revoke itself, though: `DELETE /public/v1/me` ends the key that sends it, which is how `pantech auth logout` signs out. Every request a key makes is recorded: when, from where, what it called and how it ended. Keys work only on the public API, at `/public/v1` on api.pantechdynamics.com. The [API reference](https://docs.pantechdynamics.com/api) lists every endpoint, with examples. ## See and revoke keys The table lists every key with its access, who created it, when it was last used and when it expires. Only the start and end of each key are shown. Keys expiring within 7 days are flagged. To revoke a key, press **Revoke** on its row, then **Revoke key**. Revoking is final: anything using the key starts failing within about 30 seconds. The key stays in the list, under **All**, as a record. --- # Two-factor authentication Source: https://docs.pantechdynamics.com/account/two-factor Ask for a code from your phone, or a passkey, at every sign-in, so a stolen password is not enough on its own. ## Why turn it on Anyone who can sign in to your account can create and destroy machines and, depending on your role, move money. Two-factor means a password alone is not enough: signing in also needs a code from an authenticator app on your phone. ## Turn it on You are offered it during setup, in the **Security** step. If you skipped it, go to **Profile** from the account menu and find **Two-factor**. 1. Press **Set up authenticator app**. 2. Scan the QR code with Google Authenticator, 1Password, Authy, or any app that shows six-digit codes. If you cannot scan it, copy the key shown under it into the app instead. 3. Enter the six digits the app shows, and press **Turn on two-factor**. ![Set up an authenticator app: a QR code, the key to type in instead, and six boxes for the code](https://docs.pantechdynamics.com/images/two-factor/scan.png) ## Save your recovery codes As soon as two-factor is on, you are shown a set of **recovery codes**. Each one signs you in once without your phone. **This is the only time they are shown.** Press **Download** or **Copy** and keep them somewhere safe, such as a password manager or a printed copy put away. Then tick **I've saved these codes somewhere safe** and press **Done**. ![Save your recovery codes: ten codes, Download and Copy, and a checkbox before Done](https://docs.pantechdynamics.com/images/two-factor/recovery-codes.png) ## Passkeys and security keys A passkey (Touch ID, Face ID, Windows Hello, your phone) or a hardware security key can be your second factor instead of, or as well as, an authenticator app. On **Profile**, under **Two-factor**, press **Add a passkey**, name it after the device it lives on, and follow your browser's prompt. If it is your first second factor, you are shown recovery codes, as above. Each passkey is listed with its name, which you can **Rename**, and can be removed. ## Signing in with two-factor After your password, enter the code your app shows for Pantech. Codes change every 30 seconds; if one is refused, wait for the next. With a passkey, choose **Use a passkey or security key** and confirm on your device. No phone? Choose **Use a recovery code** and enter one of your saved codes, like `xxxxx-xxxxx`. Each code works once. ## Running low on recovery codes **Profile** shows how many recovery codes you have left. Before you run out, press **Generate new codes**. Your old codes stop working the moment new ones are made, and the new set is shown once. ## Turn it off On **Profile**, press **Turn off two-factor** and confirm with a code from your app or a recovery code. Your authenticator app and every recovery code stop working, and signing in needs only your password again. ## Lost your phone and your codes Without your app or a recovery code, two-factor cannot be turned off from the console. [Contact us](https://pantechdynamics.com/contact) from the email address on the account. --- # Team and roles Source: https://docs.pantechdynamics.com/account/team Invite people to your organization, choose the role each one holds, and see who has two-factor on. ## Roles Everyone in an organization holds one role. They are fixed (there is no policy editor), so give each person the narrowest one that still lets them work. | Can… | Owner | Admin | Viewer | | --- | :---: | :---: | :---: | | See virtual machines, volumes and networking | ✓ | ✓ | ✓ | | Create and destroy virtual machines | ✓ | ✓ | | | Manage volumes and snapshots | ✓ | ✓ | | | Edit security groups and networking | ✓ | ✓ | | | Invite Viewers | ✓ | ✓ | | | See the balance | ✓ | ✓ | | | See invoices, add funds, change payment methods | ✓ | | | | Remove Owners or delete the organization | ✓ | | | - **Owner**: full control. Whoever creates the organization is its first Owner. - **Admin**: recommended for engineers: builds and destroys infrastructure, but cannot touch payment methods. - **Viewer**: for contractors and anyone who needs to look, not change. A **Billing** role, for finance staff who need invoices but never a server, is shown in the console but cannot be given yet. There is no charge per member. ## Invite someone 1. Go to **Team** and press **Invite member**. 2. Enter their **Email**. 3. Choose a **Role**. **What each role can do** compares them side by side. 4. Press **Send invite**. They get one email with a link. The invite shows on **Team** as pending, with when it was sent and when it expires. If it has not arrived, press **Resend** on its row. [Accepting an invite](https://docs.pantechdynamics.com/getting-started/authentication#accepting-an-invite) covers what they see. ## The team list **Team** lists every member with their role, whether they have two-factor on, when they were last active and when they joined. Filter it by role, two-factor or status. Two-factor matters most for Owners and Admins, who can destroy machines and move money. The page tells you how many people can sign in without it. Ask them to [turn it on](https://docs.pantechdynamics.com/account/two-factor). ## What a member leaves behind Removing a member does not delete what they built. Their virtual machines, keys and volumes stay with the organization. Their [SSH keys](https://docs.pantechdynamics.com/compute/ssh-keys) stay on every machine that already has them, though. Rotate those yourself when someone leaves. ## Changing a role or removing a member Open the member from **Team**. Owners can change anyone's role and remove anyone; Admins can do both for Viewers. Neither applies to yourself. **Change role** lists every role with what it would let the member do. A role you can't grant is listed but greyed out. Making someone an Owner or Admin warns you first, more strongly when the member has no two-factor, and the change takes effect at once, without the member signing out and in. **Remove** ends the member's access at once and signs them out. Nothing they built is deleted. ## Your organization **Organization**, in the account menu, shows the organization's name, its region, when it was created, how many members and machines it has, and its balance. Owners can rename it there, and set its **Security policy**: **Require two-factor for everyone**, or **for Owners and Admins only**. The page says whether the platform enforces the policy yet; once it does, anyone not enrolled gets an email and 7 days to turn on [two-factor](https://docs.pantechdynamics.com/account/two-factor) before they're locked out. Every Owner is always emailed when a machine or volume is destroyed. ### Delete the organization Owners see a **Delete organization** card listing what deleting would take: every machine, volume, network, security group, snapshot and SSH key, every member's access, and any credit left, which is forfeited. Press **Delete organization** and type its name to confirm. It can't be undone. --- # Your profile and account Source: https://docs.pantechdynamics.com/account/profile Update your details, change your email or password, and delete your account when you are done with Pantech. Open **Profile** from the account menu at the top of the console. ## Details Your name, phone number, gender and address. They go on your invoices and are not shown to anyone outside your organization. Change any of them and press **Save details**. ## Change your email Press **Change email**, enter the new address and your password (and a code from your second factor, if two-factor is on). We send a 6-digit code to the new address: enter it and press **Confirm new email**. Until you do, you keep signing in with the old one. ## Change your password Press **Change password**, enter your current password and the new one twice (at least 8 characters). You are signed out everywhere, this browser included, and sign in again with the new one. If two-factor is on, you also confirm with your second factor. Forgot it? **Reset by email** sends a link to set a new one, the same as for a [forgotten password](https://docs.pantechdynamics.com/getting-started/authentication#forgotten-password). If you sign in with Google, there is no Pantech password: changing your email needs one, so press **Set a password** first. ## Two-factor Turn two-factor on or off, add passkeys and security keys, and make new recovery codes. See [two-factor authentication](https://docs.pantechdynamics.com/account/two-factor). ## Delete your account Deleting your account removes your login and signs you out everywhere. - **Organizations only you own are deleted too**, with everything in them, and any credit left in them is not refunded. They must be empty first: no machines, volumes, networks or snapshots, and nothing owed. - **Organizations you share, you leave.** Everything in them stays. To delete it, press **Delete account** under **Delete account**, enter your password and confirm with **Delete my account**. Two cases cannot be deleted from the console yet: - **With two-factor on**: turn it off first. - **If you sign in with Google**: there is no password to confirm with. [Contact us](https://pantechdynamics.com/contact) instead. --- # Notifications Source: https://docs.pantechdynamics.com/account/notifications Your inbox in the console, which emails we send, which ones you can turn off, and where they go. ## Your inbox **Notifications**, in the account menu, starts with your inbox: replies to your support tickets, accepted invites, and anything else that needs a look, newest first. Open one to go where it points. **All** shows everything, **Unread** what you haven't opened. The email settings are below it. ## We send few emails There is no marketing list, no digest and no newsletter attached to your account. What we do send is something you would want to know about, and you choose most of it under **Notifications** in the account menu. Only Owners and Admins can change these settings, and they apply to the whole organization. ## What you can be told about | Email | When | Can be turned off | | --- | --- | :---: | | **Runway below 7 days** | Your balance will run out within a week at the current rate | No | | **Runway below 3 days** | The same, with three days left | No | | **Auto top-up charged** | Before an automatic top-up charges your card, not after | See the page | | **Monthly statement ready** | On the first of the month | Yes | | **VM or volume destroyed** | Any virtual machine or volume is destroyed. Goes to every Owner | No | | **VM stopped unexpectedly** | A machine stopped without anyone asking, such as a host failure or an out-of-memory kill | Yes | | **Volume above 80% full** | Once per volume per week, not once a day | Yes | | **Snapshot failed** | A snapshot could not be taken | Yes | | **Scheduled maintenance on your host** | At least seven days ahead | Yes | | **New sign-in from an unknown device** | With the location and IP address | Yes | | **API key created or revoked** | A key is made or revoked. Goes to Owners only | See the page | | **Member added or role changed** | Someone joins the team or changes role. Goes to Owners | Yes | The page shows which ones are locked on. The ones you cannot turn off are the ones that stop you losing data without knowing: running out of credit, and something being destroyed. Tick or untick each one, then save. The page tells you when you have unsaved changes. ## Where they go - **Primary email**: the Owner's verified address. - **Billing email**: statements and receipts go here instead of to Owners and Admins. Set it to your finance team's address, or leave it empty to stop sending them separately. --- # Getting help Source: https://docs.pantechdynamics.com/help/support-tickets Where to look first, how to open a ticket that gets answered quickly, and how the conversation works after that. ## Start in the console **Help & first step**, at the foot of the sidebar, is the place to begin: - A search over these docs, and answers to the questions people ask most, such as being locked out of a machine, restoring a snapshot, narrowing port 22. - Whether the platform is healthy right now, and the last incident. See [platform status](https://docs.pantechdynamics.com/reference/status-and-sla). - Your tickets: how many are open, how many are waiting on you, and how many closed in the last 30 days. ## Open a ticket A ticket reaches a person. It is the best route for anything involving logs or a particular virtual machine. 1. On **Help & first step**, press **Open a ticket**. 2. Under **What is happening**: - **Subject**: a line that says what is wrong, like "web-03 not answering on 443 since 14:00". - **Related resource**: the virtual machine it is about, if there is one. Attaching it lets us look without asking you for IDs. - **Description**: what you expected, what happened instead, and what you have already tried. 3. Under **How urgent**, pick one: | Urgency | Use it when | | --- | --- | | **Production down** | Customers cannot reach something that was working | | **Degraded** | It works, but slowly, flakily, or partly | | **Question** | You want to understand something before you do it | 4. Press **Send ticket**. Choose urgency honestly. There is no penalty for picking the wrong one and no queue to game: a question marked production-down still gets answered, we just wake someone up first. ## What we see The ticket carries the machine you attached, with its size, and your role in the organization. We never take anything from inside your machine: no files, no logs from its disk, no environment variables. If we need those, we ask, and you decide. ## The conversation Open a ticket from **Help & first step** → **Your tickets**, or **Tickets**. Each shows where it stands: | Status | Meaning | | --- | --- | | **With us** | It is our turn to reply | | **We replied** | Support has answered, and it is waiting on you | | **Closed** | Finished | Write in the reply box at the bottom of the conversation to answer. The page picks up our replies as they arrive. ## Close a ticket When the fix holds, press **Close ticket**. A closed ticket stays readable for good, but cannot take replies or be reopened. If the problem comes back, open a new one and mention the old one. ## Can't sign in? Tickets need you signed in. If you cannot get into your account, [contact us](https://pantechdynamics.com/contact) instead. --- # Platform status Source: https://docs.pantechdynamics.com/reference/status-and-sla Where to check whether Pantech is healthy, and where incidents are written up. ## The status page [status.pantechdynamics.com](https://status.pantechdynamics.com) shows live checks on the platform and a write-up of every incident. It is hosted apart from the console, so it still answers when the console does not. Check it first when something stops working for no reason you can find. If an incident is open, the fix is ours; you do not need to open a ticket to tell us. ## In the console **Help & first step** shows the same picture in brief: whether every check is passing, and the date and summary of the last incident. The account menu at the top of the console links to the status page too. ## When it is not us If the status page is green and something is still wrong: 1. Check the machine's **Metrics** tab. A full disk or memory exhaustion looks a lot like an outage. 2. Try the [serial console](https://docs.pantechdynamics.com/compute/serial-console). If it works when SSH does not, the problem is networking or a firewall rule. 3. [Open a ticket](https://docs.pantechdynamics.com/help/support-tickets), with the machine attached. --- # SDKs Source: https://docs.pantechdynamics.com/sdks Official Pantech libraries for Go, Python and JavaScript. They handle keys, retries, idempotency, pagination and waiting for you. The SDKs call the [public API](https://docs.pantechdynamics.com/api) for you. You write `create an instance`; they add the headers, retry safely, follow pages and wait for the change to finish. | Language | Install | Import | | --- | --- | --- | | [Go](https://docs.pantechdynamics.com/sdks/go) | `go get github.com/Pantech-Dynamics/pantech-go` | `github.com/Pantech-Dynamics/pantech-go` (package `pantech`) | | [Python](https://docs.pantechdynamics.com/sdks/python) | `pip install pantech` | `import pantech` | | [JavaScript and TypeScript](https://docs.pantechdynamics.com/sdks/javascript) | `npm install @pantechdynamics/sdk` | `import { Pantech } from "@pantechdynamics/sdk"` | ## What every SDK does - **Reads your key from `PANTECH_API_KEY`.** Or pass it in. Make one under [API keys](https://docs.pantechdynamics.com/getting-started/api-keys). - **Talks to `https://api.pantechdynamics.com/public/v1`.** Set `PANTECH_API_URL`, or pass a base URL, to use another. - **Adds an `Idempotency-Key` to every change.** A fresh UUID per call, reused on each retry, so a retry never does the work twice. [Why it matters](https://docs.pantechdynamics.com/api/conventions#idempotency). - **Retries what is safe to retry.** Network errors and timeouts, `429` (after `Retry-After`), `500`, `502`, `503` and `504`, up to 3 times with a growing, random wait. The few calls the API does not replay, such as adding or deleting an SSH key, opening an instance console, or a webhook's test, delete, rotate-secret and redeliver, are sent once, and retried only after a `429`, which means the API did not run them. - **Turns errors into one error type** with the HTTP status, the stable `code`, the title, the detail, the `request_id` and any field errors. [Error codes](https://docs.pantechdynamics.com/api/errors). - **Follows pages for you.** Loop over every item; the SDK follows `next_cursor`. - **Waits for changes.** `operations.wait` polls an operation until it finishes, and raises an error if it failed. New machines and databases are waited on through their order. - **Verifies webhooks.** Checks the `Pantech-Signature` header on a delivery. [How](https://docs.pantechdynamics.com/sdks/webhooks-verify). Every SDK covers every endpoint in the [API reference](https://docs.pantechdynamics.com/api). ## Which one to use Use the language your project already uses. All three do the same things. If you only need a few calls from a shell script, the [CLI](https://docs.pantechdynamics.com/cli) may be simpler. To let an AI app manage your servers, see [Connect your AI](https://docs.pantechdynamics.com/ai/connect-your-ai). ::::cards :::card[Go]{href=/sdks/go} Go 1.23 or later. No dependencies outside the standard library. ::: :::card[Python]{href=/sdks/python} Python 3.9 or later. Sync and async clients. ::: :::card[JavaScript]{href=/sdks/javascript} Node 18+, Bun, Deno and edge runtimes. No dependencies. ::: :::: --- # Go SDK Source: https://docs.pantechdynamics.com/sdks/go Install pantech-go, create a client, make a machine and wait for it, and handle errors, pages and retries. ```go c, _ := pantech.NewClient() me, _ := c.Me.Get(ctx) fmt.Println(me.OrganizationID, me.APIKey.Scopes) ``` `pantech-go` is the official Go library. It needs Go 1.23 or later and has no dependencies outside the standard library. ## Install ```console $ go get github.com/Pantech-Dynamics/pantech-go ``` ```go import pantech "github.com/Pantech-Dynamics/pantech-go" ``` The package is called `pantech`. ## Authenticate Make an [API key](https://docs.pantechdynamics.com/getting-started/api-keys) and put it in `PANTECH_API_KEY`. `NewClient` reads it. ```console $ export PANTECH_API_KEY=PAN_… ``` Or pass options: ```go c, err := pantech.NewClient( pantech.WithAPIKey(os.Getenv("MY_PANTECH_KEY")), pantech.WithBaseURL("https://api.pantechdynamics.com/public/v1"), // or PANTECH_API_URL pantech.WithMaxRetries(5), // default 3; 0 turns retries off ) ``` The other options are `WithHTTPClient(h)` (its `Timeout` bounds each attempt; the default is 90 seconds), `WithUserAgent(ua)` and `WithHeader(key, value)`. `c.Me.Get(ctx)` describes the key, and `c.Me.Revoke(ctx)` revokes it (`DELETE /me`). ## Create a machine and wait for it ```go title="main.go" package main import ( "context" "fmt" "log" pantech "github.com/Pantech-Dynamics/pantech-go" ) func main() { ctx := context.Background() c, err := pantech.NewClient() if err != nil { log.Fatal(err) } order, err := c.Instances.Create(ctx, pantech.CreateInstanceRequest{ Name: "web-01", PlanSlug: "individual", ImageSlug: "ubuntu-24-04", SSHKeyID: "sshk_…", }) if err != nil { log.Fatal(err) } // Creating a machine places an order: payment, then provisioning. // WaitForOrder returns when the order is provisioned, or an // *pantech.OrderError if it ends payment_failed or failed. provisioned, err := c.Instances.WaitForOrder(ctx, order.OrderID, nil) if err != nil { log.Fatal(err) } if provisioned.OperationID != nil { if _, err := c.Operations.Wait(ctx, *provisioned.OperationID, nil); err != nil { log.Fatal(err) } } instance, err := c.Instances.Get(ctx, order.InstanceID) if err != nil { log.Fatal(err) } fmt.Println(instance.Name, instance.ObservedState) if instance.PublicIPv4 != nil { fmt.Println(*instance.PublicIPv4) } } ``` Optional request fields are pointers, and a nil field is not sent. `pantech.Ptr` sets one inline, for example `Tags: pantech.Ptr(map[string]string{"env": "prod"})`. `c.Databases.WaitForOrder` does the same for a new database. Read real slugs from the catalogue first: `c.Catalog` lists regions, plans, images, disk offerings, database engines and Kubernetes versions, and estimates prices. ## Services Each part of the API is a field on the client: `c.Instances`, `c.Networks`, `c.Subnets`, `c.FirewallRules`, `c.PublicIPs`, `c.PortForwardingRules`, `c.SecurityGroups`, `c.LoadBalancers`, `c.Volumes`, `c.Snapshots`, `c.Databases`, `c.Kubernetes`, `c.SSHKeys`, `c.Webhooks`, `c.Operations`, `c.Catalog`, `c.Billing` and `c.Me`. ## Errors Every API error is a `*pantech.Error`, with `Status`, `Code`, `Title`, `Detail`, `RequestID`, `Errors` (the invalid fields of a `422`) and `RetryAfter`. Check the code with `pantech.IsCode`, and get the error with `pantech.AsError` (or `errors.As`): ```go _, err := c.Instances.Get(ctx, "vm_…") if pantech.IsCode(err, "RESOURCE_NOT_FOUND") { fmt.Println("no such machine") } if apiErr, ok := pantech.AsError(err); ok { log.Printf("%s: %s (request %s)", apiErr.Code, apiErr.Detail, apiErr.RequestID) } ``` Act on the code, never the wording. [Every code](https://docs.pantechdynamics.com/api/errors). ## Pages A paginated list has two methods. `List` returns one `*pantech.Page[T]` (`Data`, `NextCursor`, `HasMore()`). `All` iterates over every item, following `next_cursor` for you, as a Go `iter.Seq2`: ```go page, err := c.Instances.List(ctx, &pantech.ListParams{Limit: 50, Q: "web"}) if err != nil { log.Fatal(err) } fmt.Println(len(page.Data), page.HasMore()) for instance, err := range c.Instances.All(ctx, nil) { if err != nil { log.Fatal(err) } fmt.Println(instance.Name) } ``` Lists the API returns in one page, such as regions, plans, images and SSH keys, return a plain slice. ## Operations Most changes return an operation. Wait for it before you rely on the result: ```go accepted, err := c.Instances.Stop(ctx, "vm_…") if err != nil { log.Fatal(err) } op, err := c.Operations.Wait(ctx, accepted.OperationID, &pantech.WaitOptions{Timeout: 10 * time.Minute}) var failed *pantech.OperationError if errors.As(err, &failed) { fmt.Println(failed.Operation.Failure.Code, failed.Operation.Failure.Reason) } else if err == nil { fmt.Println(op.Status) } ``` `Wait` polls `GET /operations/{id}` (every 2 seconds by default; pass `nil` for the defaults) until the status is `succeeded` or `failed`, and returns a `*pantech.OperationError` when it failed. `c.Operations.Stream(ctx, id)` follows it over Server-Sent Events instead. ## Idempotency and retries Every `POST`, `PUT`, `PATCH` and `DELETE` gets an `Idempotency-Key` automatically (a random UUID). The same key is sent on every retry, so the change happens once. To make a change safe to repeat across runs, choose the key yourself: ```go order, err := c.Instances.Create(ctx, req, pantech.WithIdempotencyKey("deploy-42-web-1")) ``` The client retries network errors, `429`, `500`, `502`, `503` and `504` up to 3 times, with a growing, random wait (0.5 s, 1 s, 2 s… up to 8 s), and honours `Retry-After` (up to 60 seconds). Calls the API does not replay are retried only on `429`, which the API refuses before acting: creating or deleting an SSH key, opening an instance console, and testing, deleting, rotating the secret of or redelivering a webhook. [The list](https://docs.pantechdynamics.com/api/conventions#idempotency). Cancelling the context stops a call and its retries at once. ## Calling any endpoint `Do` calls a path directly, with the same auth, retries and errors: ```go var out map[string]any err := c.Do(ctx, "GET", "/instances", url.Values{"q": {"web"}}, nil, &out) ``` Every typed method, and `Do`, takes per-request options: `pantech.WithIdempotencyKey(key)` to choose the key yourself, `pantech.WithRequestHeader(key, value)`, and `pantech.WithNoRetry()` to send a call once. ## Webhooks `pantech.VerifyWebhook(secret, header, body, tolerance)` checks a delivery's signature and returns an error if it fails. `pantech.ParseWebhook` checks it and decodes the event in one call. [Verify webhooks with the SDKs](https://docs.pantechdynamics.com/sdks/webhooks-verify#go). --- # Python SDK Source: https://docs.pantechdynamics.com/sdks/python Install pantech from PyPI, create a client, make a machine and wait for it, and handle errors, pages and retries. ```python import pantech client = pantech.Pantech() print(client.me.get()["organization_id"]) ``` `pantech` is the official Python library. It needs Python 3.9 or later, and its only dependency is `httpx`. ## Install ```console $ pip install pantech ``` ## Authenticate Make an [API key](https://docs.pantechdynamics.com/getting-started/api-keys) and put it in `PANTECH_API_KEY`. The client reads it. ```console $ export PANTECH_API_KEY=PAN_… ``` Or pass it, with an optional base URL (also read from `PANTECH_API_URL`): ```python import os import pantech client = pantech.Pantech( api_key=os.environ["MY_PANTECH_KEY"], base_url="https://api.pantechdynamics.com/public/v1", max_retries=5, # default 3 ) ``` The other arguments are `timeout` (an `httpx.Timeout`, default 60 seconds), `default_headers`, `user_agent` and `http_client` (your own `httpx.Client`). Use the client as a context manager, or call `client.close()`, to close its connections. ## Create a machine and wait for it ```python title="create.py" import pantech client = pantech.Pantech() order = client.instances.create( name="web-01", plan_slug="starter", image_slug="ubuntu-24-04", ssh_key_id="sshk_…", ) # Creating a machine places an order: payment, then provisioning. # wait_for_order returns the order once it is provisioned. done = client.instances.wait_for_order(order["order_id"]) instance = client.instances.get(done["instance_id"]) print(instance["name"], instance["public_ipv4"]) ``` `wait_for_order` raises `pantech.OrderFailedError` if the payment or the order fails, and `pantech.WaitTimeoutError` after 15 minutes (pass `timeout=` in seconds to change it). Databases have the same `client.databases.wait_for_order`. Read real slugs from the catalogue first: `client.catalog.list_all_plans()`, `list_all_images()`, `list_all_regions()` and `list_all_disk_offerings()`. ## Resources Each part of the API is an attribute of the client: `client.instances`, `client.networks`, `client.public_ips`, `client.security_groups`, `client.load_balancers`, `client.volumes`, `client.snapshots`, `client.databases`, `client.kubernetes`, `client.ssh_keys`, `client.webhooks`, `client.operations`, `client.catalog`, `client.billing`, `client.usage` and `client.me`. Families nest where the API does, for example `client.networks.subnets`, `client.networks.firewall_rules`, `client.public_ips.port_forwarding_rules` and `client.kubernetes.node_pools`. Responses are plain dictionaries, typed with `TypedDict`s from `pantech.types`. ## Async `pantech.AsyncPantech` has the same methods, to `await`: ```python import asyncio import pantech async def main(): async with pantech.AsyncPantech() as client: me = await client.me.get() print(me["organization_id"]) asyncio.run(main()) ``` In the async client, `list_all` returns an async iterator: `async for instance in client.instances.list_all():`. ## Errors Every API error is a `pantech.APIError`, with `status`, `code`, `title`, `detail`, `request_id` and field `errors`. Common ones have their own class: | Class | When | | --- | --- | | `BadRequestError` | `400` | | `AuthenticationError` | `401`: the key is wrong, expired or revoked | | `PaymentRequiredError` | `402`: not enough credit for this order | | `PermissionDeniedError` | `403`: the key's scope or its creator's role does not allow it, or AI apps are turned off | | `NotFoundError` | `404` | | `ConflictError` | `409` | | `ValidationError` | `422`: see `errors` for each bad field | | `RateLimitError` | `429`, after the retries ran out; `retry_after` is the wait asked for | | `InternalServerError` | `5xx`, after the retries ran out | Errors that are not an API answer: `APIConnectionError` (the request never got an answer) and its subclass `APITimeoutError`, `OperationFailedError` and `OrderFailedError` (a wait ended in failure), `WaitTimeoutError` and `WebhookVerificationError`. All of them, `APIError` included, are a `pantech.PantechError`. ```python try: client.instances.get("vm_…") except pantech.NotFoundError: print("no such machine") except pantech.APIError as e: print(e.code, e.detail, e.request_id) ``` Act on `code`, never the wording. [Every code](https://docs.pantechdynamics.com/api/errors). ## Pages `list()` returns one page: a dictionary with `data` and `next_cursor`. `list_all()` loops over every item and follows `next_cursor` for you: ```python for instance in client.instances.list_all(): print(instance["name"]) ``` ## Operations Most changes return an operation. Wait for it before you rely on the result: ```python accepted = client.instances.stop("vm_…") op = client.operations.wait(accepted["operation_id"]) print(op["status"]) # "succeeded" ``` `wait` polls `GET /operations/{id}` every 2 seconds. It raises `pantech.OperationFailedError` (with `code` and `reason`) if the operation fails, unless you pass `raise_on_failure=False`, and `pantech.WaitTimeoutError` after 10 minutes (`timeout=` in seconds, `None` to wait for ever). ## Idempotency and retries Every `POST`, `PUT`, `PATCH` and `DELETE` gets an `Idempotency-Key` automatically. The same key is sent on every retry, so the change happens once. To choose it yourself, pass `idempotency_key=` to any write method. The client retries network errors, timeouts, `429` (after `Retry-After`), `500`, `502`, `503` and `504`, up to 3 times, with a growing, random wait. Calls the API cannot replay safely, such as adding an SSH key, are sent once, and only a `429` is retried. [The list](https://docs.pantechdynamics.com/api/conventions#idempotency). ## Calling any endpoint `client.request` calls a path directly, with the same auth, retries and errors, and returns the parsed body: ```python usage = client.request("GET", "/usage") ``` It also takes `query=`, `body=`, `idempotency_key=`, `extra_headers=`, `timeout=` and `max_retries=`. Every typed method takes `extra_headers=`, `extra_query=` and `timeout=` too. ## Webhooks `pantech.verify_webhook(secret, header, body)` checks a delivery's signature. It returns `None`, and raises `pantech.WebhookVerificationError` if the signature does not verify. [Verify webhooks with the SDKs](https://docs.pantechdynamics.com/sdks/webhooks-verify#python). --- # JavaScript and TypeScript SDK Source: https://docs.pantechdynamics.com/sdks/javascript Install @pantechdynamics/sdk, create a client, make a machine and wait for it, and handle errors, pages and retries. ```ts import { Pantech } from "@pantechdynamics/sdk"; const pantech = new Pantech(); const me = await pantech.me.get(); console.log(me.organization_id); ``` `@pantechdynamics/sdk` is the official JavaScript library, with TypeScript types generated from the API's OpenAPI spec. It has no runtime dependencies and uses the built-in `fetch` and Web Crypto, so it runs on Node 18 or later, Bun, Deno and edge runtimes. It ships as ESM and CommonJS. Request and response fields are exactly as the API sends them (`plan_slug`, `next_cursor`); method names are camelCase. ## Install ```console $ npm install @pantechdynamics/sdk ``` ## Authenticate Make an [API key](https://docs.pantechdynamics.com/getting-started/api-keys) and put it in `PANTECH_API_KEY`. The client reads it. ```console $ export PANTECH_API_KEY=PAN_… ``` Without a key, `new Pantech()` throws. Or pass options: ```ts const pantech = new Pantech({ apiKey: process.env.MY_PANTECH_KEY, baseURL: "https://api.pantechdynamics.com/public/v1", // or PANTECH_API_URL maxRetries: 3, // the default timeout: 60_000, // per attempt, in milliseconds (the default) }); ``` The other options are `fetch` (your own fetch), `defaultHeaders` and `userAgent`. Every method also takes per-call options last: `{ signal, timeout, maxRetries, idempotencyKey, headers }`. :::warning[Keep the key on the server] Never put an API key in code that runs in a browser. Anyone can read it there. ::: ## Create a machine and wait for it ```ts title="create.ts" import { Pantech } from "@pantechdynamics/sdk"; const pantech = new Pantech(); const order = await pantech.instances.create({ name: "web-01", plan_slug: "starter", image_slug: "ubuntu-24-04", ssh_key_id: "sshk_…", }); // Creating a machine places an order: payment, then provisioning. // wait() resolves once it is provisioned and its create operation is done. await pantech.instanceOrders.wait(order); const instance = await pantech.instances.get(order.instance_id); console.log(instance.name, instance.public_ipv4); ``` A failed or unpaid order rejects with `OrderFailedError`. Read real slugs from the catalogue first: `pantech.plans.list()`, `pantech.images.list()` and `pantech.regions.list()`. Databases work the same way, with `pantech.databaseOrders.wait(order)`. ## Resources Each part of the API is a property on the client: `me`, `regions`, `plans`, `images`, `diskOfferings`, `databaseEngines`, `kubernetesVersions`, `pricing`, `usage`, `billing`, `instances`, `instanceOrders`, `networks` (with `subnets` and `firewallRules`), `publicIps` (with `portForwardingRules`), `loadBalancers`, `kubernetes` (with `nodePools` and `addons`), `securityGroups`, `volumes`, `snapshots`, `databases`, `databaseOrders`, `sshKeys`, `webhooks` and `operations`. `pantech.me.get()` describes the key; `pantech.me.revoke()` revokes it. ## Errors Every error the SDK throws is a `PantechError`, with `status`, `code`, `title`, `detail`, `requestId` and `errors` (the field errors of a `422`). Each status has its own subclass: `BadRequestError` (400), `AuthenticationError` (401), `PaymentRequiredError` (402), `PermissionDeniedError` (403), `NotFoundError` (404), `ConflictError` (409), `ValidationError` (422), `RateLimitError` (429), `InternalServerError` (5xx), and `APIConnectionError` and `APITimeoutError` when there is no answer. `isPantechError(err, code)` checks the code. ```ts import { isPantechError, NotFoundError, PantechError } from "@pantechdynamics/sdk"; try { await pantech.instances.get("vm_…"); } catch (err) { if (err instanceof NotFoundError) console.log("no such machine"); else if (isPantechError(err, "INSUFFICIENT_SCOPE")) console.log("the key lacks a scope"); else if (err instanceof PantechError) console.log(err.code, err.detail, err.requestId); else throw err; } ``` Act on `code`, never the wording. [Every code](https://docs.pantechdynamics.com/api/errors). ## Pages `list()` returns one page (`{ data, next_cursor }`). `listAll()` is an async iterator that follows `next_cursor` for you: ```ts for await (const instance of pantech.instances.listAll()) { console.log(instance.name); } ``` ## Operations Most changes return an operation. Wait for it before you rely on the result: ```ts import { OperationFailedError } from "@pantechdynamics/sdk"; const accepted = await pantech.instances.stop("vm_…"); try { const op = await pantech.operations.wait(accepted); // an id, or the 202 body console.log(op.status); // "succeeded" } catch (err) { if (err instanceof OperationFailedError) console.log(err.code, err.detail); else throw err; } ``` `wait` polls `GET /operations/{id}` every 2 seconds. It resolves when the operation has `succeeded`, rejects with `OperationFailedError` when it has `failed`, and with `WaitTimeoutError` after 20 minutes. Options: `{ timeoutMs, pollIntervalMs, signal, onUpdate }`. `pantech.operations.stream(id)` follows it with Server-Sent Events instead. ## Idempotency and retries Every `POST`, `PUT`, `PATCH` and `DELETE` gets an `Idempotency-Key` automatically. The same key is sent on every retry, so the change happens once. The client retries network errors, timeouts, `429` (after `Retry-After`), `500`, `502`, `503` and `504`, up to 3 times (`maxRetries`) with a growing, random wait. It sends once the calls the API does not replay by key: creating and deleting SSH keys, opening an instance console, issuing a volume key credential, testing, deleting, rotating the secret of and redelivering a webhook, and `me.revoke()`. A `429` is still retried, since nothing ran. [The list](https://docs.pantechdynamics.com/api/conventions#idempotency). ## Calling any endpoint `request` calls a path directly, with the same auth, retries and errors: ```ts const page = await pantech.request({ method: "GET", path: "/instances", query: { limit: 10 } }); ``` ## Webhooks `verifyWebhook({ secret, header, body })` checks a delivery's signature with Web Crypto, so it is `async`: it resolves when the signature is valid and rejects with `WebhookVerificationError` when it is not. [Verify webhooks with the SDKs](https://docs.pantechdynamics.com/sdks/webhooks-verify#javascript). --- # Verify webhooks with the SDKs Source: https://docs.pantechdynamics.com/sdks/webhooks-verify Check a webhook delivery's Pantech-Signature in one call, in Go, Python or JavaScript. Every webhook delivery carries a `Pantech-Signature` header. Each SDK checks it for you in one call. Check it before you act on the event, and answer `400` if it fails. ```http Pantech-Signature: t=1792687211,v1=1a5d101f814e8d6654baea274139a16ec4eea3a5f7619322edbfabf799cabb0e ``` What the SDKs check: - `v1` is the HMAC-SHA256 of `.`, keyed with your webhook's secret (`whsec_…`, the whole string). - The comparison takes the same time whatever the input, so it leaks nothing. - `t` must be within 5 minutes of your clock, so an old delivery cannot be replayed. - Any of several `v1` values may match. After you [rotate the secret](https://docs.pantechdynamics.com/webhooks/overview#rotate-the-secret), deliveries are signed with both secrets for 24 hours. :::warning[Use the raw body] Pass the exact bytes you received. If your framework parses the JSON first and you serialize it again, the bytes change and the check fails. ::: How it works underneath, and examples with no SDK: [Verify webhook signatures](https://docs.pantechdynamics.com/webhooks/verify-signatures). ## Go `VerifyWebhook` returns an error when the header is missing or malformed (`pantech.ErrWebhookHeader`), too old (`pantech.ErrWebhookTimestamp`) or does not match (`pantech.ErrWebhookSignature`). A tolerance of `0` means the default, 5 minutes. `ParseWebhook` verifies and decodes the event in one call. ```go title="main.go" package main import ( "io" "log" "net/http" "os" pantech "github.com/Pantech-Dynamics/pantech-go" ) var secret = os.Getenv("PANTECH_WEBHOOK_SECRET") func handle(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } event, err := pantech.ParseWebhook(secret, r.Header.Get("Pantech-Signature"), body, 0) if err != nil { http.Error(w, "invalid signature", http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) // Handle the event after answering. Skip an event.ID you have seen before. log.Println(event.Type, event.ID) } func main() { http.HandleFunc("POST /hooks/pantech", handle) log.Fatal(http.ListenAndServe(":3000", nil)) } ``` To test your receiver, `pantech.SignWebhook(secret, time.Now(), body)` returns a valid header. ## Python `pantech.verify_webhook(secret, header, body, tolerance=300)` returns `None` when the delivery verifies, and raises `pantech.WebhookVerificationError` when the header is missing or malformed, the signature is wrong, or the timestamp is more than `tolerance` seconds from now (`tolerance=None` skips that check). With Flask: ```python title="app.py" import json import os import pantech from flask import Flask, abort, request SECRET = os.environ["PANTECH_WEBHOOK_SECRET"] app = Flask(__name__) @app.post("/hooks/pantech") def pantech_webhook(): body = request.get_data() # the raw bytes try: pantech.verify_webhook(SECRET, request.headers.get("Pantech-Signature"), body) except pantech.WebhookVerificationError: abort(400) event = json.loads(body) # Queue the event. Skip an event["id"] you have seen before. return "", 204 ``` ## JavaScript `verifyWebhook` takes `{ secret, header, body }` and is `async`: it resolves when the signature is valid, and rejects with `WebhookVerificationError` when the header is missing or malformed, the signature is wrong, or the timestamp is too old. `toleranceSeconds` changes the 300-second window. It uses Web Crypto, so the same code works on Node, Bun, Deno and edge runtimes (on Node 18, start node with `--experimental-global-webcrypto`). ```ts title="route.ts" import { verifyWebhook, WebhookVerificationError } from "@pantechdynamics/sdk"; const SECRET = process.env.PANTECH_WEBHOOK_SECRET!; export async function POST(request: Request) { const body = await request.text(); // the raw body try { await verifyWebhook({ secret: SECRET, header: request.headers.get("Pantech-Signature"), body }); } catch (err) { if (err instanceof WebhookVerificationError) { return new Response("invalid signature", { status: 400 }); } throw err; } const event = JSON.parse(body); // Queue the event. Skip an event.id you have seen before. return new Response(null, { status: 204 }); } ```