Quick start
Five minutes from nothing to your first API response. All you need is a VDSok account and a terminal.
1. Get a key
Keys are issued by the account owner in the cabinet: Settings → API (/my/api). You pick the mode (live or test), the set of scopes and, if you want, an IP allow-list. The secret is shown once — nobody can show it again, you would have to issue a new key.
Start with a test key (vk_test_…): it talks to the same base URL and reads your real data, but performs no writes for real — see Sandbox.
2. First request
GET /me is the cheapest way to prove the key works. Samples in four languages are right below this section.
Look at three things in the answer: key.mode (live or test), scopes — what this key is allowed to do, and the X-Request-ID header, worth quoting in any support request. What is left of your rate limit arrives in X-RateLimit-Remaining.
3. First write, for free
A test key can walk the whole ordering path without spending anything: the balance does not move, no VM is created, no payment gateway is called. Every such answer carries X-Sandbox: true, and the server you get has a fake id from 9000000000 that lives for 24 hours.
BASE=https://vdsok.guru/api/v1
KEY=vk_test_XXXXXXXXXXXXXXXXXXXXXXXX
# order: returns the order invoice_id and the server_id to come
curl -sS -X POST $BASE/servers \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"tariff_id": 12, "os": "ubuntu-22.04", "months": 1}'
# how the order is going — by the invoice_id above, not by server_id
curl -sS $BASE/orders/4242 -H "Authorization: Bearer $KEY"
# clean up: deletion is simulated too
curl -sS -X DELETE $BASE/servers/9000000001 \
-H "Authorization: Bearer $KEY" -H "Idempotency-Key: $(uuidgen)"Once the same thing works with a live key, the only difference is the key prefix and the missing X-Sandbox header.
Where to go next
- Authentication, scopes and presets — what to actually allow a key.
- Errors: envelope and codes — every working client starts by handling these.
- Servers: full walkthrough — order, status, power, renewal, delete with refund.
- SDKs — if you would rather not write HTTP by hand.
- Glossary — if "quote" is unfamiliar, or the difference between an order and an invoice is unclear.
curl -sS https://vdsok.guru/api/v1/me -H "Authorization: Bearer vk_live_XXXXXXXXXXXXXXXXXXXXXXXX"
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.
If this is your first visit, start with the quick start: a key, a first request and a free ordering loop in the sandbox.
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). The secret is shown once. A key is independent of cabinet sessions and stays valid until it expires or is revoked. 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
A preset is a ready-made bundle of scopes; you can adjust it after picking one.
| Preset | Contents |
|---|---|
read_only | every *:read scope |
operate | read_only + servers:manage, domains:manage, invoices:pay |
full | all scopes, including balance:topup, servers:order, 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. 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 |
# 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",...}Glossary
The words you will meet in API answers and further down this guide.
| Term | What it means |
|---|---|
tariff | A priced configuration: cores, memory, disk, traffic, location. Lives in the catalog, addressed by tariff_id. You can only buy what GET /catalog/tariffs returns. |
server | A paid virtual machine — an instance of a tariff. Addressed by server_id, the same number the cabinet shows in the page URL. |
order | The buying process: invoice issued, paid, machine being created. It is addressed by invoice_id, not by server_id — GET /orders/{invoice_id}. This is the first thing people trip over. |
invoice | A bill: for an order, for a renewal or for a balance top-up. It has a status, a PDF and can be paid from the balance. |
quote | A price computed without charging: GET /catalog/quote for what an order would cost, GET /servers/{server_id}/refund-quote for what deletion would return. |
panel | The virtualisation panel where the machine actually lives. Live status comes from there; power and reinstall go there. |
scope | A permission granted to a key: servers:read, balance:topup and so on. Missing one gives 403 insufficient_scope. |
preset | A ready-made bundle of scopes in the cabinet: read_only, operate, full. A label, not a separate entity. |
live key / test key | A vk_live_ key acts for real; a vk_test_ one reads real data but simulates every write. |
sandbox | The mode a test key runs in. Answers are marked with the X-Sandbox: true header. |
cursor | An opaque bookmark string for the next page of a list. Do not parse it — pass it back as is. |
Idempotency-Key | Your identifier for an attempt at a money operation. A repeat with the same key returns the original result instead of charging twice. |
delivery | One attempt to deliver a webhook to your endpoint. Sending the same attempt again is a redeliver. |
PTR | The reverse DNS record for an IP: the address resolves back to a name. Mail servers want it. |
punycode | The ASCII spelling of a domain with non-Latin letters (пример.рф → xn--e1afmkfd.xn--p1ai). The API accepts both forms and returns punycode. |
expensive | A marker on an operation that reaches an external system. Those draw from a separate, narrower rate-limit bucket. |
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 |
405 / 413 / 415 | method_not_allowed, payload_too_large, unsupported_media_type |
429 | rate_limited |
500 | server_error: a failure on our side; quote the request_id when you report it |
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. The ceiling and what is left of it come back in the X-RateLimit-Limit and X-RateLimit-Remaining headers of every response; the limits of a specific key are in GET /keys/{key_id}. If the standard ceiling is not enough, ask support.
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;GET /servers/{server_id}/ips,GET /servers/{server_id}/ips/quote;POST /servers,POST /servers/{server_id}/renew,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, which 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.
- 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: the service is active; stopped: powered off by the client; suspended: the service is suspended, usually for non-payment; 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 (the server is locked, 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..2160, if the tariff has hourly_available). A single tariff comes from GET /catalog/tariffs/{tariff_id}, and the list of locations with their codes and countries is GET /catalog/locations.
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. The account's keys are listed by GET /ssh-keys and removed with DELETE /ssh-keys/{key_id}, which does not touch servers where the key is already installed.
# 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 server card: 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. Some servers have no live data 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..2160}. 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, the invoice is issued and paid from the balance automatically 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: the server is locked; 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, currency, balance_after and refund_quote — the same breakdown the quote returns. A pending cancel request is withdrawn silently and the answer does not report it. 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, balance_after}'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 the domain is locked 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 — exactly two 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 | the service was suspended; data.previous.status holds the prior state | Server |
server.unsuspended | the service was resumed | 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 | 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 lands in disabled_reason), and the account owner gets 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. A single delivery is returned by GET /webhooks/deliveries/{delivery_id}, and DELETE /webhooks/{webhook_id} removes the subscription itself. Your subscriptions are listed by GET /webhooks, a single one by GET /webhooks/{webhook_id}.
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. 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: every page is walked by
client.servers.list_all()in Python,for await (… of await client.servers.list())in Node and$client->servers->iterate()in PHP;list()returns a single 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 config repositories.vdsok vcs https://github.com/VDSok/sdk-php → composer require vdsok/sdk:^1.0 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).
All endpoints
The full list of v1 operations — the same as in the interactive reference, but as one table and without JavaScript.
Columns: Scope — what the key must carry (a dash means any valid key will do); Idem — whether Idempotency-Key is required; Expensive — whether the call draws from the separate rate-limit bucket; Sandbox — behaviour under a test key (real — as in live, fake — simulated without writing, forbidden — 403).
Meta
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /health | — | — | — | real |
GET /openapi.json | — | — | — | real |
GET /me | — | — | — | real |
Account
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /account | account:read | — | — | real |
GET /balance | balance:read | — | — | real |
GET /transactions | balance:read | — | — | real |
GET /balance/topup-info | balance:read | — | — | real |
POST /balance/topup | balance:topup | yes | yes | fake |
Invoices
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /invoices | invoices:read | — | — | real |
GET /invoices/{invoice_id} | invoices:read | — | — | real |
GET /invoices/{invoice_id}/pdf | invoices:read | — | yes | real |
POST /invoices/{invoice_id}/pay | invoices:pay | yes | — | fake |
POST /invoices/{invoice_id}/payment-link | invoices:pay | — | — | fake |
Catalog
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /catalog/tariffs | — | — | — | real |
GET /catalog/tariffs/{tariff_id} | — | — | — | real |
GET /catalog/os | — | — | — | real |
GET /catalog/locations | — | — | — | real |
GET /catalog/zones | — | — | — | real |
GET /catalog/quote | — | — | yes | real |
Servers
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /servers | servers:read | — | — | real |
POST /servers | servers:order | yes | yes | fake |
GET /servers/{server_id} | servers:read | — | — | real |
DELETE /servers/{server_id} | servers:delete | yes | — | fake |
PATCH /servers/{server_id} | servers:manage | — | — | fake |
GET /servers/{server_id}/status | servers:read | — | yes | real |
GET /servers/{server_id}/refund-quote | servers:read | — | — | real |
POST /servers/{server_id}/renew | servers:order | yes | yes | fake |
Server actions
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
POST /servers/{server_id}/actions/power | servers:manage | — | — | fake |
POST /servers/{server_id}/actions/reinstall | servers:manage | — | — | fake |
POST /servers/{server_id}/actions/reset-password | servers:manage | — | — | fake |
Orders
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /orders | servers:read | — | — | real |
GET /orders/{invoice_id} | servers:read | — | — | real |
IPs
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /servers/{server_id}/ips | servers:read | — | yes | real |
POST /servers/{server_id}/ips | servers:order | yes | yes | fake |
GET /servers/{server_id}/ips/quote | servers:read | — | yes | real |
DELETE /servers/{server_id}/ips/{ip_id} | servers:order | — | — | fake |
PUT /servers/{server_id}/ips/{ip_id}/ptr | servers:manage | — | — | fake |
SSH keys
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /ssh-keys | servers:read | — | — | real |
POST /ssh-keys | servers:manage | — | — | fake |
DELETE /ssh-keys/{key_id} | servers:manage | — | — | fake |
Domains
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /domains/availability | domains:read | — | yes | real |
GET /domains | domains:read | — | — | real |
POST /domains | domains:order | yes | — | fake |
GET /domains/{domain_id} | domains:read | — | — | real |
PATCH /domains/{domain_id} | domains:manage | — | — | fake |
POST /domains/{domain_id}/renew | domains:order | yes | — | fake |
PUT /domains/{domain_id}/nameservers | domains:manage | — | — | fake |
POST /domains/transfers | domains:order | yes | — | fake |
Keys
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /keys | keys:read | — | — | real |
GET /keys/{key_id} | keys:read | — | — | real |
DELETE /keys/{key_id} | — | — | — | real |
Webhooks
| Operation | Scope | Idem | Expensive | Sandbox |
|---|---|---|---|---|
GET /webhooks | webhooks:manage | — | — | forbidden |
POST /webhooks | webhooks:manage | — | — | forbidden |
GET /webhooks/events | webhooks:manage | — | — | forbidden |
GET /webhooks/{webhook_id} | webhooks:manage | — | — | forbidden |
DELETE /webhooks/{webhook_id} | webhooks:manage | — | — | forbidden |
PATCH /webhooks/{webhook_id} | webhooks:manage | — | — | forbidden |
POST /webhooks/{webhook_id}/rotate-secret | webhooks:manage | — | — | forbidden |
POST /webhooks/{webhook_id}/test | webhooks:manage | — | yes | forbidden |
GET /webhooks/{webhook_id}/deliveries | webhooks:manage | — | — | forbidden |
GET /webhooks/deliveries/{delivery_id} | webhooks:manage | — | — | forbidden |
POST /webhooks/deliveries/{delivery_id}/redeliver | webhooks:manage | — | yes | forbidden |
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
Support and feedback
What to include in a report
Every API answer carries an X-Request-ID header. With it we find your request in the logs in seconds. So please send:
- the
request_idfromX-Request-ID(it is in the error body too); - the method and path:
POST /servers/2001/renew, not "renewal is broken"; - the time in UTC, to the minute at least;
- the key id from
GET /keysand its mode (liveortest). Never send the key itself — if it ends up in a message, revoke it withDELETE /keys/{key_id}and issue a new one.
Where to write
- A cabinet ticket is the main route: your account, services and history are all visible there.
- Email:
support@vdsok.guru. - Contacts and working hours: the contacts page.
API availability
There is no public status page. The availability probe is GET /health: it answers 200 without a key and says whether the API is switched on (status: ok | disabled). It is a probe, not a protected endpoint — handy for telling whether the silence is ours or yours.
A bug in an SDK or in these docs
- The SDKs live on GitHub: sdk-node, sdk-python, sdk-php — open an issue there.
- Something wrong on this page: a ticket or an email, with the section anchor (
#idempotency, for example).