Overview and base URL
The VDSok Client API is a REST interface over HTTPS with JSON bodies. It covers everything a client can do in the cabinet except tickets, dedicated servers, sub-accounts and profile editing: balance and invoices, the VDS catalog, servers (order, renew, power, reinstall, IPs, PTR, SSH keys, delete with refund), domains, API keys and webhooks.
Base URL
Every path in this guide is relative to:
https://vdsok.guru/api/v1
The version lives in the path. Breaking changes will go to /v2; v1 only grows additively: new fields and endpoints may appear at any time, so ignore unknown fields in responses.
Conventions
- JSON only. A
POST/PUT/PATCHbody that is notapplication/jsonis rejected with415; a body over 64 KB with413. - Timestamps are RFC 3339 in UTC with a trailing
Z:2026-09-15T10:00:00Z. - Money is a decimal string with 2–4 fraction digits, never a float, always next to a
currencyfield (ISO 4217). server_idis the panel id of the VM, the same number the cabinet shows in the URL.- Every response carries
X-Request-ID; quote it in support requests.
Quick start
- Create a key in the cabinet: Settings → API (
/my/api). The key is shown once. - Call
GET /me: the cheapest way to validate the key and see its scopes and limits. - Then read authentication, errors and the servers walkthrough.
curl https://vdsok.guru/api/v1/me \ -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX"
Authentication, scopes and presets
Every request carries Authorization: Bearer <key>. A key starts with vk_live_ (live) or vk_test_ (test, see Sandbox).
Keys are created by the account owner in the cabinet (/my/api): a name, live/test mode, a set of scopes, an optional IP/CIDR allow-list and a validity window (not_before/expires_at). Creation is confirmed with a TOTP code or the password; the secret is shown once. Keys are independent of cabinet sessions: password changes, "log out everywhere" and 2FA changes do not touch them; a ban revokes all keys. Keys cannot be created through the API, only listed (GET /keys) and the calling key can be revoked.
Scopes
Every operation lists its required scopes in x-scopes of the specification; an empty list means "any valid key" (catalog, /me). Only GET /health (always 200 with status: ok | disabled — it is an availability probe, not a protected endpoint) and GET /openapi.json need no key at all. A missing scope → 403 insufficient_scope with details.required naming what is missing.
| Scope | What it unlocks |
|---|---|
account:read | GET /account: profile, client group, discount |
balance:read | balance, transactions, top-up limits |
balance:topup | creating a top-up invoice and payment link |
invoices:read | invoice list, invoice, PDF |
invoices:pay | paying an invoice from balance, payment link |
servers:read | servers, live status, IPs, SSH keys, orders, refund quote |
servers:manage | power, reinstall, password reset, PTR, PATCH, adding and removing SSH keys |
servers:order | ordering, renewal, buying and releasing IPs |
servers:delete | deleting a server with refund |
domains:read | domains, availability check |
domains:manage | auto-renew, WHOIS privacy, nameservers |
domains:order | registration, renewal, transfer |
keys:read | list of the account's keys |
webhooks:manage | subscriptions, deliveries, test, redeliver |
Presets
Presets in the cabinet are just bundles of scopes; you can adjust the checkboxes after picking one.
| Preset | Contents |
|---|---|
read_only | every *:read scope |
operate | read_only + servers:manage, servers:order, domains:manage, invoices:pay |
full | all scopes, including balance:topup, servers:delete, domains:order, webhooks:manage |
Kill switch
DELETE /keys/{key_id} revokes only the key that makes the request, whatever its scopes. A leaked key can be killed from anywhere (CI, a server) without giving every key the power to destroy the whole integration. Other keys are revoked in the cabinet. Works for test keys too.
last_used_ip and is easier to contain.# A key without servers:delete gets 403 with the missing scope in details:
curl -i -X DELETE https://vdsok.guru/api/v1/servers/2001 \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Idempotency-Key: $(uuidgen)"
# HTTP/1.1 403 Forbidden
# {"error":{"code":"insufficient_scope","message":"This key lacks servers:delete",
# "request_id":"9f1c2a9d-4b7e-4a21-8d3f-0c6e5b2a1d44","details":{"required":["servers:delete"]}}}
# Kill switch: revoke the calling key itself
KEY_ID=$(curl -s https://vdsok.guru/api/v1/me -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq .key.id)
curl -X DELETE https://vdsok.guru/api/v1/keys/$KEY_ID -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX"Sandbox: test keys
A test key (vk_test_…) is created in the same cabinet as a live one and talks to the same base URL: there is no separate staging host.
GET /servers, GET /balance and GET /invoices with a test key return your real servers, real balance and real invoices. Only writes differ: no mutation is actually performed.What a test key does:
- Moves no money: ordering, renewing, topping up, paying an invoice and deleting return a plausible response, but the balance does not change, no VM or domain is created and no payment gateway is called.
- Ordering a server returns a fake server with
id >= 9000000000. It lives 24 hours and shows up inGET /serversfor that key only; status, power, reinstall, IPs and delete can be called on it and are simulated too. - Every response to a test key carries the
X-Sandbox: trueheader. Assert on it in your tests so the modes never get mixed up. - Idempotency, rate limits and validation errors behave as in live:
402,409and429can happen in the sandbox as well.
The behaviour of each operation under a test key is described by x-sandbox in the specification:
x-sandbox | Meaning |
|---|---|
real | same as live: every read, DELETE /keys/{key_id} |
fake | simulated write: ordering, renewal, power, domains, top-up |
forbidden | 403 sandbox_not_supported: all webhook endpoints; subscriptions are live-only |
If test keys are switched off on the VDSok side, every request answers 403 sandbox_disabled.
# Same URL, test key: the order is simulated, the header says so
curl -i -X POST https://vdsok.guru/api/v1/servers \
-H "Authorization: Bearer vk_test_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"tariff_id": 12, "os": "ubuntu-24.04", "name": "sandbox-01", "months": 1}'
# HTTP/1.1 201 Created
# X-Sandbox: true
# {"server":{"id":9000000042,"name":"sandbox-01",...},"root_password":"...","charged":"5.90",...}Errors: envelope and codes
The API has exactly one error shape, for every status including app-level 404/405/413/500:
{
"error": {
"code": "insufficient_funds",
"message": "Balance 4.10 USD is below the required 5.90 USD",
"request_id": "9f1c2a9d-4b7e-4a21-8d3f-0c6e5b2a1d44",
"details": {"required": "5.90", "balance": "4.10", "shortfall": "1.80", "currency": "USD"}
}
}code: a machine code from the closed list below. Branch on it, not onmessage.message: human-readable English, not for parsing; wording may change.request_id: the same value as theX-Request-IDheader; quote it to support.details: code-specific extras:required(scopes),fields(per-field validation errors), money figures forinsufficient_funds,statusforservice_state.
The list is closed per status, but a client should still tolerate unknown codes and handle them by HTTP status, so adding a code never breaks an integration.
| Status | Codes |
|---|---|
400 | invalid_request, validation_error, invalid_cursor, idempotency_key_required, os_not_allowed, invalid_period, invalid_action, upstream_rejected |
401 | invalid_token, key_expired, key_not_yet_valid |
402 | insufficient_funds: nothing was charged or created |
403 | insufficient_scope, ip_not_allowed, account_suspended, api_disabled_for_account, sandbox_not_supported, sandbox_disabled, server_blocked, domain_blocked |
404 | not_found: no such object on this account (someone else's objects are 404 too, never 403) |
409 | conflict, idempotency_conflict, idempotency_in_progress, operation_in_progress, service_state, no_capacity, tariff_unavailable, ip_limit_reached, domain_taken, domain_exists, cancel_pending |
413 / 415 | payload_too_large, unsupported_media_type |
429 | rate_limited |
500 | server_error: our fault, the request id is already logged |
502 | upstream_error: the panel, registrar or gateway answered with an error; for money operations nothing was charged unless the response says otherwise |
503 | api_disabled, upstream_unavailable, temporarily_unavailable: retry after Retry-After |
504 | upstream_timeout: the panel, registrar or gateway did not answer in time; retry with the same Idempotency-Key |
What to retry: 429, 502, 503, 504 are safe to retry for GET and for mutations sent with an Idempotency-Key. Do not blindly retry other mutations.
# -f makes curl exit non-zero on 4xx/5xx; -s hides progress; the body still has the envelope
curl -s -w "\n%{http_code}\n" https://vdsok.guru/api/v1/servers/2001 \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX"
# {"error":{"code":"not_found","message":"Server 2001 not found","request_id":"9f1c2a9d-4b7e-4a21-8d3f-0c6e5b2a1d44"}}
# 404
# Extract the code with jq
curl -s https://vdsok.guru/api/v1/servers/2001 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq -r '.error.code // "ok"'Rate limits and headers
The default is 120 requests per minute per key plus a separate bucket of 20 per minute for expensive calls. Limits are per key (not per account, not per IP) over a fixed 60-second window. VDSok staff can raise the limits of a specific key; the effective ceiling and what is left of it come back in the X-RateLimit-Limit and X-RateLimit-Remaining headers of every response, and a specific key’s settings are in GET /keys/{id}. Reads in the cabinet do not count against the API.
Expensive calls are marked x-expensive: true in the specification:
GET /servers/{server_id}/statusandGET /servers/{server_id}?include=live: a live query to the panel;GET /domains/availability: a registrar lookup;GET /catalog/quote;POST /servers,POST /servers/{server_id}/ips,POST /balance/topup;GET /invoices/{invoice_id}/pdf;POST /webhooks/{webhook_id}/test,POST /webhooks/deliveries/{delivery_id}/redeliver.
Separately, POST /servers/{server_id}/actions/reset-password is limited to 5 calls per 5 minutes per server. Requests with an invalid key are limited to 30 per minute per IP, against brute force.
Headers
| Header | Meaning |
|---|---|
X-RateLimit-Limit | requests per minute allowed in the bucket the call hit (normal or expensive) |
X-RateLimit-Remaining | requests left in the current 60-second window |
X-RateLimit-Reset | unix time (seconds) when the window resets |
Retry-After | seconds to wait; present on 429 and 503, and on 409 idempotency_in_progress |
On 429 rate_limited the details carry bucket (normal/expensive) and limit. The right reaction is to sleep for Retry-After and retry, not to hammer in a loop: the window is fixed and will not open earlier.
GET /servers/{id}/status more often than you need: the 20 expensive calls per minute are shared by every live status, domain check and order of that key. To monitor dozens of servers use GET /servers (not expensive) plus the server.suspended/server.terminated webhooks.curl -sD - -o /dev/null https://vdsok.guru/api/v1/servers/2001/status \ -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | grep -i -E "x-ratelimit|retry-after" # X-RateLimit-Limit: 20 <- expensive bucket # X-RateLimit-Remaining: 19 # X-RateLimit-Reset: 1789466460
Pagination
Lists that can grow large (/servers, /invoices, /transactions, /orders, /domains, /webhooks/{id}/deliveries) return a page and a cursor:
{"data": [ … ], "next_cursor": "eyJpZCI6MjAwMSwic2lnIjoi…"}limit: 1 to 100, default 50.next_cursor: pass it back as?cursor=to get the next page;nullmeans this was the last one.- Cursors are opaque and signed: never build or parse them by hand. A tampered cursor answers
400 invalid_cursor. A cursor is bound to its filters: changestatusand start again from the first page. - Order is fixed: newest first. Items added while you iterate may land at the front, but there are no duplicates or gaps within one walk.
Short reference lists (/catalog/*, /servers/{id}/ips, /ssh-keys, /keys, /webhooks) return {"data": [...]} in full, without a cursor.
# First page, 20 active servers
curl -s "https://vdsok.guru/api/v1/servers?status=active&limit=20" \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '{n: (.data|length), next: .next_cursor}'
# Next page: pass next_cursor back verbatim (URL-encode it)
CURSOR=$(curl -s "https://vdsok.guru/api/v1/servers?status=active&limit=20" \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq -r '.next_cursor')
curl -s "https://vdsok.guru/api/v1/servers?status=active&limit=20&cursor=$CURSOR" \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX"Idempotency
Operations that move money or touch external systems are marked x-idempotent: true and require an Idempotency-Key header: POST /balance/topup, POST /invoices/{id}/pay, POST /servers, DELETE /servers/{id}, POST /servers/{id}/renew, POST /servers/{id}/ips, POST /domains, POST /domains/{id}/renew, POST /domains/transfers.
The key is any string of 16..128 characters, unique per logical operation; a UUID v4 is fine. Generate it before the first attempt and reuse it on retries: that is the whole point. The key is remembered for 7 days together with a digest of the endpoint and the body:
| Situation | Answer |
|---|---|
| same key, same request | the stored response is replayed; secrets such as root_password are blanked in the replay |
| same key, different body or endpoint | 409 idempotency_conflict |
| same key while the first request is still running | 409 idempotency_in_progress with Retry-After |
| no key | 400 idempotency_key_required |
The outcome is always recorded, 5xx responses included: otherwise a retry would get idempotency_in_progress forever. That is why a retry never charges twice. A server order that hit a panel timeout (202 provisioning) replays the same 202 with the same invoice_id on retry; poll GET /orders/{invoice_id} from there.
201. If you got a network error after a successful creation and retried, the replay has an empty root_password: use POST /servers/{id}/actions/reset-password.# Generate the key once, reuse it for every retry of this renewal
IDEMPOTENCY_KEY=$(uuidgen)
curl -X POST https://vdsok.guru/api/v1/servers/2001/renew \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"months": 3}'
# Sending it again replays the stored answer, nothing is charged twice
curl -X POST https://vdsok.guru/api/v1/servers/2001/renew \
-H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"months": 3}'Money and dates
Money
Every amount is a string like "5.90", "0.0083", "-12.00" (pattern ^-?[0-9]+\.[0-9]{2,4}$), always next to currency, the ISO 4217 code of the account currency (e.g. USD). Floats are never used in JSON: 0.1 + 0.2 is not 0.3 in binary arithmetic, and in billing that error becomes a discrepancy on an invoice.
- Parse amounts into a decimal type:
Decimalin Python,BigIntin minor units ordecimal.jsin Node,bcmath/BigDecimalin PHP. - In request bodies (
amounton top-up) send a string with two fraction digits:"25.00". The number25is accepted as well,25.005is not. - Hourly prices (
price_hourly) have up to 4 fraction digits:"0.0083". - Transactions carry an absolute
amountand a separatedirection(credit/debit); a signedMoneyappears only in adjustments.
Dates
Every point in time is RFC 3339 in UTC with a trailing Z: 2026-09-15T10:00:00Z. Fields that can be empty (next_due_at, expires_at, paid_at) come as null, never as an empty string or 0000-00-00.
- In requests (
since/untilon/transactions) send RFC 3339 withZor an offset as well. X-RateLimit-ResetandX-Webhook-Timestampare unix seconds, not milliseconds.- Refunds on deletion are computed by whole days of the unused period (
days_leftinRefundQuote); compare your own figures in days, not seconds.
# Sum debits of the last 30 days with jq: strings -> numbers only at the very end SINCE=$(date -u -d "30 days ago" +%Y-%m-%dT%H:%M:%SZ) curl -s "https://vdsok.guru/api/v1/transactions?direction=debit&since=$SINCE&limit=100" \ -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \ | jq '[.data[].amount | tonumber] | add'
Servers: full walkthrough
Below is the life cycle of one VDS from the catalog to deletion. It is exactly what the cabinet does, with the same checks and prices. server_id is always the panel id of the VM (id in /servers responses).
Server statuses (status): active: running or at least not stopped by billing; stopped: powered off by the client; suspended: switched off for non-payment or by staff; pending_cancel: a cancel request at end of period is approved; cancelled: deleted by the client or by a cancel request; terminated: removed after prolonged non-payment. The flags flags.blocked (locked by staff, every mutation → 403 server_blocked), flags.expired and flags.is_test tell what can still be done.
While an order, a deletion or an IP change is running on a server, a parallel mutation of the same object answers 409 operation_in_progress: wait and retry.
1. Catalog and quote
GET /catalog/tariffs returns the VDS tariffs available for order, already with your group discount applied (price_monthly; list_price_monthly is the public price). in_stock: false means the location has no capacity and an order would answer 409 no_capacity. GET /catalog/os?tariff_id=12 excludes the images that tariff forbids (excluded_os); the image slug is the os value when ordering.
GET /catalog/quote computes the price with the exact functions the order uses: base amount, group/loyalty/volume/period discounts, promo code, and says whether the balance covers it (balance_sufficient, shortfall). A promo code is validated but not consumed. Give either months (1, 3, 6, 12) or hours (1..720, if the tariff has hourly_available).
curl -s https://vdsok.guru/api/v1/catalog/tariffs -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '.data[] | {id, name, loc: .location.code, price_monthly, in_stock}'
curl -s "https://vdsok.guru/api/v1/catalog/os?tariff_id=12" -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '.data[].slug'
curl -s "https://vdsok.guru/api/v1/catalog/quote?tariff_id=12&months=3" -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '{total, currency, balance_sufficient, shortfall, discounts}'2. Order
POST /servers is synchronous like the cabinet: it validates tariff, OS, name and capacity, computes the price, checks the balance before writing anything (402 insufficient_funds has no side effects), then charges and creates the VM. Idempotency-Key is required.
201: the server is created and running; the answer hasserver, the one-timeroot_password,invoice_id,charged,balance_after. Store the password immediately: the replay under the same idempotency key has it blank.202: the panel timed out (and only then: a panel timeout never becomes a504); the charge is kept and the order continues in the background. The answer is anOrderwithorder_url. PollGET /orders/{invoice_id}every few seconds whilestatusstaysprovisioning. Success isstatus: activewith aserver_id; any other terminal state means no server was created and the charge has already been credited back.
SSH keys: store public keys once with POST /ssh-keys and pass their ids in ssh_key_ids when ordering and reinstalling. When password is omitted it is generated and returned once. A promo code goes in promo_code; answers to order-form fields configured by VDSok go in custom_fields.
# Store an SSH key once
curl -s -X POST https://vdsok.guru/api/v1/ssh-keys -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -H "Content-Type: application/json" \
-d '{"name": "laptop", "public_key": "'"$(cat ~/.ssh/id_ed25519.pub)"'"}' | jq .id
# Order: months=1, key id 3 injected into the image
curl -s -X POST https://vdsok.guru/api/v1/servers -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{"tariff_id": 12, "os": "ubuntu-24.04", "name": "web-01", "months": 1, "ssh_key_ids": [3]}' \
| jq '{status: (.server.status // .status), id: (.server.id // .server_id), ip: .server.ip, root_password, invoice_id}'
# On 202 poll the order while it is still provisioning
curl -s https://vdsok.guru/api/v1/orders/10231 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '{status, server_id, invoice_id}'3. Status
GET /servers/{server_id} is the billing view of the server: status, tariff, IPs, resources, billing (cycle, next_due_at, recurring_amount, auto_renew) and refund_quote, what deleting it right now would refund. It is cheap and fine for frequent polling.
GET /servers/{server_id}/status (or ?include=live on the detail call) queries the panel for live data: power (running/stopped/unknown), CPU, memory, disk, uptime. It is an expensive call from the 20/min bucket. Servers imported from history with synthetic ids (>= 2000000000) have no panel record and answer 409 service_state.
curl -s https://vdsok.guru/api/v1/servers/2001 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '{status, ip, next_due: .billing.next_due_at, auto_renew: .billing.auto_renew, refund: .refund_quote.amount}'
curl -s https://vdsok.guru/api/v1/servers/2001/status -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '{power, cpu_percent, mem: .memory, uptime_seconds}'4. Power
POST /servers/{server_id}/actions/power with {"action": "start" | "stop" | "restart"}. 202 means the panel accepted the command; check the real state through the live status a few seconds later. An unknown action → 400 invalid_action; a suspended or blocked server → 409 service_state / 403 server_blocked. Scope servers:manage.
curl -s -X POST https://vdsok.guru/api/v1/servers/2001/actions/power -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -d '{"action": "restart"}'
# {"server_id":2001,"action":"restart","status":"accepted","message":null}5. Reinstall and password reset
POST /servers/{server_id}/actions/reinstall with {"os": "<slug>", "password"?: "...", "ssh_key_ids"?: [...]} destroys all data on the disk. The image must be allowed for the tariff, otherwise 400 os_not_allowed. When password is omitted a new one is generated and returned once as root_password; the answer is 202 with status: reinstalling. The server.reinstalled webhook fires when the installation finishes.
POST /servers/{server_id}/actions/reset-password generates a new root password and shows it once. Limited to 5 calls per 5 minutes per server.
curl -s -X POST https://vdsok.guru/api/v1/servers/2001/actions/reinstall -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -d '{"os": "debian-12", "ssh_key_ids": [3]}'
# {"server_id":2001,"status":"reinstalling","os":"debian-12","root_password":"..."}
curl -s -X POST https://vdsok.guru/api/v1/servers/2001/actions/reset-password -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq -r .password6. Additional IPs and PTR
GET /servers/{server_id}/ips lists every address of the server (v4 and v6) with primary, ptr, price_monthly. GET /servers/{server_id}/ips/quote says in advance what would be charged right now (prorated_now, for the rest of the current period) and how many more addresses can be added (extra_ips / max_extra_ips).
POST /servers/{server_id}/ips (with Idempotency-Key, scope servers:order) buys one IPv4: it charges prorated_now and raises billing.recurring_amount. The answer is {"success", "server_id", "charged", "currency", "days"} — the address itself is not in it, the panel assigns it asynchronously, so read it from GET /servers/{server_id}/ips. The per-server limit answers 409 ip_limit_reached. DELETE /servers/{server_id}/ips/{ip_id} releases an additional address with no refund; recurring_amount drops from the next period. The primary address cannot be released (409 conflict).
PUT /servers/{server_id}/ips/{ip_id}/ptr with {"domain": "mail.example.com"} sets the reverse record and answers {"id", "ptr"}; the name needs at least two labels of letters, digits, dots and hyphens. The field is domain (not ptr), and "" or null removes the record.
curl -s https://vdsok.guru/api/v1/servers/2001/ips/quote -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '{prorated_now, price_monthly, extra_ips, max_extra_ips}'
curl -s -X POST https://vdsok.guru/api/v1/servers/2001/ips -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -H "Idempotency-Key: $(uuidgen)" \
| jq '{success, charged, currency, days}' # the address arrives asynchronously
curl -s https://vdsok.guru/api/v1/servers/2001/ips -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '.data[] | select(.primary | not) | {id, ip}'
curl -s -X PUT https://vdsok.guru/api/v1/servers/2001/ips/501/ptr -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -d '{"domain": "mail.example.com"}' | jq '{id, ptr}'
curl -s -X DELETE https://vdsok.guru/api/v1/servers/2001/ips/501 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX"7. Renewal and auto-renew
POST /servers/{server_id}/renew charges the balance and extends billing.next_due_at; an early renewal stacks on top of the current period. Monthly servers take {"months": 1|3|6|12}, hourly ones take {"hours": 1..720}. Idempotency-Key is required; with insufficient funds the answer is 402 and nothing changes.
PATCH /servers/{server_id} changes auto_renew, name and notes. With auto-renew on, billing issues and pays the invoice from the balance before next_due_at; the amount is billing.recurring_amount. Watch GET /balance → upcoming_7d/low_balance or subscribe to the balance.low webhook.
curl -s -X POST https://vdsok.guru/api/v1/servers/2001/renew -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"months": 3}' \
| jq '{charged, balance_after, next_due_at}'
curl -s -X PATCH https://vdsok.guru/api/v1/servers/2001 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -d '{"auto_renew": true, "notes": "prod, do not stop"}' \
| jq '.billing.auto_renew'8. Delete with refund
First call GET /servers/{server_id}/refund-quote: amount is what will be credited back, refundable and excluded_reason explain a zero (promo_tariff: the tariff is flagged non-refundable; blocked: an admin blocked the server; unpaid: the service is past due; no_payment_record: no paid invoice exists, which is also how test servers come out). The refund is prorated by whole days of the unused paid period, from the invoices that were actually paid (breakdown), not from the tariff price; referral bonuses paid out for those invoices are deducted (referral_adjustment).
DELETE /servers/{server_id} (scope servers:delete, Idempotency-Key) removes the VM from the panel and credits the refund; the answer is a DeleteResult with refund, balance_after and cancel_request_withdrawn when a pending cancel request was withdrawn. A server that is already gone (cancelled/terminated) answers 409 service_state, a blocked one 403 server_blocked. If the panel fails with anything other than "already gone" the answer is 502 and nothing changes. The server.terminated webhook fires.
refund_quote.amount and ask for confirmation before DELETE.curl -s https://vdsok.guru/api/v1/servers/2001/refund-quote -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '{amount, currency, refundable, excluded_reason, days_left: .breakdown[0].days_left}'
curl -s -X DELETE https://vdsok.guru/api/v1/servers/2001 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -H "Idempotency-Key: $(uuidgen)" \
| jq '{status, refunded: .refund.amount, balance_after, cancel_request_withdrawn}'Domains: full walkthrough
Domains are registered with the contact data of the account profile, the same as the cabinet uses; it cannot be changed through the API. Statuses (status): pending: the registrar accepted the request and the domain becomes active on the next sync; active; expired; transfer_pending; cancelled. blocked: true means staff locked the domain and mutations answer 403 domain_blocked. locked is the transfer lock at the registrar.
Every money operation on domains (POST /domains, /domains/{id}/renew, /domains/transfers) requires Idempotency-Key and the domains:order scope; reads need domains:read; nameservers, privacy and auto-renew need domains:manage.
1. Zones and availability
GET /catalog/zones lists the TLDs with registration, renewal and transfer prices, allowed terms (min_years/max_years) and the privacy_supported, transfer_supported flags.
GET /domains/availability?name=example.com asks the registrar (an expensive call): available, reason (taken, premium, reserved, invalid, unsupported_tld), price_register, price_renew. IDNs can be sent as Unicode or punycode.
curl -s https://vdsok.guru/api/v1/catalog/zones -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '.data[] | select(.tld == ".com")'
curl -s "https://vdsok.guru/api/v1/domains/availability?name=example.com" -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '{available, reason, price_register, currency}'2. Registration
POST /domains with {"name", "years"?, "nameservers"?, "privacy"?, "auto_renew"?, "promo_code"?} charges the balance and registers the domain. Defaults: years: 1, privacy: true, auto_renew: false, VDSok nameservers.
201withstatus: registered: the registrar confirmed at once.202withstatus: pending: the request was accepted and the domain becomesactiveon the next sync; thedomain.registeredwebhook fires.409 domain_taken: the name is taken;409 domain_exists: already on the account;402: insufficient funds, nothing charged.
curl -s -X POST https://vdsok.guru/api/v1/domains -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{"name": "example.com", "years": 1, "privacy": true, "auto_renew": true}' \
| jq '{status, id: .domain.id, expires_at: .domain.expires_at, charged, balance_after}'3. Nameservers, privacy, auto-renew
PUT /domains/{domain_id}/nameservers with {"nameservers": ["ns1.example.net", "ns2.example.net"]} replaces the whole set (2..4 unique names). PATCH /domains/{domain_id} toggles auto_renew and privacy; privacy is applied at the registrar synchronously, and a refusal there answers 400 upstream_rejected with nothing saved.
GET /domains is a paginated list with a status filter; GET /domains/{domain_id} is the detail with expires_at, price_renew, nameservers, locked. The domain.expiring webhook fires 30, 7 and 1 day before expiry.
curl -s -X PUT https://vdsok.guru/api/v1/domains/77/nameservers -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{"nameservers": ["ns1.example.net", "ns2.example.net"]}' | jq .nameservers
curl -s -X PATCH https://vdsok.guru/api/v1/domains/77 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -d '{"privacy": false, "auto_renew": true}' | jq '{privacy, auto_renew}'4. Renewal and transfer
POST /domains/{domain_id}/renew with {"years": 1..10} charges price_renew × years and extends expires_at. The answer is status: renewed, or pending_sync when the registrar accepted the renewal but the new date is not visible yet; the daily sync picks it up and the domain.renewed webhook fires.
POST /domains/transfers with {"name", "auth_code", "privacy"?, "auto_renew"?} charges the transfer price (usually including a one-year renewal) and starts the transfer from another registrar; the domain appears with status transfer_pending. auth_code is the EPP code from the current registrar; unlock the domain there first.
curl -s -X POST https://vdsok.guru/api/v1/domains/77/renew -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{"years": 1}' \
| jq '{status, charged, expires_at: .domain.expires_at}'
curl -s -X POST https://vdsok.guru/api/v1/domains/transfers -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{"name": "example.org", "auth_code": "AbC-123-xyz"}' | jq '{status, charged}'Billing: balance, top-up, invoices
Every service is paid from the account balance in its currency (currency). Top-ups go through a payment gateway by link; the API takes no cards and stores no payment details. Ordering, renewing and buying IPs charge the balance synchronously, so check GET /balance or GET /catalog/quote → balance_sufficient first.
Balance and transactions
GET /balance (scope balance:read): balance, upcoming_7d and upcoming_30d (renewals due in the next 7 and 30 days), low_balance (the balance does not cover the next 7 days), auto_renew_total_monthly. The same object arrives in the balance.low webhook.
GET /transactions is the money history, newest first, with direction (credit/debit), since, until filters and cursor pagination. type is one of topup, payment, refund, bonus, referral, adjustment, other; invoice_id links a transaction to its invoice.
GET /account (scope account:read) is the profile: login, e-mail, currency, client group with its discount, loyalty discount, the email_verified and two_factor_enabled flags.
curl -s https://vdsok.guru/api/v1/balance -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '{balance, currency, upcoming_7d, low_balance}'
curl -s "https://vdsok.guru/api/v1/transactions?direction=credit&limit=5" -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '.data[] | {created_at, type, amount, gateway, invoice_id}'Top-up: payment link
GET /balance/topup-info returns the currency, the min/max amount, the first top-up bonus (first_topup_bonus_percent, first_topup_eligible) and gateways — a flat list of enabled gateway codes (["cryptobot", …], not objects).
POST /balance/topup (scope balance:topup, Idempotency-Key, expensive) with {"amount": "25.00", "gateway": "<code from gateways>"} creates an unpaid topup invoice and returns the gateway payment_url. Exactly two fields: anything else (return_url included) is 400 validation_error. Send the user there. The balance changes only after the gateway confirms the payment: the invoice.paid event, or GET /invoices/{invoice_id} → status: paid. The sandbox returns a fake payment_url.
curl -s https://vdsok.guru/api/v1/balance/topup-info -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '{min, max, currency, gateways}'
curl -s -X POST https://vdsok.guru/api/v1/balance/topup -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{"amount": "25.00", "gateway": "cryptobot"}' | jq '{invoice_id, payment_url, gateway}'Invoices: list, PDF, payment
GET /invoices lists invoices, newest first, with status (not_paid, paid, cancelled, refunded) and type (topup, vds_purchase, vds_renewal, ip_purchase, domain_registration, domain_renewal, domain_transfer, other) filters. GET /invoices/{invoice_id} is the detail with items (each carrying server_id or domain_id) and pdf_url. GET /invoices/{invoice_id}/pdf returns application/pdf (expensive).
POST /invoices/{invoice_id}/pay (scope invoices:pay, Idempotency-Key) pays an unpaid invoice from the balance synchronously and answers 200 with {"status": "paid", "amount", "balance_after"}. If the invoice ordered a server, provisioning runs in the background after the answer — watch it through GET /orders or the server.created webhook, as in the servers walkthrough. Insufficient funds → 402 and nothing changes. POST /invoices/{invoice_id}/payment-link returns a gateway link for the invoice (optional body {"gateway"}; no other field is accepted). Overdue unpaid invoices raise the invoice.overdue webhook.
curl -s "https://vdsok.guru/api/v1/invoices?status=not_paid" -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '.data[] | {id, type, amount, due_at}'
curl -s https://vdsok.guru/api/v1/invoices/10231/pdf -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -o invoice-10231.pdf
curl -s -X POST https://vdsok.guru/api/v1/invoices/10231/pay -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -H "Idempotency-Key: $(uuidgen)" \
| jq '{status, amount, balance_after}'
curl -s -X POST https://vdsok.guru/api/v1/invoices/10231/payment-link -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq .payment_urlWebhooks
A webhook is a POST from VDSok to your https:// URL when something happens in the account. Subscriptions are created only with a live key that has the webhooks:manage scope; a test key gets 403 sandbox_not_supported on every /webhooks* endpoint. The URL must be on a public address; redirects are not followed.
Event catalogue
| Event | When | data.object |
|---|---|---|
server.created | a server was created (order or paid invoice provisioned) | Server |
server.suspended | switched off for non-payment or by staff; data.previous.status holds the prior state | Server |
server.unsuspended | resumed after payment or by staff | Server |
server.terminated | deleted (by the client with refund, by cancel request, or after long non-payment) | Server |
server.reinstalled | OS reinstall finished | Server |
invoice.created | new invoice (renewal, order, top-up) | Invoice |
invoice.paid | invoice paid (gateway or balance) | Invoice |
invoice.overdue | invoice past due and still unpaid | Invoice |
balance.low | balance dropped below the upcoming-charges threshold | Balance |
domain.registered | domain became active at the registrar | Domain |
domain.expiring | domain expires soon (30, 7 and 1 day before) | Domain |
domain.renewed | domain renewed (manually or by auto-renew) | Domain |
key.created | a new API key was created in the cabinet | ApiKey |
key.revoked | an API key was revoked (client, self-revoke, staff or ban) | ApiKey |
ping | test event sent by POST /webhooks/{id}/test | {subscription_id, message: "pong"} |
The current list with descriptions comes from GET /webhooks/events.
Envelope
{
"id": "evt_01J7ZK3Q9X4R",
"type": "server.suspended",
"created_at": "2026-09-15T10:00:00Z",
"livemode": true,
"account_id": 57,
"api_version": "1",
"data": {
"object": {"id": 2001, "name": "web-01", "status": "suspended", "...": "full Server object"},
"previous": {"status": "active"}
},
"resource": "/api/v1/servers/2001"
}data.object is the full snapshot in the same shape the REST API returns; data.previous holds the fields that changed or null; resource is the API path of the object. The envelope is serialized once when the event is queued: retries and redeliveries send the identical bytes, so id and created_at never change. X-Webhook-Timestamp is the time of that particular attempt, so the signature is recomputed for every attempt: deduplicate on X-Webhook-Id, never on the signature.
Delivery headers
| Header | Value |
|---|---|
X-Webhook-Signature | v1=<hex HMAC-SHA256(secret, "{timestamp}.{body}")> |
X-Webhook-Timestamp | unix seconds when this attempt was sent |
X-Webhook-Id | evt_…, the event id, stable across retries; deduplicate by it |
X-Webhook-Event | the event type, e.g. server.created |
User-Agent | VDSok-Webhooks/1.0 |
Delivery and retries
Answer any 2xx within 10 seconds; the response body is ignored. Process events asynchronously: accept, enqueue on your side, answer 200. Otherwise the delivery is retried after 60 s, 5 min, 15 min, 1 h and 6 h and marked dead after the 6th failure. 20 failures in a row auto-disable the subscription (the reason is written to disabled_reason as text) and send an e-mail; re-enable it with PATCH /webhooks/{id} and {"active": true}. The log keeps delivered rows for 14 days and dead ones for 30.
X-Webhook-Id is mandatory.Subscribing
POST /webhooks with {"url", "events": [...], "description"?} creates the subscription and returns the secret (whsec_…) once; store it in your application secrets. A lost secret cannot be recovered, only replaced: POST /webhooks/{id}/rotate-secret issues a new one, the old one stops working immediately, and deliveries already queued are signed with the new one at send time.
curl -s https://vdsok.guru/api/v1/webhooks/events -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '.data[] | .type'
curl -s -X POST https://vdsok.guru/api/v1/webhooks -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/vdsok",
"events": ["server.created", "server.suspended", "server.terminated", "invoice.paid", "balance.low"],
"description": "billing sync"}' \
| jq '{id, secret, events}' # secret is shown onceVerifying the signature
The algorithm: take the raw request body bytes (not re-serialized JSON, any whitespace change breaks the signature), build the string "{X-Webhook-Timestamp}.{body}", compute HMAC-SHA256 with the subscription secret, compare the hex result with the value after v1= using a constant-time comparison, and reject the delivery when |now − timestamp| > 300 seconds (replay protection). Only then parse the JSON. The SDKs do exactly this in Webhooks.verify() / construct_event().
Answer 2xx quickly and process the event in the background; log a signature failure and answer 400: such a delivery is retried and the log will show you the problem.
import hmac, hashlib, json, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"whsec_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # from POST /webhooks, keep it secret
seen = set() # use a persistent store (Redis, DB) in production
def verify(secret: bytes, timestamp: str, body: bytes, signature: str, tolerance: int = 300) -> bool:
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - ts) > tolerance: # replay protection
return False
expected = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
given = signature.removeprefix("v1=")
return hmac.compare_digest(expected, given) # constant time
@app.post("/vdsok")
def vdsok_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(SECRET, request.headers.get("X-Webhook-Timestamp", ""), raw,
request.headers.get("X-Webhook-Signature", "")):
abort(400)
event_id = request.headers["X-Webhook-Id"]
if event_id in seen: # duplicate retry/redelivery
return "", 200
seen.add(event_id)
event = json.loads(raw)
if event["type"] == "server.suspended":
print("server", event["data"]["object"]["id"], "was", event["data"]["previous"]["status"])
# enqueue heavy work here; answer within 10 s
return "", 200Test, log, redeliver
POST /webhooks/{id}/test sends a ping event synchronously, bypassing the queue, and reports your receiver's status and latency (ok, status, latency_ms, detail), handy while setting things up. GET /webhooks/{id}/deliveries is the log with status (pending, delivered, dead) and event_type filters; each row carries the payload (the very envelope), attempts, last_status, last_error and the first 1000 bytes of your response. POST /webhooks/deliveries/{delivery_id}/redeliver re-queues a delivery with the same bytes. Test and redeliver are expensive calls.
curl -s -X POST https://vdsok.guru/api/v1/webhooks/5/test -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq '{ok, status, latency_ms, detail}'
curl -s "https://vdsok.guru/api/v1/webhooks/5/deliveries?status=dead" -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" \
| jq '.data[] | {id, event_type, attempts, last_status, last_error}'
curl -s -X POST https://vdsok.guru/api/v1/webhooks/deliveries/9012/redeliver -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" | jq .status
curl -s -X PATCH https://vdsok.guru/api/v1/webhooks/5 -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX" -H "Content-Type: application/json" -d '{"active": true}'SDKs
The official SDKs cover all of v1 and follow the same specification as this site (the Node package generates its types straight from it). Common to all three:
- constructor
(apiKey, {baseUrl, timeout = 30 s, maxRetries = 2}); headersAuthorization,Accept,User-Agent: vdsok-sdk-<lang>/<ver>; - automatic retries on
429/502/503/504honouringRetry-After, only forGETand for mutations sent with anIdempotency-Key; on money endpoints the SDK generates the idempotency key itself and hands it back to the caller; - a single
ApiError {status, code, message, requestId, details}andRateLimitInfoparsed from the headers. In PHP the machine-readable code iserrorCode, notcode:\Exceptionalready owns an inherited$code(the HTTP status) that cannot be redeclared; - pagination iterators (
for server in client.servers.list()walks every page); Webhooks.verify(secret, headers, rawBody, tolerance = 300)andconstruct_event(), the signature check from the webhooks section;- groups
account,balance,invoices,catalog,servers,domains,ssh_keys,keys,webhooks; money as strings/Decimal, dates as native types.
| Language | Package | Install | Requirements |
|---|---|---|---|
| Node / TypeScript | @vdsok/sdk | npm install @vdsok/sdk | Node 18+, no runtime dependencies (global fetch), ESM and CJS |
| Python | vdsok | pip install vdsok | Python 3.9+, httpx; Vdsok and AsyncVdsok classes |
| PHP | vdsok/sdk | composer require vdsok/sdk guzzlehttp/guzzle | PHP 8.1+, PSR-18/PSR-17 (Guzzle is the recommended provider) |
SDK 1.x versions track API v1; breaking API changes ship only together with /v2 and a new SDK major. Sources and issue trackers live on GitHub under the vdsok organization (sdk-node, sdk-python, sdk-php).
# pip install vdsok
from vdsok import Vdsok, ApiError
client = Vdsok("vk_live_XXXXXXXXXXXXXXXXXXXXXXXX") # or Vdsok(api_key, base_url=..., timeout=30, max_retries=2)
for server in client.servers.list_all(status="active"): # walks every page
print(server.id, server.name, server.billing.next_due_at)
try:
result = client.servers.renew(2001, months=3) # Idempotency-Key generated by the SDK
print("charged", result.charged, "next due", result.next_due_at)
except ApiError as e:
if e.code == "insufficient_funds":
print("short by", e.details["shortfall"])
else:
raiseChangelog
API changes are documented here and in the info.version field of the specification. Within v1 everything is added compatibly: new fields, new endpoints, new error codes and webhook events. A client must ignore unknown fields and handle unknown codes by HTTP status.
1.0.0 — 2026-09-15
- First public release of Client API v1:
/me, account, balance and transactions, top-up, invoices (list, PDF, pay from balance, payment link), catalog (tariffs, OS images, locations, zones, quote), servers (order, status, power, reinstall, password reset, renew, delete with refund, notes and auto-renew), orders, additional IPs and PTR, SSH keys, domains (availability, registration, renewal, nameservers, privacy, transfer), API keys (view and kill switch), webhooks (subscriptions, log, redeliver, test). vk_live_/vk_test_keys, scopes and presets, IP allow-lists, validity windows.- Idempotency on money operations, cursor pagination, 120/20 per-minute limits.
- Sandbox: test keys read real data and simulate writes.
- 14 webhook event types with HMAC-SHA256 signatures and retries.
- SDKs:
@vdsok/sdk(Node),vdsok(Python),vdsok/sdk(PHP).
OpenAPI specification
The machine-readable description of the API is an OpenAPI 3.1 document, the same one this site and the SDKs are built from. It is available without a key:
- Download openapi.json: a cacheable copy; the live document is served by
GET /api/v1/openapi.json. - Interactive reference: every operation, schema, request and response example, with the option to run a request with your own key right from the browser.
The document uses these extensions: x-scopes (required scopes), x-idempotent (Idempotency-Key needed), x-expensive (counted in the 20/min bucket), x-sandbox (real | fake | forbidden: behaviour under a test key). You can generate a client for any language from it (openapi-typescript, openapi-generator, oapi-codegen) or import it into Postman/Insomnia.
curl -s https://vdsok.guru/api/v1/openapi.json -o vdsok-openapi.json jq '.info.version, (.paths | keys | length)' vdsok-openapi.json