logo
logo

High-performance virtual servers in Russia and Europe.

    SERVICES
  • Virtual Servers
  • Dedicated Servers
  • Domains
    SUPPORT
  • Contact via Telegram
  • support@vdsok.guru
  • abuse@as202831.network
  • Contacts
    TOOLS
  • Developer API
  • Looking Glass
    LEGAL INFORMATION
  • User Agreement
  • Privacy Policy
  • Public Offer
  • TrustPilot
© 2026 VDSok.guru. All rights reserved.
CryptoBot Lolz.Guru Heleket FreeKassa Lava.ru 2328.io NOWPayments
DMCA.com Protection Status
Client API v1

Developer API

Everything you do in the cabinet, from code: servers, domains, balance, invoices and webhooks. JSON over HTTPS, scoped keys, a sandbox and SDKs for Node, Python and PHP.

Interactive referenceDownload openapi.json
Contents
Getting started
Overview and base URL
Authentication, scopes and presets
Sandbox: test keys
Conventions
Errors: envelope and codes
Rate limits and headers
Pagination
Idempotency
Money and dates
Walkthroughs
Servers: full walkthrough1. Catalog and quote2. Order3. Status4. Power5. Reinstall and password reset6. Additional IPs and PTR7. Renewal and auto-renew8. Delete with refund
Domains: full walkthrough1. Zones and availability2. Registration3. Nameservers, privacy, auto-renew4. Renewal and transfer
Billing: balance, top-up, invoicesBalance and transactionsTop-up: payment linkInvoices: list, PDF, payment
WebhooksSubscribingVerifying the signatureTest, log, redeliver
Tooling
SDKs
Changelog
OpenAPI specification

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/PATCH body that is not application/json is rejected with 415; a body over 64 KB with 413.
  • 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 currency field (ISO 4217).
  • server_id is 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.

ScopeWhat it unlocks
account:readGET /account: profile, client group, discount
balance:readbalance, transactions, top-up limits
balance:topupcreating a top-up invoice and payment link
invoices:readinvoice list, invoice, PDF
invoices:paypaying an invoice from balance, payment link
servers:readservers, live status, IPs, SSH keys, orders, refund quote
servers:managepower, reinstall, password reset, PTR, PATCH, adding and removing SSH keys
servers:orderordering, renewal, buying and releasing IPs
servers:deletedeleting a server with refund
domains:readdomains, availability check
domains:manageauto-renew, WHOIS privacy, nameservers
domains:orderregistration, renewal, transfer
keys:readlist of the account's keys
webhooks:managesubscriptions, deliveries, test, redeliver

Presets

Presets in the cabinet are just bundles of scopes; you can adjust the checkboxes after picking one.

PresetContents
read_onlyevery *:read scope
operateread_only + servers:manage, servers:order, domains:manage, invoices:pay
fullall 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.

Keep keys in CI secrets or a secret manager, never in the repository. Give each integration its own key with minimal scopes and an IP allow-list: a leak then shows up in 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.

Test keys read the real data of your account. 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 in GET /servers for 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: true header. Assert on it in your tests so the modes never get mixed up.
  • Idempotency, rate limits and validation errors behave as in live: 402, 409 and 429 can happen in the sandbox as well.

The behaviour of each operation under a test key is described by x-sandbox in the specification:

x-sandboxMeaning
realsame as live: every read, DELETE /keys/{key_id}
fakesimulated write: ordering, renewal, power, domains, top-up
forbidden403 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 on message.
  • message: human-readable English, not for parsing; wording may change.
  • request_id: the same value as the X-Request-ID header; quote it to support.
  • details: code-specific extras: required (scopes), fields (per-field validation errors), money figures for insufficient_funds, status for service_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.

StatusCodes
400invalid_request, validation_error, invalid_cursor, idempotency_key_required, os_not_allowed, invalid_period, invalid_action, upstream_rejected
401invalid_token, key_expired, key_not_yet_valid
402insufficient_funds: nothing was charged or created
403insufficient_scope, ip_not_allowed, account_suspended, api_disabled_for_account, sandbox_not_supported, sandbox_disabled, server_blocked, domain_blocked
404not_found: no such object on this account (someone else's objects are 404 too, never 403)
409conflict, idempotency_conflict, idempotency_in_progress, operation_in_progress, service_state, no_capacity, tariff_unavailable, ip_limit_reached, domain_taken, domain_exists, cancel_pending
413 / 415payload_too_large, unsupported_media_type
429rate_limited
500server_error: our fault, the request id is already logged
502upstream_error: the panel, registrar or gateway answered with an error; for money operations nothing was charged unless the response says otherwise
503api_disabled, upstream_unavailable, temporarily_unavailable: retry after Retry-After
504upstream_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}/status and GET /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

HeaderMeaning
X-RateLimit-Limitrequests per minute allowed in the bucket the call hit (normal or expensive)
X-RateLimit-Remainingrequests left in the current 60-second window
X-RateLimit-Resetunix time (seconds) when the window resets
Retry-Afterseconds 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.

Do not poll 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; null means 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: change status and 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:

SituationAnswer
same key, same requestthe stored response is replayed; secrets such as root_password are blanked in the replay
same key, different body or endpoint409 idempotency_conflict
same key while the first request is still running409 idempotency_in_progress with Retry-After
no key400 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.

The password of a new server comes only in the first 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: Decimal in Python, BigInt in minor units or decimal.js in Node, bcmath/BigDecimal in PHP.
  • In request bodies (amount on top-up) send a string with two fraction digits: "25.00". The number 25 is accepted as well, 25.005 is not.
  • Hourly prices (price_hourly) have up to 4 fraction digits: "0.0083".
  • Transactions carry an absolute amount and a separate direction (credit/debit); a signed Money appears 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/until on /transactions) send RFC 3339 with Z or an offset as well.
  • X-RateLimit-Reset and X-Webhook-Timestamp are unix seconds, not milliseconds.
  • Refunds on deletion are computed by whole days of the unused period (days_left in RefundQuote); 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 has server, the one-time root_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 a 504); the charge is kept and the order continues in the background. The answer is an Order with order_url. Poll GET /orders/{invoice_id} every few seconds while status stays provisioning. Success is status: active with a server_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 .password

6. 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.

Deletion is irreversible and the quote changes every day. Show the user 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.

  • 201 with status: registered: the registrar confirmed at once.
  • 202 with status: pending: the request was accepted and the domain becomes active on the next sync; the domain.registered webhook 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_url

Webhooks

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

EventWhendata.object
server.createda server was created (order or paid invoice provisioned)Server
server.suspendedswitched off for non-payment or by staff; data.previous.status holds the prior stateServer
server.unsuspendedresumed after payment or by staffServer
server.terminateddeleted (by the client with refund, by cancel request, or after long non-payment)Server
server.reinstalledOS reinstall finishedServer
invoice.creatednew invoice (renewal, order, top-up)Invoice
invoice.paidinvoice paid (gateway or balance)Invoice
invoice.overdueinvoice past due and still unpaidInvoice
balance.lowbalance dropped below the upcoming-charges thresholdBalance
domain.registereddomain became active at the registrarDomain
domain.expiringdomain expires soon (30, 7 and 1 day before)Domain
domain.reneweddomain renewed (manually or by auto-renew)Domain
key.createda new API key was created in the cabinetApiKey
key.revokedan API key was revoked (client, self-revoke, staff or ban)ApiKey
pingtest 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

HeaderValue
X-Webhook-Signaturev1=<hex HMAC-SHA256(secret, "{timestamp}.{body}")>
X-Webhook-Timestampunix seconds when this attempt was sent
X-Webhook-Idevt_…, the event id, stable across retries; deduplicate by it
X-Webhook-Eventthe event type, e.g. server.created
User-AgentVDSok-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.

Ordering is not guaranteed and one event may arrive twice (a retry after a timeout, a redelivery). An idempotent handler that deduplicates by 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 once

Verifying 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 "", 200

Test, 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}); headers Authorization, Accept, User-Agent: vdsok-sdk-<lang>/<ver>;
  • automatic retries on 429/502/503/504 honouring Retry-After, only for GET and for mutations sent with an Idempotency-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} and RateLimitInfo parsed from the headers. In PHP the machine-readable code is errorCode, not code: \Exception already 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) and construct_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.
LanguagePackageInstallRequirements
Node / TypeScript@vdsok/sdknpm install @vdsok/sdkNode 18+, no runtime dependencies (global fetch), ESM and CJS
Pythonvdsokpip install vdsokPython 3.9+, httpx; Vdsok and AsyncVdsok classes
PHPvdsok/sdkcomposer require vdsok/sdk guzzlehttp/guzzlePHP 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:
        raise

Changelog

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