Partner API
Partner API v1 · revision 1.13 · 9 November 2026 · What changed
Every endpoint, field, limit and error code of the server-to-server API.
All the guides · OpenAPI spec and explorer · Need more? Talk to us · Partner terms
A server-to-server API for resellers. Your backend creates subscribers, hands them VPN configs or a whole subscription, and reads usage for reconciliation. Subscribers have no ProofKit login. If you proxy the subscription under your own domain, the subscription URL your users add is yours; if you hand out our hosted URL instead, it is on proofkit.org. Ghostlane also reads olcRTC occupancy from proofkit.org (see Ghostlane shows occupancy).
Base URL: https://proofkit.org/
OpenAPI spec and explorer: openapi.json describes every endpoint below in OpenAPI 3.1, and the Partner API explorer shows it and sends calls with your key. Those calls are real: they reach production like calls from your backend.
All money is in nanoTON integers: 1 TON = 1,000,000,000 nanoTON. A GB in prices and trial limits is 1,073,741,824 bytes, with upload and download counted together.
Guides built on this reference:
- Quick start: from a subscriber to a working subscription in Happ.
- Payments: linking your wallet, topping up, the balance, withdrawing.
- Russia: entry through the Russian edge, the transports, olcRTC.
- Partner terms: prices, the stake, Torrent Guard, acceptable use, suspension.
Getting access
This reference is for a backend. A partner that adds its subscribers by hand needs none of it, and no key: see Without code: the dashboard.
You sign your partner up yourself, in the partner dashboard; nobody at ProofKit approves it.
Sign in at proofkit.org (or create a free account) with an account that signs in with an
email address, not only with Telegram, and runs no other partner. Confirm the address, turn on
two-factor sign-in (an authenticator app or Telegram codes) and press Become a partner on
proofkit.org/partners, or open #/. The checklist then:
- asks for your service: a name, where your customers are and how you add subscribers. The
answers set where you start (see Subscription settings). Saving
them creates your partner record together with the billing account your subscribers'
traffic is charged to, and attaches your account as its owner. That account signs in on
proofkit.org with two-factor sign-in and gets the Partner page (
#/) with the plan, balance and stake, charges, wallet, top-up, withdrawal, API key, the subscription's branding and route filters and, when olcRTC is on for you, olcRTC in use against your share;partner - links the partner wallet: a TON wallet that is only for the partner, never the wallet of your personal ProofKit account;
- takes one deposit from that wallet, which is the stake and your first top-up: a stake of 5 TON and at least 0.1 TON for traffic, so at least 5.1 TON (the dashboard suggests 5.5 TON). You tick the checkbox that accepts the Partner Terms first;
- switches on the subscription for your partner once the deposit has landed and the partner is live, and emails you.
The stake is locked while the partner runs and comes back 30 days after you close the partner. There is no free trial on this path: every GB is paid for from the first byte. Then press Create key on the Partner page: the key is shown once.
A free trial exists only by agreement with ProofKit. So do olcRTC (with your olcRTC share), a bigger start, an agreed price and an entry server of your own: talk to us. The olcRTC share binds only once subscriptions are on: without them, olcRTC is bounded by the rooms alone, and the share shown on the Partner page is for information.
The subscription's branding and route filters are yours to set, in the Partner page's Subscription tab (see Subscription settings); ProofKit can also set them for you on request. Whether subscriptions and olcRTC are on, and your olcRTC share, only ProofKit changes.
Money, keys and the subscription settings are handled on the Partner page, not through this API: this API never moves money, cannot rotate its own key and cannot change the settings.
Authentication
Every request carries the key in its own header:
X-Partner-Key: pk_1a2b3c4d_<48 hex characters>
A key is pk_, 8 hex characters, _, 48 hex characters. Surrounding whitespace is
ignored. Authorization: Bearer is not used on this surface.
We store only an HMAC of the key and its public prefix (pk_ plus the first 8 hex
characters). A lost key cannot be recovered, only replaced.
A missing, malformed or unknown key, and the key of a disabled partner, all get the same answer:
{ "error": { "code": "INVALID_PARTNER_KEY",
"message": "Missing or invalid partner API key" } }
with status 401. They are deliberately indistinguishable. A partner that signed itself up
gets this answer too, until its stake is locked. What a suspension does to your
subscribers is under If ProofKit suspends your partner.
The framework's checks of the body, path and query run before the key check: a request that
fails one gets that plain-text answer (see Errors), with or without a valid key.
Every response is scoped to the partner the key belongs to. No path carries a partner id,
and naming another partner's subscriber or session answers 404, the same as a
subscriber that does not exist.
Call the API from your backend. Browsers cannot send the key cross-origin: our CORS policy
does not allow the X-Partner-Key header. The explorer can,
because it is served from proofkit.org itself; it keeps the key in the tab's memory only.
Rotating the key
A rotation issues a new key and keeps the current one working for 24 hours, so your backend can switch without a failed request:
- On the Partner page, press Rotate key. The new key is shown once; copy it then.
- Deploy the new key to your backend within 24 hours.
- After 24 hours the old key answers
401.
Only one previous key is kept. Rotating again within the 24 hours ends the older key at once, and the key you just replaced becomes the one with the 24-hour overlap. The owner can rotate at most 5 times an hour. The Partner page shows the current key's prefix and, during an overlap, until when the previous key works.
ProofKit can also rotate your key the same way, issue a new key without an overlap (every older key stops at once), or revoke it. After a revoke the Partner page offers Create key again. There is no rotation call on this API: a key cannot be used to replace itself.
Rate limits
Budgets are counted per key prefix (pk_ and its 8 hex characters), in a fixed 60-second
window that starts with the first request and is shared by all our servers. The count is
taken before the key is checked, so a request with a wrong key that carries your prefix
spends your budget, and every request with no X-Partner-Key at all shares one bucket:
| Bucket | Limit | Requests |
|---|---|---|
| Subscription fetches | 1,200 per minute | GET / |
| Everything else | 300 per minute | every other call, including errors and unknown paths |
Every response from this layer, errors and 401s included, carries the state of the
bucket the request was counted in:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 297
X-RateLimit-Reset: 42 # seconds until the window resets
A refusal is 429 with code RATE_LIMITED ("Rate limit exceeded: N requests per 60
seconds") and adds Retry-After, in seconds, equal to X-RateLimit-Reset.
In front of the per-key budgets sits a per-IP flood guard: 6,000 requests per minute from
one IP address on /api/. That is more than one key's two budgets together,
so a single backend or proxy does not reach it in normal use. Its 429 has the same
envelope but no X-RateLimit-* headers, because it runs before the per-key layer; so
does its 500 when our rate limiter's store is unreachable.
Other limits you may meet:
| Path | Limit |
|---|---|
Hosted https://proofkit.org/ URLs | 60 per minute per IP |
GET / | 120 per minute per IP |
POST / | 20 per hour per IP |
GET / reports rate_limit_per_min: 300, the general budget. The subscription
bucket's 1,200 is not reported there; the Partner page shows both. Both budgets are the same
for every partner; for what 1,200 fetches a minute carry, see At scale under
Serving it under your domain.
Errors
Failures from our handlers use one envelope. The code is nested under error:
{ "error": { "code": "PARTNER_NOT_BILLABLE",
"message": "Partner has no billing account yet — set wholesale terms first" } }
Dispatch on error.code; the message is for people. No endpoint on this surface sets an
error.details object today.
An unknown path under /api/ answers 404 with code NOT_FOUND ("Unknown
partner API endpoint"). A known path with the wrong method answers 405 with code
METHOD_NOT_ALLOWED, with an Allow header listing the path's methods.
Not every failure is in the envelope. Requests the framework rejects before a handler runs come back as plain text, before the key is checked:
| Status | Cause |
|---|---|
400 | The body is not valid JSON |
415 | A JSON body sent without Content-Type: application/ |
422 | The JSON is valid but a field is missing or has the wrong type, e.g. no external_id |
400 | A query parameter that does not parse, e.g. from=yesterday or qr=maybe |
400 | A path parameter that does not parse, e.g. a session_id that is not a UUID, or an external_id that is not valid UTF-8 once percent-decoded (%FF) |
A body over 1 MB never reaches the API: our proxy answers 413 with its own HTML page,
without X-RateLimit-* or X-Request-ID. Every body this API takes is far smaller.
Treat any response without the JSON envelope as one of these (or as coming from a proxy in front of us).
Statuses: 400 for a request we refuse, 401 for the key, 403 for something you may
not do (a private node without a grant, a disabled subscriber at connect, the trial's
subscriber cap, subscriptions or olcRTC not switched on), 404 for anything that is not
yours or does not exist, 409 for a removed device that cannot be let back while its
subscriber is at its device limit, 429 for rate limits, 503 for olcRTC capacity or your olcRTC
share (retry or fall back), 500 for us. If our rate limiter's store is unreachable, the per-IP layer answers 500
with code INTERNAL_ERROR ("Rate limiter unavailable"), without X-RateLimit-*.
Every response from the API carries X-Request-ID (the proxy's 413 does not). Send your
own and we echo it; otherwise we generate one. Quote it when you contact us about a specific call.
The full list of codes is at the end: Error codes.
Versions and compatibility
The API has one major version, v1, in its path. Within it we only add: nothing you call or
read is removed, renamed or given another meaning. We may add endpoints, optional request
fields, fields and headers in answers, error codes for new features, and new values where a
set can grow (transports, carriers, statuses, error codes). Write your client to ignore fields
it does not know and to treat a value it does not know as "other".
Every addition is a numbered revision, such as 1.4, listed with its date under
Changes. Every answer names the revision that produced it in the
X-Partner-Api-Version header, for example 1.4
(since revision 1.4); the answers that never reach the
API, the proxy's 413 and the per-IP layer's 429 or 500, carry no such header, like the
rate-limit ones. You do not choose a revision: everyone gets the newest. The OpenAPI document
of each revision stays where it was published, for example
https://proofkit.org/, and openapi.json is always the newest.
If we ever need a change that breaks these rules, it will be a new major version at a new
path, /api/, announced at least 90 days before v1 stops, and v1 works
throughout.
Endpoint summary
All paths are under /api/ and need X-Partner-Key.
| Method | Path | Purpose |
|---|---|---|
GET | /account | Plan, balance, trial, wallet and deposit link |
GET | /catalogue | Nodes you may sell, with the price you pay |
POST | /subscribers | Create or re-enable a subscriber (idempotent) |
GET | /subscribers | Your subscribers, a page at a time |
GET | /subscribers/ | One subscriber's state |
PATCH | /subscribers/ | Set, clear or renew a subscriber's limits |
DELETE | /subscribers/ | Disable a subscriber |
POST | /subscribers/ | New subscription token and credentials, for a leaked link |
GET | /subscribers/ | The subscriber's whole subscription, branded |
GET | /subscribers/ | The devices that fetched the subscriber's subscription |
DELETE | /subscribers/ | Remove a device: its fetches are refused |
POST | /subscribers/ | Let a removed device back |
POST | /subscribers/ | Start (or return) one session |
GET | /subscribers/ | The subscriber's live sessions |
DELETE | /subscribers/ | End one session |
GET | /subscribers/ | Every transport for one session |
POST | /subscribers/ | Provision, connect and return olcRTC lines in one call |
GET | /usage | Usage and cost for every subscriber over a window |
GET | /subscribers/ | Usage and cost for one subscriber |
GET | /guard-events | Every time Torrent Guard ended a session of yours, for 30 days |
There are two ways to give a subscriber VPN access:
- The subscription (recommended for Happ and similar apps): create the subscriber, then
serve
GET …/to their app, proxied under your domain. It carries every route and transport they may use and provisions itself. The quick start walks through it.subscription - Per session:
POST …/for a chosen country or node, thenconnect GET …/for that session's links. Use it when you want to choose the routes yourself. Once subscriptions are on for your partner, a later subscription fetch for the same subscriber lists these sessions too and adopts them as subscription lines (see What it contains).configs
GET /account
The state of your account, so you can act before it turns into a refusal.
{
"partner_id": "e337bf03-…",
"billable": true,
"billing_mode": "metered",
"billing_plan": "trial",
"balance_nano": 0,
"stake_nano": 0,
"credit_limit_nano": 0,
"suspended": false,
"rate_limit_per_min": 300,
"wallet": null,
"escrow_address": "0:4ed1…",
"deposit_link": null,
"hosted_base": null,
"olcrtc_pricing": { "total_price_per_gb_nano": null, "schedule": [] },
"trial": {
"until": "2026-10-03T12:00:00Z",
"max_subscribers": 10,
"subscribers": 3,
"max_bytes": 53687091200,
"bytes_used": 2147483648,
"active": true
}
}
| Field | Meaning |
|---|---|
partner_id | Your partner id. |
billable | Whether your billing account exists. false means POST / answers PARTNER_NOT_BILLABLE. |
billing_mode | "free" while ProofKit covers your traffic (the exempt plan), otherwise "metered". This field keeps this meaning; the plan itself is in billing_plan. A partner in a trial reads "metered", even while its traffic is free: read billing_plan and trial.active for that. |
billing_plan | "exempt" (ProofKit covers your traffic), "trial" (free within the trial's limits, then charged like prepaid) or "prepaid" (charged from your own TON deposit). |
balance_nano | Balance of your billing account: deposits + unsettled earnings − traffic charges − withdrawals − slashes. A slash of your partner's stake lowers balance_nano and stake_nano by the same amount. The connect gate, the session enforcer and the Partner page also subtract your partner's stake (stake_nano) and any stake locked in nodes your billing account owns (none unless it runs nodes). |
stake_nano | The stake locked on your partner, in nanoTON: 0 for a partner without one. balance_nano still counts it, and the connect gate subtracts it (see The connect gate below). Since revision 1.9. |
credit_limit_nano | How far below zero the balance may go, as a positive magnitude. Always 0 on prepaid, and set to 0 when a trial starts. |
suspended | Always false when you can read it: a disabled partner's key gets 401 before this runs. It is here so a monitor polls one endpoint. |
rate_limit_per_min | The general per-key budget, 300. |
wallet | The TON wallet linked to your billing account, or null before you link one on the Partner page. |
escrow_address | The ProofKit escrow contract your deposits go to. |
deposit_link | ton://transfer/, a transfer to the escrow with the comment deposit. null until a wallet is linked. |
hosted_base | The base your hosted subscription URLs ride on (https://sub.yourdomain.com) while your managed domain is active, null otherwise — then it is https://proofkit.org. See Serving it under your domain. Since revision 1.2. |
olcrtc_pricing | What olcRTC traffic costs apart from the rest. total_price_per_gb_nano is the price of a GB of olcRTC traffic right now, with the network fee, or null while olcRTC traffic is charged at each session's own price. schedule lists every step of that price, oldest first, each with effective_from and total_price_per_gb_nano (null in a step: back to the session's own price from that date). It holds the steps in force or past and those announced. From 12 October 2026 the steps of 20 November, 8 December and 22 December 2026 are on record here (the prices are in The olcRTC price). The price in force applies to traffic as it moves, on running sessions too. Since revision 1.1. |
trial | Present only while billing_plan is "trial"; omitted otherwise. See below. |
Not reported here or anywhere in this API: whether the subscription is switched on for your
partner, whether olcRTC is on for it, your olcRTC share and your subscription settings. Here
the only signals are 403 SUBSCRIPTION_NOT_ENABLED, 403 OLCRTC_NOT_ENABLED and olcRTC
lines missing from a subscription. The Partner page shows all of them: its Subscription tab
says whether subscriptions are on and holds your settings, and its olcRTC figure ("in rooms
/ your share") says whether olcRTC is on for you and, when it is, how many of your
subscribers' olcRTC keys count as present now against your share. Present does not mean
in the room: see olcRTC under GET /.
The connect gate. A new session is allowed when balance_nano > -credit_limit_nano
(after subtracting any stake locked in nodes your billing account owns, and your partner's stake (stake_nano)),
or when you are on exempt, or while trial.active is true, or when the route's total
price is 0. On prepaid the credit limit is 0, so the balance must be above zero. When
the balance reaches the limit, new connects on priced routes are refused with
INSUFFICIENT_BALANCE and your subscribers' running sessions are closed: the session
whose traffic report took the balance there is closed at once, and the others within
about 15 seconds (what a close does to connections still open: see
DELETE /). That close covers
active sessions on priced routes: a session still pending (its node has not confirmed
it yet) is closed only once it turns active, and a
session on a route priced at 0 is left running. Nothing restarts on its own after a
top-up: your
subscribers' apps get new lines on their next subscription refresh, or you connect them
again. Alert on the distance to zero, on billing_plan changing and on trial.active
turning false; there are no webhooks.
Deposits. Only a transfer from the linked wallet is credited, and only when it is
sent with the escrow's Deposit operation (what the Partner page's Top up sends) or as a
plain transfer with the text comment deposit (what deposit_link does). A plain
transfer with no comment is not credited to anyone, and a transfer with any other comment is
rejected by the escrow. A transfer from a wallet that is not linked is not credited while it
is unlinked, so link the wallet first, then deposit. A deposit sent before linking is picked
up once the wallet is linked, but only while it is still among the escrow's 50 latest
transactions; after that, open a ticket under Support on proofkit.org, signed in as the
owner, with the transaction hash, and ProofKit credits it by hand.
The Partner page's Top up takes 0.1 TON or more; a transfer with the comment deposit
takes 0.05 TON or more (the escrow refuses less). There is no minimum balance to keep. On
prepaid there is no credit either: a new session needs a balance above zero. A deposit is
credited once the escrow confirms it and our checker sees it, usually within a minute.
Details: Payments.
trial
| Field | Meaning |
|---|---|
until | When the free traffic ends (null if the trial was never started). |
max_subscribers | How many subscribers the trial covers. |
subscribers | Subscribers you have created, disabled ones included. |
max_bytes | The trial's traffic allowance in bytes. |
bytes_used | Traffic counted against it so far. |
active | true while every limit holds and traffic is free: before until, bytes_used under max_bytes, and subscribers not above max_subscribers. false means you are charged like prepaid from the next traffic report on. |
GET /catalogue
Every node you may sell: active nodes ProofKit has approved for automatic routing, public
ones plus nodes granted to you, including private ones. ProofKit reviews each new node before
it is approved, and a node is listed only while its own price is at or under the network's
ceiling for automatic routing. Ordered by country, then price. Origins (exits) and edges
(entries) are both listed.
Before you have a key, https://proofkit.org/ lists the current public exit
countries and their prices (the network fee included).
[
{
"node_id": "0f2a…",
"node_type": "origin",
"country": "DE",
"price_per_gb_nano": 10000000,
"total_price_per_gb_nano": 12000000,
"fee_bps": 2000,
"supported_transports": ["vless_reality", "hysteria2", "vless_xhttp", "olcrtc"],
"olcrtc_carriers": ["telemost", "wbstream", "salutejazz", "vkcalls"],
"olcrtc_slots_total": 16,
"olcrtc_slots_free": 13,
"olcrtc_keys_total": 32,
"olcrtc_keys_free": 20,
"is_public": true,
"sessions_count": 12,
"max_users": 0,
"max_users_effective": 500,
"agent_online": true
},
{
"node_id": "17e6…",
"node_type": "edge",
"country": "RU",
"price_per_gb_nano": 10000000,
"total_price_per_gb_nano": 12000000,
"fee_bps": 2000,
"entry_region": "RU",
"supported_transports": ["vless_reality", "hysteria2", "vless_xhttp", "vless_webtunnel"],
"olcrtc_carriers": [],
"is_public": true,
"sessions_count": 32,
"max_users": 0,
"max_users_effective": 500,
"agent_online": true
}
]
The prices above are examples; read the live ones from this endpoint.
| Field | Meaning |
|---|---|
node_id | Use as origin_id or edge_id in connect. |
node_type | "origin" (the exit to the internet) or "edge" (an entry that relays to an origin). |
country | Two-letter country code, as exit_country / entry_country expect it. |
price_per_gb_nano | The node's price per GB for you, with any price agreed for you applied, before the network fee. |
total_price_per_gb_nano | What a GB through this node costs you: price_per_gb_nano plus the network fee. |
fee_bps | The network fee in basis points: 2000 = 20%. |
entry_region | "RU" on an edge in Russia. Absent on every other node, including an origin in Russia. |
supported_transports | Any of vless_reality, hysteria2, vless_xhttp, vless_webtunnel, olcrtc. vless_webtunnel is named on an entry while one of its links to an exit in this list serves a WebTunnel line now, and, since revision 1.11, on an exit while a route straight to it serves one. It says that a subscriber of yours could be given the line there, which your sub_transports then decides (see WebTunnel). Since revision 1.8. |
olcrtc_carriers | The olcRTC carriers the node serves right now, Telemost first: any of telemost, wbstream, mtslink, salutejazz, vkcalls. [] when it serves none. |
olcrtc_slots_total / olcrtc_slots_free | olcRTC capacity: key holders on the node counted as present at once, all its carrier rooms together (see olcRTC below for what present means). Absent on a node with no olcRTC carrier. |
olcrtc_keys_total / olcrtc_keys_free | olcRTC keys the node may hold, and at most how many it will still hand out. Absent with the slots. |
is_public | false for a private node granted to you. |
sessions_count | Active sessions on the node, as origin or edge, idle ones included. |
max_users | The node's own user limit, 0 when it sets none. |
max_users_effective | The limit actually enforced (max_users, or our default when it is 0). It applies to origins only; see Load below. |
agent_online | Whether the node's agent is connected to our servers right now. |
Price. A session's price is locked when it starts: the origin's price plus, on a route
through an edge, the edge's price, plus the 20% network fee on that sum, rounded down to a
whole nanoTON. So a direct route costs the origin's total_price_per_gb_nano, and a route
through an edge costs the origin's total plus the edge's total, within one nanoTON.
connect returns that figure. A later drop in a node's price is followed by running
sessions once the lower price has stood for 3 hours (any change of the node's price starts
those 3 hours again), a rise is not: the session follows a drop down (a price agreed for you
still applies), and its total and fee are recomputed. Public prices at https://proofkit.org/
already include the fee; here price_per_gb_nano does not, and total_price_per_gb_nano
does. The field names say fee or platform (fee_bps, platform_fee_nano); both mean the
20% network fee.
Load. max_users_effective is checked against live users, sessions that moved
traffic in the last 15 minutes, not against sessions_count. A subscription keeps one
session per route for each subscriber, idle or not, so sessions_count is usually far
above the number the limit sees. Do not compare the two. Live users are counted by exit,
so the user limit applies to origins only: an edge's own count always reads zero. An
edge's load metrics (CPU, memory, load average, connections, bandwidth) are weighed only
when connect picks the edge itself through entry_country. A named edge_id, and every
route through an edge that the subscription starts (it names the origin and the edge), is
not refused for the edge's load: only the named origin's load and user limit are checked.
Online. A node listed with agent_online: false still accepts a session, but the
session stays pending and carries nothing until the node's agent reconnects. Its
credential is sent then, and the session becomes active. If the node stays offline for 30
minutes, the session is closed. Prefer nodes with agent_online: true.
Entry in Russia. entry_region: "RU" marks an edge you can use as the entry for users
in Russia: entry_country: "RU", or its node_id as edge_id, in connect. The
subscription includes routes through it unless your route filters leave them out. See
Russia.
olcRTC. olcrtc_carriers lists the carriers that are actually served on the node now:
the node's olcRTC is on, the carrier is chosen for it, the node's owner has an account for
the carrier (Telemost, WB Stream, MTS Link and VK Calls; SaluteJazz needs none), our rollout covers the
node, for MTS Link, SaluteJazz and VK Calls its agent can run the carrier, and the carrier's server on the node has
not been down for more than two hours. A carrier missing here is not offered on that node.
The two olcRTC pools are separate from max_users_effective. A node's olcRTC is limited by
one shared call, so its numbers are small (16 slots and 32 keys per node today).
Slots count key holders who are present. A key holder is present while any of its
sessions on that exit has moved traffic in the last 5 minutes, on any transport:
Reality, Hysteria2 and XHTTP traffic, direct or through an entry, counts as much as olcRTC.
So a subscriber who holds a key on DE and uses the DE Reality line takes a DE slot without
ever opening olcRTC. Slots free themselves when the holder stops sending. Keys are what
a subscriber holds, and every holder may come back at once, so keys bound how large the
crowd can get. A newcomer needs both a free slot and a free key: a node with
olcrtc_slots_free: 0 or olcrtc_keys_free: 0 refuses them (POST …/ passes it
over, or answers OLCRTC_UNAVAILABLE on a session it reused; a subscription leaves its
lines out). Someone who already holds a key on the node is never refused. Both pools are
per node: all of the node's carrier rooms (Telemost, WB Stream, MTS Link, SaluteJazz, VK Calls) share them, with
one key per subscriber. They are shared by everyone using that node: your subscribers, other
partners' and ProofKit's own users.
olcrtc_keys_free already counts the keys the node could take back from any long-idle
holder to seat someone, so read it as an upper bound. Without subscriptions, the olcRTC
door and configs do take such keys back. With subscriptions on for your partner, your new
keys never make the exit take back anyone else's key: a node gives one of your
subscribers a new key only while its live keys are under its budget and you hold fewer than
your part of them (a fifth at the default share, up to half as your olcRTC traffic grows), or
else one for one in place of a quiet subscriber of yours, on an exit nobody is present on. So a
node can show olcrtc_slots_free and olcrtc_keys_free above zero and still refuse every new
subscriber of yours.
Do not pick an olcRTC node from these numbers and then connect: another caller can take
the last place in between. POST …/ chooses
and claims in one call; the subscription does the same for its olcRTC lines.
A node leaves the catalogue when it is no longer active, or no longer approved for automatic routing (a price raised above the ceiling included). Stop selecting it then.
What happens to the sessions already on such a node depends on why it left:
- Its price went above the ceiling, or it is under review again. Nothing: a session on it keeps running at the price it locked, in use or not. Leaving the catalogue affects new choices only.
- ProofKit paused it — the node is short of memory or needs work done on it. ProofKit routes nobody new to it, and its sessions are moved off it. A session that has carried no traffic for two hours is closed, and the subscription does not start that route on the paused node again; the subscriber's other routes stay as they are. A session in use is closed only when its subscriber already holds the same route through another node of the same country at the same price or less — with a subscription, which holds every route, that is the usual case — and only while the node is short of memory, one session every five minutes for a node. The other line keeps working, and the closed one leaves the body at the next fetch.
A subscriber of yours who names a paused node by origin_id/edge_id can still connect to
it. A session made that way is closed like any other once it has gone two hours without
traffic.
POST /subscribers
Creates a subscriber, or returns the existing one. Idempotent on external_id, including
under concurrent calls.
{ "external_id": "user-42", "quota_bytes": 26843545600,
"exit_countries": ["DE", "SE"], "expires_at": "2026-11-10T20:30:00Z",
"device_limit": 3 }
{
"external_id": "user-42",
"account_id": "8c1d…",
"sub_token": "b7f3…",
"created": true,
"disabled": false,
"created_at": "2026-09-26T10:00:00Z",
"quota_bytes": 26843545600,
"quota_used_bytes": 0,
"quota_remaining_bytes": 26843545600,
"over_quota": false,
"exit_countries": ["DE", "SE"],
"expires_at": "2026-11-10T20:30:00Z",
"expired": false,
"device_limit": 3,
"devices": 0
}
Only external_id is required: { "external_id": "user-42" } creates a subscriber with no
limits.
external_id is your own identifier for the person: 1 to 128 bytes of UTF-8 after
surrounding whitespace is trimmed (so 64 Cyrillic letters), unique within your partner.
The trimmed value is what we store and what you use in paths. In a path, percent-encode it
(a space, /, ?, #, % and any non-ASCII letter): we decode the path segment before
the lookup. The same value under another partner is a different person. Longer, empty or
blank values answer 400 INVALID_EXTERNAL_ID. Avoid . and ..: they are accepted, but
browsers (the explorer included) and many HTTP clients drop such path segments, even
percent-encoded, so a path call for them reaches another endpoint.
created is the only way to tell whether this call made the subscriber: the status is
200 either way. Calling it for a disabled subscriber re-enables them and answers
created: false, disabled: false, with the same sub_token as before.
sub_token is the capability behind the subscriber's hosted subscription URLs
(https://proofkit.org/, see
Hosted subscription URLs). Anyone holding it can fetch that
subscription without a key, so treat it as a secret. It stays the same until you
reset the subscriber.
Since revision 1.3,
quota_bytes is an optional traffic quota in bytes (1 MiB to 1 PiB): when the
subscriber's metered traffic since the quota was set reaches it, the platform ends their
sessions and refuses new ones — see
PATCH / for the full rules.
Absent means no quota, today's behaviour; 0 is not "no quota" and answers
400 INVALID_QUOTA. The quota's used bytes are the same metered intervals your invoice
counts, read from the quota's start — not from any report window.
Since revision 1.5, exit_countries gives the subscriber
its own list of exit countries and expires_at the instant its subscription ends — see
Subscriber exit countries and
Subscriber expiry. Both are optional. Absent means no list and no end
date, and the subscriber then answers exit_countries: null, expires_at: null and
expired: false. For a subscriber that exists, POST applies the fields it names, like
PATCH; it clears nothing.
Since revision 1.7, device_limit caps how many devices
may receive the subscriber's subscription, from 1 to 100, and the subscriber answers
device_limit and devices, the devices that hold one of its slots now: see
Subscriber device limit. Absent or null leaves the limit as it
is, so a new subscriber has none and answers device_limit: null. A whole number outside 1
to 100 answers 400 INVALID_DEVICE_LIMIT; a value that is not a whole number, such as 2.5
or "3", is the framework's plain-text 422 (see Errors).
Creating a subscriber starts no session and touches no node.
| Code | Status | When |
|---|---|---|
INVALID_EXTERNAL_ID | 400 | external_id empty or over 128 bytes |
INVALID_QUOTA | 400 | quota_bytes outside 1 MiB..1 PiB (0 included — use null/absent for "no quota") |
INVALID_EXIT_COUNTRIES | 400 | exit_countries empty, over 64 codes, a code that is not two upper-case letters, or a code listed twice |
INVALID_EXPIRES_AT | 400 | expires_at is not an RFC 3339 instant with an offset, or not in the future |
INVALID_DEVICE_LIMIT | 400 | device_limit is a whole number outside 1 to 100 |
PARTNER_NOT_BILLABLE | 400 | Your billing account is not set up yet |
TRIAL_LIMIT | 403 | You are inside a free trial and it already covers max_subscribers subscribers |
QUOTA_EXHAUSTED | 403 | The subscriber's quota is used up: re-enabling them needs a quota_bytes with room in it |
SUBSCRIPTION_EXPIRED | 403 | An existing, enabled subscriber's end date has passed: the call needs a later expires_at (a disabled one is re-enabled as always, and answers expired: true) |
TRIAL_LIMIT refuses only a new subscriber: an existing one is always returned. Its
message says what to do: "The free trial covers {N} subscribers. To add more, top up the
balance in the partner dashboard and ask ProofKit to end the trial." Once the trial is
over (ended, or any limit crossed), there is no cap.
GET /subscribers
Your subscribers, a page at a time: every one you created, disabled ones included, oldest
first (by creation time, then by account_id for two created in the same instant).
GET /subscribers?limit=100&q=tg-&disabled=false
X-Partner-Key: pk_…
{
"subscribers": [
{ "external_id": "tg-1001", "account_id": "8c1d…", "disabled": false,
"created_at": "2026-09-26T10:00:00Z" },
{ "external_id": "tg-1002", "account_id": "5a07…", "disabled": false,
"created_at": "2026-09-26T10:05:00Z" }
],
"next_cursor": "MTc5MDQxNzEwMDAwMDAwMC44YzFk…"
}
| Parameter | Meaning |
|---|---|
limit | Subscribers per page, 1 to 200. Default 50. |
cursor | The previous page's next_cursor, as it came. Absent for the first page. |
q | Keep the external_ids that start with this, byte for byte: case-sensitive, and % and _ are ordinary characters. |
disabled | true keeps the disabled subscribers, false the enabled ones. Absent: both. |
Read the next page by passing next_cursor as cursor, with the same q and disabled;
next_cursor is null on the last page. The cursor is opaque: pass it back unchanged. A
subscriber created while you page lands on a later page, never on one you already read.
Each entry is what GET / answers,
without sub_token and created: a list carries no credential, and created describes a
POST. So each entry carries the subscriber's quota state and,
since revision 1.5, its exit_countries, expires_at
and expired, and since revision 1.7 its device_limit
and devices. Traffic is not in the list; GET / has it for every
subscriber.
The Partner page shows these subscribers to your partner's owner, newest first, with a search
that matches anywhere in an external_id, a filter by state with each state's count, each
subscriber's last 30 days, its limits, when it last moved bytes, its hosted links, and Reset,
Disable and Enable, which do what the calls below do. This list's order and filters are the
page's own: the call above keeps its four parameters. Remove… and Change number… there
are the page's own too. To this API a removed subscriber is a disabled one whose link was reset:
it stays in this list and on the usage report, and POST / with its number enables
it again. A changed number is the subscriber's external_id from then on. Link copies the link you give the subscriber,
Ghostlane the one that app reads, and QR opens Hand over: the link as a code to
scan, what the subscriber is limited to, and Copy, Share (where the browser has a share sheet)
and Print — a card with your title, the code, the link, the end date and three steps. Once
subscriptions are on for your partner, the owner can also add a subscriber by hand there
(Add, with an external_id — an order or booking number — and, if wanted, an end date, a
quota and exit countries): it does what POST / does — a new subscriber, or one
that exists enabled again, within a trial's cap — for a seller with no backend of its own.
The page asks before it touches a number that exists (the same external_id is the same
subscriber, with its old link), Add several… makes up to 100 at once and changes nobody
that exists, and Export CSV saves the list as a file, its links only when asked for.
Hand over opens by itself for a subscriber that was just made.
A subscriber's menu there has Quota, end date, countries, devices…, which does what
PATCH / does: it sends only what
was changed, so moving the date does not start the quota's allowance again. A subscriber's
card there lists its devices, with Remove and Allow again (see
Subscriber device limit).
| Code | Status | When |
|---|---|---|
INVALID_LIMIT | 400 | limit below 1 or above 200 |
INVALID_CURSOR | 400 | A cursor that no list answered with |
A limit that is not a number, or a disabled other than true or false, is a
plain-text 400 (see Errors).
GET /subscribers/{external_id}
The subscriber in the same shape as above, with created: false. Disabled subscribers are
returned too, with disabled: true. A subscriber that is not yours, or does not exist,
answers 404 NOT_FOUND ("Subscriber not found").
GET / lists them a page at a time, without the sub_token.
PATCH /subscribers/{external_id}
Since 1.3Changes a subscriber's limits: its traffic quota and,
since revision 1.5, its exit_countries and its
expires_at, and since revision 1.7 its device_limit.
Answer is the same payload
GET / gives, fresh.
{ "quota_bytes": 26843545600, "expires_at": "2026-12-10T20:30:00Z" }
Three meanings for each field:
- a value sets it;
nullclears it: no quota, your partner's own exit list, no end date, no device limit;- absent leaves it as it is. A call that names no field is a read that answers the current state.
What a value does:
quota_bytessets the quota and starts its allowance from now: the used counter reads from this call's moment. So aPATCHto the number already in force is the renewal: a subscriber sold "25 GB valid 30 days" gets 25 GiB at purchase and the samePATCHevery month, and a top-up is aPATCHto a higher number. Lowering is never an error and never cuts on the spot — any positive quota is a fresh allowance; a subscriber only goes past their quota by moving traffic.exit_countriesgives the subscriber its own list of exits, and ends its sessions on the exits a narrower list leaves out: see Subscriber exit countries.expires_atsets the instant the subscription ends; a later one renews an expired subscriber: see Subscriber expiry.device_limitsets how many devices may receive the subscriber's subscription, from 1 to 100. A lower limit removes no device: see Subscriber device limit.
A field does not touch the others: naming only exit_countries, expires_at or
device_limit leaves the quota and its allowance as they are. One bad field refuses the whole
call, and nothing of it is written.
| Code | Status | When |
|---|---|---|
INVALID_QUOTA | 400 | quota_bytes outside 1 MiB..1 PiB |
INVALID_EXIT_COUNTRIES | 400 | An empty list, more than 64 codes, a code that is not two upper-case letters, a code listed twice |
INVALID_EXPIRES_AT | 400 | Not an RFC 3339 instant with an offset, or not in the future |
INVALID_DEVICE_LIMIT | 400 | A whole number outside 1 to 100 |
NOT_FOUND | 404 | A subscriber that is not yours, or does not exist |
DELETE /subscribers/{external_id}
Disables the subscriber and cuts off their access. Returns 204; calling it again also
returns 204.
What happens:
- Every live session of the subscriber ends, and each session's credential is removed from
its node at once, so their VLESS Reality, Hysteria2 and XHTTP lines stop working. On the
node, a new connection with an ended session's credential is refused, and so is a new
stream on a Hysteria2 connection that is still up. Traffic that was already under way (a
download in progress, a UDP flow, a reused HTTP/2 or HTTP/3 connection) is not cut at
once: if it keeps going after the session ended (about 1 MiB received, or 16 MiB sent),
the node restarts its proxy within about a minute. That ends it, and drops the node's
other connections for a moment too (apps reconnect on their own). The same happens when a
Hysteria2 connection held open for more than an hour is used again. A client that has
stopped costs nobody else anything: every other session on the node keeps running. Ending
a session any other way (a reset,
DELETE …/, a close for balance) works the same.sessions/ {session_id} - Their hosted subscription URLs and
GET …/answersubscription 404, andconnectanswers403 FORBIDDEN("Your access has been disabled by your provider."). - Their olcRTC keys drop out of the room at the next room-health pass, about 5 minutes later, sometimes longer. Until then an olcRTC line they already hold can still carry traffic.
- The account and its usage and ledger history stay: traffic already charged remains in your reports.
A disable is undone by POST / and by POST /
with the same external_id: both re-enable the subscriber, with the same sub_token. The
olcRTC call re-enables even when it then answers 503; only its
403 OLCRTC_NOT_ENABLED comes before that and changes nothing. Make sure your backend does
not call either for someone you cut off.
A re-enabled subscriber does not get its olcRTC place back. Once the room-health pass has
revoked its keys, its next ghostlane or json fetch (or olcRTC call) needs a new place,
under your share and the room's capacity like any newcomer, and gets a new key. If your
share is in use or no room has a place, its body comes back without olcRTC lines.
If a subscriber's link leaked but they are still paying, use
reset instead.
POST /subscribers/{external_id}/reset
Starts a subscriber over, for a link that leaked. No body.
- A new
sub_tokenis issued. The old hosted URLs answer404from this moment. - Every live session ends, and its credential is removed from its node (what that does to
connections still open: see
DELETE /).subscribers/ {external_id} - The subscriber's olcRTC keys are revoked and each room is told at once.
- The subscriber's devices are forgotten, the removed ones too
(since revision 1.7): the new link starts with none, and
its
device_limitstays (see Subscriber device limit).
The subscriber stays as it was (enabled or disabled). The next subscription fetch
provisions fresh credentials on every route it can start. olcRTC is not guaranteed to come
back: the reset gives up the subscriber's olcRTC place, so its next ghostlane or json
fetch needs a new place, under your share and the room's capacity, and gets a new key. If
your share is in use or no room has a place, the body comes back without olcRTC lines until
one frees. Your own proxied URL for the subscriber does not change unless you change it: if
that URL leaked, replacing it is up to you; the reset makes the leaked credentials useless.
Returns the subscriber object with the new sub_token, created: false and devices: 0.
Not idempotent: every call issues a new token and ends the sessions again. 404 if the
subscriber is not yours.
GET /subscribers/{external_id}/subscription
The subscriber's whole subscription, in the format their app imports, named and branded for you. Serve it to the app from your own domain (see Serving it under your domain).
GET /subscribers/user-42/subscription?format=happ
X-Partner-Key: pk_…
It must be switched on for your partner first. Until ProofKit switches subscriptions on,
this answers 403 SUBSCRIPTION_NOT_ENABLED ("Subscriptions are not switched on for this
partner; ask ProofKit to enable them") and provisions nothing. That 403 is the answer for
an existing, enabled subscriber: an invalid format is 400 INVALID_FORMAT and an unknown
or disabled subscriber is 404 NOT_FOUND first, so test the switch with a subscriber you
have created.
Formats
format is optional:
format | Content-Type | Body | olcRTC lines |
|---|---|---|---|
happ (default; also when absent or empty) | text/ | Base64 of the newline-joined share links, the list format Happ, Hiddify and v2rayNG import | never |
ghostlane | text/ | The same links as plain lines, then the olcRTC lines | when olcRTC is enabled for your partner |
json | application/ | The lines as data, for building your own format | when olcRTC is enabled for your partner |
Any other value answers 400 INVALID_FORMAT ("format must be happ, ghostlane or json").
ghostlane and json fetches can reserve olcRTC keys for the subscriber (see
olcRTC in the subscription); a happ fetch never does. If
you build a list for an app that cannot run olcRTC, fetch happ, not json.
The json body:
{
"entries": [
{
"url": "vless://…@…:443?type=tcp&security=reality&…#My%20VPN%20DE%20Direct",
"transport": "vless_reality",
"remark": "My VPN DE Direct",
"session_id": "3d91…",
"origin_id": "0f2a…",
"edge_id": null,
"exit_country": "DE",
"entry_country": null
},
{
"url": "hysteria2://…@…:30211?sni=…&obfs=salamander&…#My%20VPN%20DE%20via%20RU%20%C2%B7%20Hysteria2",
"transport": "hysteria2",
"remark": "My VPN DE via RU · Hysteria2",
"session_id": "a51c…",
"origin_id": "0f2a…",
"edge_id": "17e6…",
"exit_country": "DE",
"entry_country": "RU"
},
{
"url": "olcrtc://wbstream?vp8channel@<room>#<key>$My VPN DE · olcRTC · WB",
"transport": "olcrtc",
"carrier": "wbstream",
"remark": "My VPN DE · olcRTC · WB",
"session_id": "3d91…",
"origin_id": "0f2a…",
"edge_id": null,
"exit_country": "DE",
"entry_country": null
}
],
"upload": 214748364,
"download": 858993460
}
transport is vless_reality, hysteria2, vless_xhttp, vless_webtunnel or olcrtc
(vless_webtunnel since revision 1.8, and only when you
list it in sub_transports: see WebTunnel); carrier appears only on olcRTC
lines. upload and download are this calendar month's bytes (UTC).
What it contains
Every route the subscriber may use that your route filters keep: each active origin
they may use (public, or granted to you), direct, and each active link from an edge to such
an origin, for example every exit through the Russian edge. With no filters set, that is
all of them. A new route is started only on nodes approved for automatic routing, the
ones the catalogue lists; a route the subscriber already holds stays in the
list if a node's approval is withdrawn later. The filters choose exits, the kind of route and the transports for all your
subscribers at once (see Route filters). To give one subscriber exits of
its own, set its exit_countries; for anything finer, build
the list from the json form, or use the per-session calls instead.
Plus every live session the subscriber already holds that the filters keep, one per
route, whichever call made it: connect, the olcRTC call or an earlier fetch. A fetch
adopts each of them as a subscription line, so once active it is no longer closed for
going unused (see A session nobody used is closed for you under
The olcRTC link); it lasts until it ends for another reason. Such a
session is in the body while it lives, even when the subscription itself would not start
that route:
- a
connectwith anorigin_idand anedge_idthat are not linked (its "via" lines do not work); - a node whose grant to you was revoked since;
- a node that is offline or degraded, until it has been offline for 30 minutes and the session is closed.
End such a session with
DELETE …/ to take
its lines out of the body.
Per route, up to three lines, and a fourth if you ticked WebTunnel, in this order, each only if your transport filter keeps it:
- VLESS Reality over TCP. Present on every route: a route that cannot make it is left out.
- Hysteria2 (
hysteria2://, Salamander obfuscation, certificate pinned), when the route carries it. - VLESS + XHTTP + Reality, when the route carries it.
- WebTunnel (
vless://…&type=httpupgrade, VLESS over HTTPUpgrade and TLS), in thehappandjsonformats, when you listed it insub_transportsand the route can serve it now: a route through an entry that serves it (since revision 1.8), or a route straight to an exit that serves it (since revision 1.11); see WebTunnel. Theghostlaneformat never carries it.
Routes are ordered by exit country, then by node (oldest first), the direct route before
the routes through edges. Lines are named "<prefix> <exit> Direct" or
"<prefix> <exit> via <entry>", with " · Hysteria2", " · XHTTP" or " · WebTunnel"
appended on those lines. A second origin in one country is "DE 2". Names are percent-encoded
in the links, so any script and emoji survive.
The numbers count the routes in this body, not the nodes of the network. A route that
joins or leaves the body renumbers the other lines of its country, with no change to your
settings: DE becomes DE 2 when an older DE origin that was left out (offline, full, out
of time) is started on a later fetch, and DE 2 becomes DE when the older route's session
ends and a fetch cannot start it again. The olcRTC lines are named after the same exits. Apps key their lists on names, so a
rename can show up as a duplicate or a missing entry until the next refresh.
Provisioning on fetch. The first fetch starts a session for every route your filters
keep that the subscriber does not hold yet. A fetch spends at most about 5 seconds on this;
routes not started in time are started by the next fetch. A route that cannot be started now
is left out and the rest is returned; the body is never an error because of one route. Any
connect refusal leaves a route out. The usual reasons: its node's agent is offline, the
node is full or overloaded, the subscriber already holds 50 sessions, your balance does not
allow a new session, or Torrent Guard ended the subscriber's session on that exit in the
last hour (TORRENT_GUARD_COOLDOWN). A prepaid partner at zero balance gets no new routes
(not even a route priced at 0: a fetch asks your balance once for all of them), so a new
subscriber's body is empty until you top up. A subscriber past their quota gets no lines
at all — nothing is provisioned and every serving path (the hosted URL, your own
domain, the resolver) answers an empty body whose header still carries the quota's
figures, used past total, so the user's app shows the traffic as used up rather than
servers that cannot connect.
Stable while nothing changes. Later fetches reuse the sessions and return the same
lines; only the traffic figures (upload/download in json, and the
subscription-userinfo header) change as traffic moves. A line's credential lives as long
as its session. When a session ends (your balance reached zero, the node was offline for 30
minutes, Torrent Guard ended it, a reset or a disable), the next fetch makes a new session
on that route with a new credential; the app picks it up on its next refresh. An active
subscription line is not closed for being idle, with one exception: on a node ProofKit has
paused, a line that has carried nothing for two hours is closed and the route is not made
there again (see the catalogue). A pending one (its node never confirmed
it) that has carried nothing is closed once it is an hour old, and the next fetch makes it
again. A change to your subscription settings shows in each subscriber's body on its next
fetch. Line names can still change between fetches: see the numbering above.
Route filters
Three settings narrow every subscriber's body at once. You set them in the Partner page's Subscription tab (see Subscription settings); with none set, the body carries everything.
| Setting | Values | Default | The body keeps |
|---|---|---|---|
sub_exit_countries | 1 to 64 two-letter country codes in upper case (ISO 3166 alpha-2), none twice, e.g. ["DE", "NL", "FR"], or null | null, every exit | The routes whose exit is in one of these countries |
sub_entry | all, direct or via_edge | all | direct: only the routes straight to an exit. via_edge: only the routes through an entry edge, such as … via RU. all: both. |
sub_transports | A non-empty list of vless_reality, hysteria2, vless_xhttp and vless_webtunnel (Since revision 1.8), none twice, or null | null, the first three | The lines over these transports, on every route kept |
- These are the defaults. A subscriber with
exit_countriesof its own follows that list in place ofsub_exit_countries;sub_entryandsub_transportsapply to every subscriber. nullis Reality, Hysteria2 and XHTTP, and never WebTunnel. A partner that never touchedsub_transportsgets the lines it always got. WebTunnel comes only when the list namesvless_webtunnel, whichever others it names.- olcRTC is not a transport here. The olcRTC switch governs olcRTC lines, and they come
only for exits in the body (see olcRTC in the subscription).
An exit the filters leave out gets no olcRTC line, and neither does an exit left with no
route, for example an exit with no entry linked to it under
via_edge. While no olcRTC price is in force (olcrtc_pricingonGET /), olcRTC traffic is charged at the price of the subscriber's direct route to the exit when it holds one. Underaccount via_edgethe subscription gives it none, so the price is that of its route through the entry, unless the subscriber holds a direct session on that exit anyway: one fromconnect, or the direct session the olcRTC call makes on an exit where the subscriber held no session (step 5 ofPOST …/). While that session lives, its olcRTC traffic is charged at the direct price. With an olcRTC price in force, olcRTC traffic costs that price whatever the route.olcrtc - Codes are not checked against the network. A code with no exit behind it, such as
ZZ, is accepted and keeps nothing. The catalogue lists the countries there are. - A route left out is never started. A fetch makes no session for it and sends its node no credential.
- The transport filter removes lines, not routes. Every route the exit and entry filters keep is still started, with the one credential its lines share; the lines of the transports you left out are simply not in the body.
- A change reaches each subscriber on its next fetch. Saving sends nothing to the nodes
and ends no session. A route a subscriber already holds that the new filters leave out
drops out of its body on the next fetch. If its session has never carried traffic, it is
closed once it is an hour old, like any unused session. If it has, it stays open and its
credential keeps working until the session ends for another reason, although a refreshed
app no longer lists it; end it now with
DELETE …/(the ids are insessions/ {session_id} GET …/). Widening the filters again while that session lives brings back the same session and the same line.sessions - Names are numbered over the routes in the body, so narrowing a filter can rename a
line, for example from
DE 2toDE, and so can a route that is left out on one fetch and started on the next (see What it contains). - When the filters keep nothing, the answer is still
200with the usual headers and an empty body:""forhappandghostlane,{"entries": [], "upload": N, "download": N}forjson. - Only the subscription reads them: this endpoint and the hosted URLs.
connectand the catalogue ignore them. The olcRTC door and a session's configs apply the exit filter only, and only when they make a new olcRTC place for a subscriber (seePOST …/).olcrtc
WebTunnel
Since 1.8A route can carry a fourth line, vless_webtunnel: VLESS inside HTTPUpgrade, over TLS, to a
name that looks like an ordinary website. To a network it looks like a visit to that website,
which is why it is for networks that cut the other lines. Through an entry the name is on a
domain that the entry's operator controls. Straight to an exit
(since revision 1.11) it is on a domain that ProofKit
controls, at the exit's own address.
- It is off until you list it.
sub_transports: nullmeans Reality, Hysteria2 and XHTTP, as it always did, and never WebTunnel. The line comes only when the list namesvless_webtunnel: in the dashboard, the WebTunnel box of the Subscription tab, which is shown once an entry or an exit you can sell serves the line (and, once ticked, until you untick it). - Only where the route can serve it now. A route through an entry has it while the entry's
operator has set the link up and everything that makes it work is working. A route straight to
an exit has it (since revision 1.11) while the exit
serves its own line; not every exit does. When the route cannot, it is a body without the
fourth line, and the line comes back by itself. The catalogue says which nodes can:
vless_webtunnelin theirsupported_transports(seeGET /). Your entry setting decides which kinds of route your list keeps in the first place, socatalogue directkeeps the exits' lines andvia_edgekeeps the entries'. - Where it comes. The
happandjsonformats ofGET …/, and the hostedsubscription /sub/URL. In<sub_token> jsonit is an entry with"transport": "vless_webtunnel"and aremarkending· WebTunnel. No list for the Ghostlane app carries it: neither theghostlaneformat of that call nor the hosted/sub/URL.<sub_token>/ unified /olcrtcis olcRTC only. - It is the route's own session. The line carries the credential of the session the route's other lines carry, at the same price, with the same quota, expiry and device limit.
- Everything else is as published.
GET …/still lists three transports and the bundle of those three, and theconfigs transportofconnectignores"vless_webtunnel"as it ignores every value but"hysteria2". - The line is a secret. Its path opens the tunnel, as a Reality key does for a Reality line: do not log it or publish it.
- Apps. It is an ordinary
vless://link withtype=httpupgradeandsecurity=tls: the app needs a core that runs that transport.
Headers and branding
| Header | Value |
|---|---|
profile-title | base64: + base64 of your title, so any script survives. Omitted when no title is set. |
profile-update-interval | Hours between the app's refreshes, 1 to 168; 1 unless set. |
support-url | Your support link. Omitted when not set. |
profile-web-page-url | Your web page. Omitted when not set. |
subscription-userinfo | upload=<bytes>; download=<bytes>; total=<bytes-or-0>; expire=<unix-or-0>. Without a quota: this calendar month's traffic (UTC), total=0. With a quota_bytes: the quota's traffic since it was set, and total= the quota itself — the user's app renders "x of your plan used" natively. expire is the subscriber's expires_at in Unix seconds when it has one (since revision 1.5), and 0 when it has none: then durations stay yours. |
Cache-Control | no-store |
Content-Disposition | inline |
The per-key X-RateLimit-* headers are also present, with the limit 1200.
Since revision 1.7, an answer that judged the device of a
subscriber with a device limit also carries subscription-always-hwid-enable: 1, and a fetch
the limit refused carries x-hwid-active, x-hwid-max-devices-reached or
x-hwid-not-supported, x-hwid-limit, announce and announce-url: see
Subscriber device limit.
You set the branding in the Partner page's Subscription tab (see Subscription settings), or ProofKit sets it for you on request. With none set, the three optional headers are left out and line names carry no prefix. The limits:
| Setting | Limit |
|---|---|
Title (sub_title) | Up to 64 characters, no control characters. Any script and emoji. An empty title sends no header. |
Line-name prefix (sub_remark_prefix) | Up to 32 characters, no control characters, none of #, ?, &, $. An empty or blank prefix means none. |
Support URL, web page URL (sub_support_url, sub_web_page_url) | Up to 512 characters: https:// in lower case, then visible ASCII only, with no spaces and no letters outside ASCII (a https://t.me/ link is fine). Leave it blank for none. |
Refresh interval (sub_update_hours) | A whole number of hours, 1 to 168. |
Subscription settings
The Partner page (#/, signed in as the owner) has a Subscription tab, "branding
& filters". It holds:
- a line that says whether subscriptions are on for your partner (read-only);
- profile title, support URL, web page URL, line-name prefix and update interval, hours: the branding above, blank for none;
- setup page: on or off — whether a subscriber's link, opened in a browser, shows a page that says how to connect (see Hosted subscription URLs). It is on unless you turn it off;
- fixed exit address: on or off — whether your subscribers leave our servers from the server's own address (see Fixed exit address). It is off unless you turn it on;
- exit countries: comma-separated codes such as
DE, NL, upper-cased as you type; blank keeps every exit; - entry: All, Direct only, or Via entry edge only;
- transports: Reality, Hysteria2, XHTTP and WebTunnel. The first three checked and WebTunnel not is the same as no filter; WebTunnel is off until you tick it, and is offered once an entry or an exit you can sell serves the line, or while it is ticked (since revision 1.8; see WebTunnel); at least one must be checked;
- apps that do not name the device: "get no list" (
strict, the default) or "get the list and are not counted" (soft), for a subscriber with a device limit (see Subscriber device limit); - Save, and under it a preview of the first line names.
ProofKit sets, and the tab does not edit: whether subscriptions are on, whether olcRTC is on,
and your olcRTC share. The page shows olcRTC as a figure, "in rooms / your share" (your
subscribers' keys that count as present, see olcRTC under
GET /), or off. Ask us to change any of the three.
A save changes your settings and ends no session. Each subscriber's next fetch serves the new settings (see Route filters for a route that leaves the body); a change of the fixed exit address also reaches the servers your subscribers are on within a minute. You can save before subscriptions are switched on.
Fixed exit address
Off, a server sends its users' web traffic (TCP) out through a shared network and their other traffic (UDP) from its own address, so a site can see two addresses for one customer, and the first can change. That means fewer captchas for most customers. Turned on, for all your subscribers:
- All their traffic leaves from the server's own address.
- Only our own servers keep a fixed address: a country that only other operators' servers cover is not in your subscribers' bodies while it is on.
- Each subscriber keeps one server in each country, and its body lists only that server: the one it used last. New subscribers spread over the servers of a country.
- Busy servers do not move these subscribers. A subscriber is given another server of its country only when its own cannot take it when the app fetches the body: down, full or out for maintenance. So the address still changes then, when you change a subscriber's countries, and when you turn this off and on; the first fetch after you turn it on can move a subscriber once.
- While a subscriber's server is down, that country does not work for it: after 30 minutes, the app's next fetch gives it another server of the country, if there is one.
- Some sites show captchas more often to a server's address.
- It applies to new connections within a minute; connections already open keep their path until they close. No session ends.
Turn it on when your customers' accounts (Apple ID, a bank) check the address they sign in from. It is opened partner by partner: ask us in a ticket when the dashboard does not show it.
The preview lists up to 5 line names a subscriber would get under the saved settings,
for example My VPN DE Direct. It makes no session. It does not check whether a node is
online or full, whether a route carries Hysteria2 or XHTTP, or what one subscriber was
granted on top of your nodes, and it numbers over every route you may sell, so a real body
can say DE where the preview says DE 2. With WebTunnel listed it names that line only on a
route that serves one at this moment: through an entry that serves it, or straight to an exit
that serves its own. It shows no olcRTC lines. "No route
matches these filters yet." means the filters keep nothing.
Behind the tab is the dashboard's own endpoint, GET and
PUT https://proofkit.org/. Like the rest of the
dashboard it takes the owner's signed-in session (Authorization: Bearer …) with two-factor
sign-in on (see the dashboard API); the Partner API key
does not work there. The GET answer:
{
"sub_exit_countries": ["DE", "NL", "FR"],
"sub_entry": "all",
"sub_transports": null,
"sub_title": "My VPN",
"sub_support_url": "https://t.me/myvpn_support",
"sub_web_page_url": null,
"sub_remark_prefix": "My VPN",
"sub_update_hours": 1,
"sub_setup_page": true,
"sub_device_mode": "strict",
"sub_fixed_egress": false,
"sub_enabled": true,
"olcrtc_enabled": true,
"olcrtc_share": { "share": 26 },
"preview": [
"My VPN DE Direct",
"My VPN DE Direct · Hysteria2",
"My VPN DE Direct · XHTTP",
"My VPN DE via RU",
"My VPN DE via RU · Hysteria2"
]
}
- The first eleven fields are yours: the filters, the branding, the setup page's switch, the
device mode and the fixed exit address.
sub_enabled,olcrtc_enabledandolcrtc_shareare ProofKit's.olcrtc_shareisnullwhile olcRTC is off, otherwise the share that applies: the one agreed with you, or the default. It binds only whilesub_enabledistrue; before that the olcRTC call and configs are bounded by the rooms alone, and the figure is for information. PUTtakes any of the eleven as JSON. A field left out is unchanged;nullclearssub_exit_countries,sub_transports,sub_title,sub_support_url,sub_web_page_urlorsub_remark_prefix;sub_entry,sub_update_hours,sub_setup_page,sub_device_modeandsub_fixed_egresscannot benull.{}changes nothing. The answer is theGETview after the write, with a fresh preview.- A value outside the limits answers
400 INVALID_FILTER(the three filters),400 INVALID_DEVICE_MODE(asub_device_modeother thanstrictorsoft) or400 INVALID_BRANDING(the rest), with a message that names the field, and nothing is saved. So does an empty support or web page URL (""): sendnullfor none. A list that names a country or a transport twice is400 INVALID_FILTERtoo ("… is listed twice"). - Naming
sub_enabled,olcrtc_enabledorolcrtc_max_concurrent(the stored share) with a value, orolcrtc_max_concurrentasnull, answers400 FIELD_NOT_EDITABLE, and nothing in that body is saved.sub_enabled: nullandolcrtc_enabled: nullare a plain-text422, as areolcrtc_share,previewand any other unknown field, a wrong type, andnullwhere it is not allowed. So do not send theGETview back as it is. - At most 30 writes per partner an hour. The count is taken before the body is checked, so
a write refused with a
400(FIELD_NOT_EDITABLE,INVALID_FILTER,INVALID_BRANDING,INVALID_DEVICE_MODE) uses one up, as does a write that changes nothing; a plain-text422does not. The next answers429 RATE_LIMITED, with noX-RateLimit-*headers. Each Save in the tab sends all ten fields and is one write. - Signed out, the answer is
401 UNAUTHORIZED; the other dashboard refusals (404 NOT_A_PARTNER,403 TWO_FACTOR_REQUIRED,409 BILLING_NOT_CONFIGURED) are listed in Payments.
Serving it under your domain
Proxy this endpoint. Your server answers its own URL (for example
https://sub.example.com/), maps it to the subscriber's external_id, calls
GET …/ with your key, and returns our body
and the headers above: the app reads both. Allow for the fetch taking a few seconds
while routes are provisioned. The URL, its token and its secrecy are yours; we never see
it.
Pass the body on unchanged. The headers are yours to rewrite: to show your own plan in the
app, set total= (bytes) and expire= (a Unix time) in subscription-userinfo and keep
our upload= and download=; any other header, such as the title or the refresh interval,
you may also replace.
Since revision 1.7, forward the app's own request headers
with each call: x-hwid, x-device-os, x-ver-os and x-device-model as they came, and its
user-agent in place of your HTTP client's. The subscriber's device limit judges the device
they name, as at the hosted URLs, and the app reads the answer's device headers, so pass those
back as well. With a limit set and no x-hwid forwarded, a strict partner's call is refused
as one from an app that names no device. See
Subscriber device limit.
At scale. The subscription budget is 1,200 fetches a minute per key, the same for every
partner: about 72,000 fetches an hour, which carries about 72,000 subscribers whose apps
refresh every hour, and six times as many at a profile-update-interval of 6 hours. As you
approach it, raise the refresh interval in the Subscription tab, and write to us before you
depend on more. Your proxy may keep a subscriber's body for a few minutes and serve it again
(our Cache-Control: no-store is for the apps): while nothing changes, a fetch returns the
same lines. Drop that copy when you reset or disable the subscriber, and expect the traffic
figures in subscription-userinfo to lag by as long as you keep it.
Your domain, our serving (managed). If running a proxy is not for you, we serve your
hostname ourselves: add one DNS record — sub.yourdomain.com CNAME subs.proofkit.org, a
plain record, DNS-only (not proxied) — request the domain on the Partner page, and once we
approve it, your hosted URLs answer on it within minutes, with our certificate. Free once
you are out of the trial; up to three domains, so a blocked one can be replaced at once.
GET /'s hosted_base names the base your links ride on while a domain is active.
A CNAME straight at proofkit.org still does not work — the record above is the one route.
olcRTC in the subscription
olcRTC lines appear only in ghostlane and json, only when ProofKit has enabled olcRTC
for your partner, only for exits in the body, and only for carriers the node serves right
now.
The rules below (places, the share, your part of each room) rest on personal room keys, one per subscriber, which is how the network runs. ProofKit can fall back to one shared key per node; while it does, nothing is minted or seated, every exit of the body gets its olcRTC lines on that key, no share applies, and the olcRTC call and configs behave as they do without subscriptions.
The same rules bind POST …/ and GET …/ once subscriptions are switched on
for your partner: the olcRTC switch, your share, one place per subscriber, and a new place
only on an exit your filters keep (see
POST …/ and
GET …/).
- The subscription issues a subscriber a new olcRTC key on one origin of their body, with one line per carrier there (Telemost, WB Stream, MTS Link, SaluteJazz, VK Calls), all on the same key. The origin is chosen per subscriber, so subscribers spread across origins and one keeps its place.
- One place per subscriber, whichever call made it. A subscriber that got its key from
POST …/orolcrtc GET …/holds its place there, and the subscription makes no second one. Every origin of the body where the subscriber holds a live key gets its lines.configs - Your share. A subscriber is given a new key only while fewer of your subscribers' keys count as present than your share allows: the larger of a fifth of all olcRTC slots in the network (rounded up) and 8 plus one for every 15.625 GiB of olcRTC traffic your subscribers moved in the last 30 days, or a number agreed with us. A key counts as present, the same way it takes a slot on its node, while any of its holder's sessions on that exit has moved traffic in the last 5 minutes, on any transport: a subscriber using that exit's Reality, Hysteria2 or XHTTP lines, direct or via RU, uses part of your share without ever opening olcRTC. The share counts keys, not subscribers: a subscriber holding keys on two exits (for example one from before subscriptions were on) counts on each where it is present. On one node you may hold only your part of its keys: a fifth, rounded up, at the default share (7 of 32 today), more in proportion as your olcRTC traffic earns you a larger share, never more than half (see Your share).
- A subscriber who already holds a key keeps their lines. When a new key cannot be given (share reached, no node with a place), the olcRTC lines are left out and the rest of the body is returned.
- A place on an exit your filters drop later stays while the subscriber has any live
session on that exit, and the body then carries no olcRTC line at all: the place still
counts, so no second one is made.
POST …/andolcrtc GET …/still answer from it. A dropped route's session that has carried traffic is not closed for you (see Route filters), so this can last. To move the subscriber's olcRTC to an exit you keep: listconfigs GET …/, find through the catalogue the sessions whosesessions origin_idis the dropped exit, and end each withDELETE …/. The key is revoked at the next room-health pass, about 5 minutes later, and the nextsessions/ {session_id} ghostlaneorjsonfetch seats the subscriber on an exit you keep, within your share. - Lines are named
"<prefix> <exit> · olcRTC", with" · WB"," · MTS"," · SJ"or" · VK"appended for WB Stream, MTS Link, SaluteJazz and VK Calls.
Hosted subscription URLs
Each subscriber also has hosted URLs, authorised by sub_token alone (no key). They ride
on your managed domain while one is active (hosted_base, the Partner page shows both),
and on proofkit.org otherwise — the same token, the same bodies, either host:
| URL | Body, once subscriptions are switched on for your partner |
|---|---|
https://proofkit.org/ | The happ body, with your headers |
https://proofkit.org/ | The ghostlane body, with your headers: like it, never with a WebTunnel line, whatever you listed (Since revision 1.8) |
https://proofkit.org/ | Only the olcRTC lines of the ghostlane body, without your headers: no title, support or web page link, no subscription-userinfo, and a refresh interval fixed at 12 hours. The device limit's headers are sent here too (since revision 1.7) |
They provision exactly like the S2S endpoint, /olcrtc included: it starts every missing
route of the full body, although it returns only the olcRTC lines. They are limited to 60 requests per minute per
IP, so a proxy fetching many subscribers from one address should call the S2S endpoint
instead. An unknown token, a token replaced by a reset, and the token of a disabled
subscriber all answer 404 ("Invalid subscription token").
Since revision 1.7, every fetch of the three URLs is judged
by the subscriber's device limit, from the headers the app sends itself (x-hwid,
x-device-os, x-ver-os, x-device-model and user-agent), before anything is provisioned,
and a fetch it refuses gets the refusal's answer: see
Subscriber device limit. The setup page below is not judged:
opening the link in a browser is no device.
/unified and /olcrtc also accept ?crypt=1, which Ghostlane uses to receive the body
encrypted in a form only it reads. Other apps should not send it.
Opened in a browser, the link is a setup page. /sub/ — the link you hand a
subscriber — answers a request whose Accept header names text/ with a page instead
of the list: your profile title, the subscriber's end date and traffic, the device limit when
one is set, an app to install (Ghostlane, an open-source client, with its store links), one tap
that adds the list to it, the link to copy for Happ, Hiddify, V2Box or another app, and your
support link. A subscriber past its end date or its quota reads so there, and to ask you. The
page's words name neither ProofKit nor the subscriber's external_id; it starts no session,
issues no key and writes nothing, and it is never cached or indexed. Its links stand on your
managed domain while one is active, and on proofkit.org otherwise.
The header decides, never the user agent. Apps do not ask for HTML, so Happ, Hiddify,
v2rayNG, Ghostlane and a script that sends Accept: */, or no Accept at all, get the
list they got, byte for byte. /unified and /olcrtc never answer a page, and an unknown,
reset or disabled token is the same 404 whatever is asked for. If something of yours
fetches /sub/ and says it accepts text/, have it ask for */, or turn
the page off: setup page in the Subscription tab (sub_setup_page, see
Subscription settings). Off, a browser gets the list's text again
at the next request. Before subscriptions are switched on for your partner there is no page.
Before subscriptions are switched on for your partner, the three URLs serve our standard
lists, unbranded, built from the sessions the subscriber already holds (from connect or
POST …/). They serve the same lists again if ProofKit later switches subscriptions
off for you, or suspends your partner. No device limit applies to them, and no session is
started:
/sub/: base64 VLESS Reality lines, one per held session. When olcRTC is on for your partner, building it issues the subscriber an olcRTC key on every olcRTC node where they hold a session, outside your share; if any such node gives a line, the list also ends with one local<sub_token> socks://127.0.0.1:10808#olcRTCline./sub/: plain lines: Reality, Hysteria2 and XHTTP of every held session, then olcRTC lines for every olcRTC node where they hold a session. It issues those keys whether or not olcRTC is on for your partner, outside your share.<sub_token>/ unified /sub/: only those olcRTC lines, issuing keys the same way.<sub_token>/ olcrtc
GET /subscribers/{external_id}/devices
Since 1.7The devices on record for the subscriber, the one seen last first: each app install that
named itself with x-hwid when it fetched the subscription, at a hosted URL or through
GET …/. What a device is, and how long
one is kept: Subscriber device limit.
GET /subscribers/user-42/devices
X-Partner-Key: pk_…
[
{
"device_id": "5f0c2e9a…",
"os": "iOS",
"os_version": "18.1",
"model": "iPhone15,2",
"app": "Happ",
"first_seen_at": "2026-10-12T08:14:03Z",
"last_seen_at": "2026-10-19T07:55:41Z",
"holds_slot": true,
"removed_at": null
},
{
"device_id": "0a7e3c5b…",
"os": "Android",
"os_version": "14",
"model": "SM-A546B",
"app": "v2RayTun",
"first_seen_at": "2026-10-03T19:20:11Z",
"last_seen_at": "2026-10-17T21:02:36Z",
"holds_slot": false,
"removed_at": "2026-10-17T20:58:00Z"
}
]
| Field | Meaning |
|---|---|
device_id | Our name for the device: a hash of the app's device id, keyed by your partner, in lower-case hex (64 characters). Never the app's id, which we do not keep. The two calls below take it. |
os, os_version, model | What the app sent in x-device-os, x-ver-os and x-device-model: its printable ASCII, cut to 64 characters. null when it never sent one; a later fetch that leaves a header out keeps what an earlier one said. |
app | The app, by the first word of its user-agent, up to the first / or space: Happ, v2RayTun. olcbox reads Ghostlane. null with no user agent. |
first_seen_at | Its first recorded fetch. |
last_seen_at | Its last fetch, to within 10 minutes. A refused fetch of a removed device counts too, so you can see that it tried. |
holds_slot | It counts toward device_limit now: not removed, and seen within the last 30 days. |
removed_at | When you removed it, or null. |
A subscriber with no device on record answers []. One that is not yours, does not exist or
is disabled answers 404 NOT_FOUND ("Subscriber not found"): enable it again with
POST / to read its devices.
DELETE /subscribers/{external_id}/devices/{device_id}
Since 1.7Removes a device, whatever the subscriber's limit. Returns 204; calling it again also
returns 204, and the device keeps the time of its first removal.
- From its next fetch the device gets the refusal ("this device was removed from the link"),
and an app that refreshes the list drops its servers (Happ refreshes every
profile-update-intervalhours). - Its slot is free at once: another device can take it at its next fetch.
- It stays on the list, with
removed_at, and stays refused, with a limit or without, until you let it back withPOST …/.devices/ {device_id}/ allow - Nothing is ended on a node. Every device of the subscriber connects with the same credentials, so the device keeps working until its app refreshes the list; if its lines were copied out of the app, only a reset stops them.
| Code | Status | When |
|---|---|---|
DEVICE_NOT_FOUND | 404 | The subscriber has no device by that device_id ("No such device.") |
NOT_FOUND | 404 | A subscriber that is not yours, does not exist, or is disabled |
POST /subscribers/{external_id}/devices/{device_id}/allow
Since 1.7Lets a removed device fetch the subscription again. No body. Returns 204.
- A device seen within the last 30 days takes a slot again. While every slot is held the call
answers
409 DEVICE_LIMIT("The subscriber is at its device limit: remove another device or raise the limit."), writes nothing, and the device stays removed. - A device silent for longer comes back without a slot, and is judged like a new device at its next fetch: served if a slot is free, refused if not.
- A device that is not removed is let back already:
204, and nothing changes.
| Code | Status | When |
|---|---|---|
DEVICE_LIMIT | 409 | Let back, the device would hold a slot past the subscriber's limit |
DEVICE_NOT_FOUND | 404 | The subscriber has no device by that device_id |
NOT_FOUND | 404 | A subscriber that is not yours, does not exist, or is disabled |
POST /subscribers/{external_id}/connect
Starts one session for the subscriber, or returns the one they already hold on that route. It runs the same code a signed-in ProofKit user's connect runs: node access, your price, the payer's balance and node capacity all apply the same way.
| Field | Meaning |
|---|---|
exit_country | Two-letter country to exit from, e.g. "DE", matched exactly against the catalogue's country. We pick the least-loaded active origin there among those approved for automatic routing, skipping one we recently failed to send a command to and one that is overloaded (over a load threshold, its user limit included, in 3 of its last 5 checks). The pick does not check that the node's agent is connected right now: see A node that is offline below. |
origin_id | An exact origin from the catalogue, instead of a country. |
service_id | Legacy, from before the catalogue; do not use it. It is still accepted, which is why MISSING_PARAM's message names it. |
entry_country | Optional entry country, e.g. "RU": we pick an edge there, approved for automatic routing and linked to the chosen origin. If none is available, the session is created direct, without an error; check edge_id in the response. |
edge_id | Optional exact edge. With exit_country, the origin is chosen among those linked to it. |
transport | Only "hysteria2" changes anything: the route must carry Hysteria2, or the call answers TRANSPORT_NOT_SUPPORTED. Any other value ("auto", "vless_reality", "vless_webtunnel", "olcrtc", a typo) is ignored. |
country | Legacy alias of exit_country. |
One of exit_country or origin_id is required; any field may be null, which counts as
absent. When several name the same thing, one wins and the others are ignored without an
error: service_id over origin_id over exit_country, exit_country over country,
and edge_id over entry_country. A session carries every transport its
route supports whatever transport says; GET …/ lists them. When you name both
origin_id and edge_id, we do not check that the edge is linked to that origin; to get
a working two-hop route, name the edge with exit_country, or use entry_country.
A subscription adopts it. Once subscriptions are on for your partner, the subscriber's next subscription fetch lists a session made here, if your filters keep its route, and takes it over as that route's subscription line: from then on it is not closed for going unused (see What it contains).
{ "exit_country": "DE", "entry_country": "RU" }
{
"session_id": "a51c…",
"sub_token": "b7f3…",
"origin_id": "0f2a…",
"edge_id": "17e6…",
"total_price_per_gb_nano": 24000000,
"platform_fee_nano": 4000000
}
total_price_per_gb_nano is the session's price per GB, the network fee included, locked
when the session started: a later rise in a node's price does not reach it, a drop does
once the lower price has stood for 3 hours (see Price under the catalogue). A repeated
connect that returns the session returns its current price: the lowered one only once the
drop has reached it. platform_fee_nano is the fee part of that price; once a drop has
reached the session it is recomputed with the total.
One session per route. If the subscriber already holds a live session on the resolved
route (the same origin, and the same edge or none), that session is returned. That happens
after the disabled-subscriber check, the choice of origin and edge (MISSING_PARAM,
NOT_FOUND, FORBIDDEN for a private node) and the Hysteria2 check
(TRANSPORT_NOT_SUPPORTED), and before the session cap, the named origin's load and user
limit, the Torrent Guard cooldown and the balance check. With exit_country alone, routing
may pick a different origin next time, which opens a second session on another route.
Node selection ignores olcRTC. transport: "olcrtc" is ignored, and a session on a node
without an olcRTC room has no olcRTC lines. Use
POST …/, or name an origin_id whose
olcrtc_carriers is not empty.
A node that is offline. A session on a node whose agent is not connected is created as
pending and carries nothing yet. When the agent reconnects, its credential is sent and the
session becomes active. If the node stays offline for 30 minutes, the session is closed.
exit_country routing checks the node's status, recent send failures and smoothed load, not
the live agent connection, so it can pick an origin whose agent dropped in the last few
minutes. Check agent_online in the catalogue before naming a node, and after a connect by
country look up the returned origin_id (and edge_id) there. AGENT_OFFLINE is returned
only when the command could not be sent at all.
Refusals:
| Code | Status | Meaning |
|---|---|---|
MISSING_PARAM | 400 | Neither exit_country nor origin_id was given |
QUOTA_EXHAUSTED | 403 | The subscriber's quota is used up; renew it with PATCH first |
SUBSCRIPTION_EXPIRED | 403 | The subscriber's end date has passed; renew it with PATCH first |
TRANSPORT_NOT_SUPPORTED | 400 | transport: "hysteria2" on a route that does not carry it |
SESSION_LIMIT | 400 | The subscriber already holds 50 live sessions |
NODE_OVERLOADED | 400 | The named origin is refusing new sessions on its live load |
NODE_AT_CAPACITY | 400 | The named origin has reached its user limit |
TORRENT_GUARD_COOLDOWN | 400 | Torrent Guard ended this subscriber's session on this origin in the last hour; GET / says why |
INSUFFICIENT_BALANCE | 400 | Your balance does not allow a new session (see GET /) |
AGENT_OFFLINE | 400 | The command to the node could not be sent |
FORBIDDEN | 403 | The subscriber is disabled, or the node is private and not granted to you |
NOT_FOUND | 404 | Unknown subscriber ("Subscriber not found"); the named origin or edge is not active ("Specified origin node not found or not active", "Specified edge node not found or not active"); or no origin in exit_country can take a session right now: "No active origin nodes available for country 'XX'", or, with edge_id, "No accessible origin in 'XX' is linked to this edge. Try a different entry, or ask the operator for access." |
NODE_OVERLOADED and NODE_AT_CAPACITY come only when you name origin_id: with
exit_country such nodes are skipped, and a country with none left answers 404. For
NODE_OVERLOADED, NODE_AT_CAPACITY, AGENT_OFFLINE and that 404, choose another node
or country rather than retrying the same one.
GET /subscribers/{external_id}/sessions
The subscriber's active and pending sessions, newest first, with the ids the configs
call needs. Closed sessions are not listed; a subscriber with none returns [].
[
{
"session_id": "3d91…",
"status": "active",
"origin_id": "0f2a…",
"edge_id": null,
"price_per_gb_nano": 12000000,
"bytes": 1073741824,
"bytes_up": 214748364,
"bytes_down": 858993460,
"started_at": "2026-09-26T09:12:00Z"
}
]
price_per_gb_nano here is the session's current locked price with the network fee:
what connect returned as total_price_per_gb_nano, or lower if a node on the route has
dropped its price since. (In the catalogue, the same field name is the price before the
fee.)
bytes is the session's cumulative total and equals bytes_up + bytes_down. bytes_up
is what the subscriber sent (client to internet), bytes_down what they received.
These are live counters for the whole session; for a time window use /usage.
DELETE /subscribers/{external_id}/sessions/{session_id}
Ends one session and leaves the subscriber and their other sessions alone. The session's
credential is removed from its node at once (what that does to connections still open: see
DELETE /). The subscriber's
olcRTC key on that node belongs to the subscriber, not to the session: it drops out of the
room at the next room-health pass (about 5 minutes later, sometimes longer) only if they
have no other live session on that node. A subscription holds the direct route and every route through an edge
to each origin, so ending one of those sessions usually leaves the key live. Returns 204,
also when the session
was already closed. A session that is not this subscriber's answers 404 ("Session not
found").
A route in a subscription whose session you end is provisioned again, with a new credential, on the subscription's next fetch.
GET /subscribers/{external_id}/sessions/{session_id}/configs
Every transport of one session, with QR codes unless you pass ?qr=false.
{
"session_id": "3d91…",
"sub_transport": "vless_reality",
"sub_token": "b7f3…",
"subscription": "dmxlc3M6Ly8…",
"olcrtc_url": "olcrtc://telemost?vp8channel@https://telemost.yandex.ru/j/…#<key>$DE · olcRTC",
"olcrtc_urls": [
{ "carrier": "telemost",
"url": "olcrtc://telemost?vp8channel@https://telemost.yandex.ru/j/…#<key>$DE · olcRTC" },
{ "carrier": "wbstream",
"url": "olcrtc://wbstream?vp8channel@<room>#<key>$DE · olcRTC · WB" }
],
"transports": [
{ "transport": "vless_reality", "label": "VLESS Reality",
"eligible": true, "url": "vless://…" },
{ "transport": "hysteria2", "label": "Hysteria2",
"eligible": true, "url": "hysteria2://…" },
{ "transport": "vless_xhttp", "label": "VLESS XHTTP",
"eligible": false, "reason": "transport not offered by this route" }
]
}
| Field | Meaning |
|---|---|
transports | Always three entries in this order: Reality, Hysteria2, XHTTP. Reality is always eligible. An ineligible entry has reason and no url. With QR codes on, each eligible entry also has qr_svg. |
subscription | Base64 of this session's eligible links (Reality, then Hysteria2 and XHTTP where eligible). It is this one session's bundle, not the body of the hosted subscription. |
subscription_qr_svg | QR of subscription; omitted with ?qr=false. |
olcrtc_url | The Telemost olcRTC line, when the session's origin serves Telemost and the subscriber may have a line there (see below). Omitted otherwise. |
olcrtc_urls | One {carrier, url} per carrier the origin serves, Telemost first, all on one key. Omitted when there are none. |
sub_transport | The subscriber account's own mode for its older hosted list; "vless_reality" unless changed. This API has no call that changes it. |
sub_token | As in the subscriber object. |
Link formats:
- VLESS Reality:
vless://<uuid>@<host>:<port>?type=tcp&security=reality&sni=…&fp=chrome&pbk=…&sid=…&flow=xtls-rprx-vision#<name> - Hysteria2:
hysteria2://<uuid>@<host>:<port>?sni=…&obfs=salamander&obfs-password=…&insecure=0&pinSHA256=…#<name> - VLESS XHTTP:
vless://<uuid>@<host>:<port>?type=xhttp&security=reality&encryption=none&pbk=…&sid=…&fp=chrome&sni=…&path=…&host=…&mode=packet-up#<name> - olcRTC: see The olcRTC link.
On a route through an edge, <host>:<port> is the edge's; the Reality keys and SNI are the
origin's.
On an origin that serves olcRTC, this call can reserve an olcRTC key for the subscriber on
that node, as POST …/ does. Do not poll it for sessions you do not hand out. Which
rules apply depends on whether subscriptions are switched on for your partner:
- Without subscriptions, the room's capacity is the only bound: the olcRTC switch and your share do not apply.
- With subscriptions, the olcRTC fields follow the subscription's rules (with personal
room keys; see olcRTC in the subscription). While olcRTC is
off for you,
olcrtc_urlandolcrtc_urlsare absent. A session on the origin where the subscriber holds its olcRTC place gets its lines. On any other origin, this call makes the subscriber's one place there only if it holds none anywhere, the exit is one your filters keep, your share has room and the room can seat it; otherwise the two fields are absent. The rest of the answer is the same either way.
A session that is not this subscriber's answers 404 ("Session not found"); a session
whose origin or entry edge has been suspended (or is gone) answers 404 ("Session has no
reachable node"). A closed
session still returns links, but they no longer work.
POST /subscribers/{external_id}/olcrtc
One call for the per-session olcRTC path: it creates the subscriber if needed, finds or starts a session on a node that serves olcRTC, and returns the lines.
{} // or {"exit_country": "DE"}, or no body at all
{
"session_id": "3d91…",
"origin_id": "0f2a…",
"sub_token": "b7f3…",
"olcrtc_url": "olcrtc://telemost?vp8channel@https://telemost.yandex.ru/j/…#<key>$DE · olcRTC",
"olcrtc_urls": [
{ "carrier": "telemost",
"url": "olcrtc://telemost?vp8channel@https://telemost.yandex.ru/j/…#<key>$DE · olcRTC" },
{ "carrier": "wbstream",
"url": "olcrtc://wbstream?vp8channel@<room code>#<key>$DE · olcRTC · WB" },
{ "carrier": "salutejazz",
"url": "olcrtc://salutejazz?datachannel@<code>:<password>#<key>$DE · olcRTC · SJ" },
{ "carrier": "vkcalls",
"url": "olcrtc://vkcalls?vp8channel@<join link>#<key>$DE · olcRTC · VK" }
],
"slots_total": 16,
"slots_free": 12,
"keys_total": 32,
"keys_free": 25
}
The body is optional: send nothing, {}, or an exit_country. With a body, send
Content-Type: application/; an empty body with that header is a plain-text 400.
A body sent without Content-Type is ignored: the exit_country in it has no effect and
the pick is fleet-wide. A body with any other Content-Type is a plain-text 415.
What it does depends on whether subscriptions are switched on for your partner.
Without subscriptions, the room's capacity is the only bound: the olcRTC switch and the olcRTC share do not apply.
- Provisions the subscriber exactly like
POST /: it creates them if new and re-enables them if they were disabled.subscribers PARTNER_NOT_BILLABLEandTRIAL_LIMITapply. - If the subscriber already holds a live session on an origin that offers an olcRTC room
right now (in
exit_country, when given), returns that session (direct before two-hop, newest first). It does not ask whether that node can seat them: if they hold no key there yet and the node has no free slot or key, the call answersOLCRTC_UNAVAILABLE, and a retry reuses the same session and answers the same. - Otherwise picks an origin you may sell that offers an olcRTC room, whose agent is online and which has a free slot and a free key, preferring the emptiest one, and connects the subscriber there.
With subscriptions, the call follows the olcRTC rules of the subscription, with personal room keys (see olcRTC in the subscription):
- While olcRTC is not switched on for you, it answers
403 OLCRTC_NOT_ENABLEDand changes nothing: the subscriber is not created or re-enabled, and no session or key is made. Only the checks of the request itself come before it: the key (401), a body that does not parse (plain-text400or415) and400 INVALID_EXTERNAL_ID. - Provisions the subscriber as above, creating or re-enabling them. Every
503below comes after this, so the subscriber may already have been created or re-enabled, and a new one counts toward a trial's subscriber cap. - A subscriber that holds its olcRTC place (a live key on an origin that serves a room
now; one per subscriber, whichever call made it) is answered from that origin, when it
offers an olcRTC room now and is in
exit_country(when given). A holder is never refused for your share or a full exit, and your exit filter does not apply to a place already held. Otherwise the answer is503 NO_OLCRTC_NODE, and no second place is made. - A subscriber without a place gets
503 NO_OLCRTC_NODEwhile your subscribers' keys that count as present fill your share. Otherwise the call takes the emptiest exit among the origins you may sell that offer an olcRTC room, have an online agent and room for a newcomer, are on an exit your filters keep, and still have room for one more of your subscribers' keys (your part of the exit's keys, a fifth at the default share and up to half as your olcRTC traffic grows, or one for one in place of a quiet subscriber of yours on an exit nobody is present on). With none, the answer is503 NO_OLCRTC_NODE. Neither refusal makes a session. - On that origin it reuses the subscriber's live session (direct before two-hop, newest
first) or connects a direct one, even when your filters keep only routes through an
entry, and returns the lines. The place held in step 3 is not checked for the node's
state: with no live session there, a node that is not active answers
404 NOT_FOUND("Specified origin node not found or not active") on every call until it is active again or the place is released (POST …/releases it, with everything else the subscriber holds), and a private node whose grant to you was revoked answersreset 403 FORBIDDEN("Access to this private node requires an invitation").
An origin is chosen whatever carriers it serves. Until revision 1.6 only an origin that served
Telemost was, and while no exit served Telemost (18 to 19 October 2026) every call here
answered 503 NO_OLCRTC_NODE.
olcrtc_url is the first line of olcrtc_urls: the Telemost line while the node serves
Telemost, otherwise the next carrier's. olcrtc_urls is one line per carrier the node serves,
Telemost first. Every line is the same node and the same key: another carrier is another
way into the same node, not another location. Hand the app all of them; an app that
understands only one ignores the rest.
An exit may run more than one room of a carrier (two Telemost rooms, for example). Each
subscriber's lines still name one room per carrier, the room its key was given, and keep naming
it while the key lives: the same session gives the same lines on every call. A line changes only
when its room is replaced, as before, when ProofKit changes how many rooms the exit runs, or once
when the exit gets its second room: the key of a subscriber that has not used the exit for an
hour may then be moved into the new room. A moved key keeps its secret, so the line already
handed out keeps working. The next call here or to GET …/, and the subscription's next
fetch, carry the new line, so a bot that stores a line takes it again from one of them. slots_*
and keys_* are the exit's, all its rooms together.
slots_* and keys_* describe the node we just put the subscriber on, read after their key
was issued. keys_free already counts this subscriber's key. Slots count key holders who
are present: any of their sessions on that exit moved traffic in the last 5 minutes, on
any transport. So the subscriber takes a slot as soon as it moves traffic through that
exit, over olcRTC or over any other line of that exit, and not before; slots_free: 0
means every slot was held by a present key holder when we read it.
Idempotent: called again, it returns the same live session.
| Code | Status | Meaning |
|---|---|---|
OLCRTC_NOT_ENABLED | 403 | Subscriptions are switched on for your partner and olcRTC is not ("olcRTC is not switched on for this partner; ask ProofKit to enable it"). Nothing was created or re-enabled. |
QUOTA_EXHAUSTED | 403 | The subscriber's quota is used up; renew it with PATCH first |
SUBSCRIPTION_EXPIRED | 403 | The subscriber's end date has passed; renew it with PATCH first |
NO_OLCRTC_NODE | 503 | No origin offering an olcRTC room (in exit_country, when given) has an online agent and a free slot and key ("No olcRTC-capable origin is available with capacity"). With subscriptions switched on, that also covers origins off the exits your filters keep and rooms where your part is taken, and there are two more cases: your subscribers' keys that count as present fill your share ("Your subscribers in olcRTC rooms fill your olcRTC share; retry when one leaves"; present means traffic on that exit in the last 5 minutes, on any transport), or the subscriber holds its place on an origin outside this request ("This subscriber holds its olcRTC place on an origin outside this request (another exit, or no olcRTC carrier served there right now)"). |
OLCRTC_UNAVAILABLE | 503 | The session's node could not give a line: the subscriber holds no key there and it has no free slot or key (a reused session, or a node that filled between the pick and the key; with subscriptions, also your share filling in between), or its room went away. A session made on the way stays, and is closed an hour after it started if it carries no traffic. Without subscriptions, when a session was reused (step 2), retrying answers the same: pass another exit_country, or end that session (DELETE …/, the id from GET …/). |
INVALID_EXTERNAL_ID | 400 | As in POST / |
PARTNER_NOT_BILLABLE | 400 | As in POST / |
TRIAL_LIMIT | 403 | As in POST / |
NOT_FOUND | 404 | The origin was not active when the session was made: a race after a fresh pick (retry), or, with subscriptions, the origin of a held place (step 5) |
FORBIDDEN | 403 | With subscriptions: the held place is on a private node no longer granted to you (step 5) |
any connect refusal | INSUFFICIENT_BALANCE, SESSION_LIMIT, NODE_OVERLOADED, NODE_AT_CAPACITY, TORRENT_GUARD_COOLDOWN, AGENT_OFFLINE |
NO_OLCRTC_NODE is a normal answer. olcRTC capacity is single digits per node, so
"everything is full right now" is a state your app has to show, not an error to log and
drop. Fall back to another transport for that user and tell them why. Dropping
exit_country widens the search and is worth one retry; for a subscriber that holds its
place on another exit, it lets the call answer from that place. The share case clears when
one of your subscribers' keys stops counting: a key counts while its holder has moved
traffic through that exit in the last 5 minutes, on any of its lines there, Reality
included, not only olcRTC, and each key counts on its own. Beyond that, treat it as "not
available right now".
The olcRTC link
olcrtc://<carrier>?<transport>@<room>#<key>[$<label>]
<carrier>:telemost,wbstream,mtslink,salutejazzorvkcalls.<transport>:vp8channelfor Telemost, WB Stream, MTS Link and VK Calls,datachannelfor SaluteJazz.<room>: what the carrier calls a room, passed through untouched. For Telemost it is a join URL (https://telemost.yandex.ru/), for WB Stream a bare room code, for SaluteJazzj/ … <code>:<password>, for VK Calls the call's join link. Do not parse it as a URL.<key>: the key that admits traffic. Someone who joins the call without it carries nothing through it.<label>: a display name only,"<COUNTRY> · olcRTC"(plus· WB,· SJor· VK), with your line-name prefix in front in a subscription. You may rewrite it; keep the$before it, and keep out of it the characters the line-name prefix refuses:#,?,&,$and control characters. Anything else,@included, is safe: Ghostlane reads the line from the left, so it has found the?,@,#and$that end the earlier parts before it reaches the label.
The room belongs to the node, and can be replaced. Every subscriber on a node shares its
room for each carrier. When a room dies, we replace it and keep the key, so the <room>
part of the line changes. The key belongs to the subscriber, and it ends in two ways:
- The subscriber has no live session on that node any more (you ended it, a disable, the balance reached zero). The key is revoked about 5 minutes later, sometimes longer, and coming back gives a different key.
- The room needed the space. A key that has gone unused for a long while can be taken back when a room at its key budget has a newcomer. This is housekeeping, not a cut-off: the same key comes back the next time you ask for a line, so the line you handed out keeps working.
So cache a line against its session, and fetch it again when the session changes or the line stops working. A subscription avoids this: the app refreshes it.
A session nobody used is closed for you. A session your backend started with
connect or this call that has not carried a byte an hour after it started is closed, and
its node credential is removed. Its room key is revoked at the next room-health pass (about
5 minutes later) only if the subscriber has no other live session on that node; a
subscription subscriber usually has its direct and via-RU sessions there, so the key stays.
Calling this again provisions a new session. Subscription lines are exempt from this while
they are active, and a later subscription fetch that lists the session adopts it as one
(see What it contains).
Ghostlane shows occupancy
Do not write slot counts into the label: they are frozen at fetch time. Ghostlane reads occupancy live from a public endpoint that needs no account and no token of ours:
GET https://proofkit.org/api/v1/olcrtc/status?key_id=<16 hex>
→ { "slots_total": 16, "slots_free": 11, "holds_slot": true }
key_id is the first 16 hex characters of sha256(key), derived from the key inside the
olcrtc:// line, so the key itself never goes over the wire. holds_slot says whether this
key counts as present, and so takes one of the slots: its holder moved traffic through that
exit in the last 5 minutes, on any transport, which is not the same as being in the room. 400 INVALID_KEY_ID for a malformed id; 404 for an
unknown or revoked key or a node without a room. Rate limited to 120 requests per minute
per IP.
You do not need to call it. It is documented so you know where the occupancy your Ghostlane
users see comes from, and that Ghostlane asks proofkit.org for it; apps that do not run
olcRTC never call it. NO_OLCRTC_NODE is the case no app can show on its own, so handle it on
your side.
GET /usage
Traffic and cost per subscriber over a window, for reconciliation.
GET /usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z
{
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z",
"total_bytes": 1073741824,
"total_bytes_up": 214748364,
"total_bytes_down": 858993460,
"total_cost_nano": 12000000,
"total_olcrtc_bytes": 268435456,
"total_olcrtc_cost_nano": 0,
"total_olcrtc_list_cost_nano": null,
"subscribers": [
{ "external_id": "user-42", "bytes": 1073741824,
"bytes_up": 214748364, "bytes_down": 858993460, "cost_nano": 12000000,
"olcrtc_bytes": 268435456, "olcrtc_cost_nano": 0, "olcrtc_list_cost_nano": null },
{ "external_id": "user-43", "bytes": 0,
"bytes_up": 0, "bytes_down": 0, "cost_nano": 0,
"olcrtc_bytes": 0, "olcrtc_cost_nano": 0, "olcrtc_list_cost_nano": null }
]
}
from and to are RFC 3339 timestamps, both optional: to defaults to now and from to
30 days before to. The window is half-open, [from, to), so [1 Sep, 1 Oct) then
[1 Oct, 1 Nov) count nothing twice and miss nothing. from after to answers 400 INVALID_RANGE; a timestamp that does not parse is a plain-text 400.
Every subscriber you created is listed, including those who moved nothing and disabled
ones, ordered by bytes, largest first. There is no pagination.
Each row also carries the subscriber's quota state
(since revision 1.3) — quota_bytes, quota_used_bytes,
quota_remaining_bytes, over_quota. These read the quota's start, not your window:
quota_used_bytes is everything metered since the quota was set, whatever from/to
asked, so a quota check polls the report without widening its window. With no quota set,
quota_bytes and quota_remaining_bytes are null and over_quota is false.
The figures are the metered intervals written as traffic is charged, not session counters:
a session that started before the window contributes only what it moved inside it.
Intervals are written per active session about once a minute, whenever at least 1 MiB
moved. cost_nano is copied from the charge itself, so the report agrees with your
balance. Each interval's cost is rounded down to a whole nanoTON, so for charged traffic a
window's cost is at most bytes × price, short of it by less than a nanoTON for each interval
written (for olcRTC bytes charged at the olcRTC price, see olcRTC below).
cost_nano is 0 for traffic ProofKit covers (the exempt plan) and for traffic inside an
active free trial. The byte counts are exact either way.
Direction. bytes_up is what the subscriber sent, bytes_down what they received, and
bytes_up + bytes_down == bytes for every interval metered since direction was recorded
(August 2026). Older intervals report less direction than total rather than an estimate.
Both come from the same counters that are billed: XRay's per-user counters, and the olcRTC
server's per-key counters. olcRTC traffic leaves through the olcRTC server, not XRay, so
XRay's BitTorrent detection does not see it; the connection watch of Torrent Guard counts its
destinations, which the olcRTC server hands it, and the exit node itself drops what that traffic sends to the BitTorrent
and DHT ports, BitTorrent handshakes sent in the clear and DHT queries.
olcRTC (since revision 1.1). Each row carries
olcrtc_bytes, olcrtc_cost_nano and olcrtc_list_cost_nano, and the report their sums across
your subscribers: total_olcrtc_bytes, total_olcrtc_cost_nano and
total_olcrtc_list_cost_nano. olcrtc_bytes is the part of bytes that went through olcRTC, and
olcrtc_cost_nano the part of cost_nano charged at the olcRTC price. While no olcRTC price
is in force (olcrtc_pricing on GET /), olcRTC bytes cost the
session's price like the rest and olcrtc_cost_nano is 0. olcrtc_cost_nano is also 0
wherever cost_nano is 0: a trial, the exempt plan. olcrtc_bytes is 0 for traffic
metered before the exit named it, and for an interval that began before then.
To reconcile olcrtc_cost_nano with the price, cut your window at the step dates of
olcrtc_pricing.schedule. Within a step that names a price, olcrtc_cost_nano on charged
traffic is olcrtc_bytes × that step's price ÷ 1,073,741,824, lower by under a nanoTON for
each interval written (each is rounded down).
olcrtc_list_cost_nano is the shadow bill: olcrtc_bytes at the price of the last step of
olcrtc_pricing.schedule, when that step names a price, whether or not its date has come, so
what the same traffic costs once that step is in force. It is a list quote, not a charge:
nothing is charged on the exempt plan or inside a trial, and the quote is given there all the
same. It is null while the schedule is empty or its last step names no price;
total_olcrtc_list_cost_nano on /usage is the sum of the subscribers' values, null under
the same condition.
GET /subscribers/{external_id}/usage
The same figures for one subscriber, for a quota check.
GET /subscribers/user-42/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z
{
"external_id": "user-42",
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z",
"bytes": 1073741824,
"bytes_up": 214748364,
"bytes_down": 858993460,
"cost_nano": 12000000,
"olcrtc_bytes": 268435456,
"olcrtc_cost_nano": 0,
"olcrtc_list_cost_nano": null
}
Same window and direction rules as /usage. A subscriber that is not yours answers 404.
GET /guard-events
Since 1.10Every time Torrent Guard ended one of your subscribers' sessions, kept for 30 days: which subscriber, why, when, on which exit node, and the numbers behind the decision. We send you no notice when it happens. You poll this call.
GET /guard-events?since=2026-11-02T10:00:00Z&limit=500
X-Partner-Key: pk_…
{
"events": [
{ "id": "0192f0a1-6b3c-7d2e-8f41-5a6b7c8d9e01", "external_id": "user-42",
"subscriber_id": "8c1d…", "reason": "scan", "at": "2026-11-02T10:00:00Z",
"node_id": "0f2a…", "node_country": "DE", "node_type": "origin", "session_id": "3d91…",
"transport": "hysteria2", "bytes_up": null, "bytes_down": null,
"destinations": 412, "nonweb_destinations": 3, "refused_destinations": 0 },
{ "id": "0192f0a2-9d4e-7a31-b5c6-1e2f3a4b5c02", "external_id": "user-43",
"subscriber_id": "8c1d…", "reason": "upload", "at": "2026-11-02T10:02:30Z",
"node_id": "0f2a…", "node_country": "DE", "node_type": "origin", "session_id": "3d91…",
"transport": null, "bytes_up": 33285996544, "bytes_down": 4294967296,
"destinations": null, "nonweb_destinations": null, "refused_destinations": null }
],
"polled_to": "2026-11-02T10:05:00Z",
"has_more": false
}
| Parameter | Meaning |
|---|---|
since | An RFC 3339 time: only events after it, not the instant itself. Pass the previous answer's polled_to. Absent: everything kept, the last 30 days. |
limit | Events per page, 1 to 1000. Default 1000. |
Oldest first, by at and then by id. Only your own subscribers' events are listed.
| Field | Meaning |
|---|---|
id | The event. It never changes and is never reused: keep the ones you have handled. |
external_id | Your identifier for the subscriber. |
subscriber_id | The subscriber's ProofKit account: the account_id of GET /. |
reason | Why the session ended: one of the five below. |
at | When Torrent Guard ended the session, in UTC. |
node_id, node_country, node_type | The exit node the session ran on, as the catalogue names it: its node_id, its two-letter country and its node_type, which is origin. |
session_id | The session Torrent Guard judged. For the three reasons of the connection watch, the subscriber's other sessions on the same exit end with it and write no event of their own. |
transport | The transport the session's connections came in through in the minute that ended it: the one that carried most of those to ports other than 80 and 443, or of all of them in a minute with none. One of vless_reality, vless_xhttp, hysteria2, vless_webtunnel and olcrtc, the words of supported_transports in the catalogue; olcrtc is named for an exit whose olcRTC servers the watch reads. null for upload and session_limit, which are judged by the session's bytes whatever they came in through, and when the exit could not say. The set can grow: treat a value you do not know as other. Since revision 1.12. |
bytes_up, bytes_down | What the session had uploaded (what the subscriber sent) and downloaded when it ended, in bytes. null unless reason is upload or session_limit. |
destinations | The different destinations, each a name or address with its port, that the session's connections reached in the minute that ended it. null unless reason is scan, torrent or bittorrent. |
nonweb_destinations | Of those, the ones on a port other than 80 and 443. null with destinations. |
refused_destinations | The different destinations on a port other than 80 and 443 that Torrent Guard refused the session in that minute. They are not counted in destinations. null with destinations. |
The reasons. The set can grow: treat a value you do not know as other.
reason | What ended the session |
|---|---|
upload | The session had uploaded more than 30 GB and more than the exit's limit allows, one and a half times what it downloaded by default. |
session_limit | The session passed the data limit that the exit's operator set per session. |
scan | Address sweeping: the session's count for one minute reached 300 different destinations, or 200 in each of two minutes in a row. |
torrent | A torrent swarm: its count for one minute reached 100 destinations on ports other than 80 and 443 that were at least 80 % of all of them. |
bittorrent | A BitTorrent client the rules turned away: 20 on ports other than 80 and 443 refused in one minute. |
The last three are the connection watch, which runs on the exit nodes that have it (at the
time of writing, ProofKit's exit nodes) and counts each minute, for each session, the
destinations its connections reached, the ones on ports other than 80 and 443, and the ones
Torrent Guard refused. It never writes, logs or sends a destination: only these counts leave
the node. In every case the node drops the session's credentials, and for one hour that exit
refuses the subscriber (TORRENT_GUARD_COOLDOWN on
connect) and the subscription leaves its routes out.
The connection watch ends all of the subscriber's sessions on that exit together, with one
event. The rules are in the partner terms; the watch
counts the destinations of olcRTC as it counts those of Xray.
Polling. polled_to is where to continue: pass it as since in your next call.
- When the page is not full,
polled_tois 5 seconds before the answer. An event is listed 5 seconds after it was written, so a call never passes one that is still being written. - When the page is full (
has_moreistrue),polled_tois theatof its last event. A page never ends inside one instant, so the next call starts right after it and repeats nothing. Call again at once. A call with an oldersincereturns what it covers again, so keep theids you have handled. - A call a minute is plenty. It counts against your 300 calls a minute like any other.
since = none
every minute:
repeat:
answer = GET /guard-events, with since when you have one
for each event in answer.events: handle it once for its id
since = answer.polled_to
while answer.has_more
What is not here. Only Torrent Guard's endings are events. A session that ends for any other reason (a reset, a disable, a balance at zero, a node offline) is not listed here or anywhere in this API. Events older than 30 days are deleted.
| Code | Status | When |
|---|---|---|
INVALID_LIMIT | 400 | limit below 1 or above 1000 |
A limit that is not a number, or a since that is not an RFC 3339 time, is a plain-text
400 (see Errors).
Subscriber quotas
Since 1.3A subscriber can carry an optional traffic quota in bytes — quota_bytes on
POST /, changed any time by
PATCH / (a value sets it and
starts the allowance from now; null clears it). When the subscriber's metered traffic
since the quota was set reaches the allowance, the platform cuts them off itself:
their sessions end on every exit, connects answer 403 QUOTA_EXHAUSTED, and their
subscription serves no lines (the header still reports the quota's figures — the user's
app shows the traffic as used up). PATCH with room in it brings them back on the next
fetch.
What the cut costs you: nothing beyond the traffic itself. The quota is a control, not a billing boundary — bytes that move while a cut lands are real traffic and bill normally at the exit's price.
How exact the cut is, honestly: every exit counts the subscriber's traffic locally,
every second, against the target the platform pushes with each report. When the traffic
crosses it, new streams are refused at once, and a transfer already in flight ends a few
seconds later — measured on our production network, the whole cut lands within ~6 s
of the crossing, at exactly the allowance. Overshoot is bounded by
throughput × ~6 s per active exit (at 27 Mbit/s that is about 20 MB; the bytes that
move while the cut lands are billed — see above). A subscriber moving traffic on several
exits at once is reconciled when the first exit's cut reaches the platform, up to about a
minute for the rest — each exit enforces the full remaining allowance, so the first to
cross wins. Behind the exits, the platform still judges used ≥ quota on every billed
report plus a 15-second safety sweep; an exit whose agent predates the local counting
cuts at that report cadence (~a minute) instead of seconds. If you want a hard absolute
number regardless of throughput, keep a little headroom: PATCH the quota to
(sold allowance − peak throughput × 1 minute) — for a 25 GB plan that is a fraction
of a percent.
Without a quota, nothing changes: the fields read null/0/false, the header keeps
total=0, and cutting someone off at 100 GB is still yours — poll
GET / and call
DELETE / when it trips.
Subscriber exit countries
Since 1.5A subscriber can carry its own list of exit countries — exit_countries on
POST / and
PATCH /: 1 to 64 two-letter country
codes in upper case (ISO 3166 alpha-2), none twice. Its subscription then carries the routes
whose exit is in one of those countries instead of your own
sub_exit_countries: the subscriber's list replaces your list for that
subscriber, it does not narrow it. So you can sell one customer an exit you do not offer
everyone. Your entry and transport settings apply to every subscriber, and a subscriber
without a list follows your own.
- A narrower list takes effect at once. In the same call, every live session the subscriber holds on an exit the new list does not name ends: new streams are refused there at once, a transfer in flight ends a few seconds later, and an olcRTC line on such an exit goes within 5 minutes. A wider list ends nothing: the next subscription fetch starts the new routes.
nullclears the list and ends nothing: your own list applies again, by its own rule (a route in use stays).- A code with no exit behind it is accepted and keeps nothing; a list that keeps no route
is an empty subscription, not an error.
GET /names the countries that have exits.catalogue POST …/reads no list: it names its exit itself, as it ignores your own filters. A session you connect outside the list is yours to end; the subscription leaves it out.connect POST …/seats the subscriber only on an exit in its list. A place it holds on an exit a narrower list has left out is given up within 5 minutes — or, if you connected a session there yourself, when that session ends — and until then the call answersolcrtc 503 NO_OLCRTC_NODE.
| Code | Status | When |
|---|---|---|
INVALID_EXIT_COUNTRIES | 400 | An empty list, more than 64 codes, a code that is not two upper-case letters, a code listed twice |
Subscriber expiry
Since 1.5A subscriber can carry the instant its subscription ends — expires_at on
POST / and
PATCH /: an RFC 3339 instant with
an offset, in the future, such as "2026-11-10T20:30:00Z". We keep and answer it in UTC. From
that instant the platform cuts the subscriber off itself, the way it cuts one past its
quota: its sessions end within about 15 seconds, POST …/ and
POST …/ answer 403 SUBSCRIPTION_EXPIRED, and its subscription serves no lines. The
subscriber then answers expired: true.
- The app shows the date. With a date set,
subscription-userinfocarriesexpire=<Unix seconds>, before and after it passes; without one it staysexpire=0. - Renewal is a later date.
PATCHwith a futureexpires_atbrings the subscriber back on the next fetch;nullremoves the date. OnePATCHwithquota_bytesandexpires_atis a monthly renewal: a fresh allowance and a new date. - A date that is not in the future is refused (
400 INVALID_EXPIRES_AT): to stop a subscriber now,DELETEit. - Expiry is a control, not a billing boundary: what moves in the last seconds is real traffic and bills normally. An olcRTC line goes within 5 minutes of the date.
- The states do not fight. Disabled wins: a disabled subscriber's hosted URL answers
404whatever its date. Expired, past its quota, or both: an empty subscription, and the subscriber reports each (expired,over_quota). A reset leaves the date and the list as they are. An expired subscriber still counts toward a trial'smax_subscribers.
| Code | Status | When |
|---|---|---|
INVALID_EXPIRES_AT | 400 | Not an RFC 3339 instant with an offset, or not in the future |
SUBSCRIPTION_EXPIRED | 403 | POST …/, POST …/, and POST / for an existing enabled subscriber without a new expires_at, once the date has passed |
Subscriber device limit
Since 1.7A subscriber can carry a device limit: how many devices may receive its subscription, from 1
to 100. It is device_limit on POST / and
PATCH /. On PATCH, null clears
it; on POST, null is the same as leaving it out. The subscriber answers device_limit (null for none) and devices, how many devices hold
one of its slots now, and GET …/ lists them.
A device is an app install that names itself when it fetches the subscription: the
x-hwid header, an id the app makes once and sends with every fetch. Happ, v2RayTun and
Ghostlane send one (see Client apps). An id is 10 to 64 letters, digits, =
and -; anything else counts as no id. Beside it we keep what the app says of the device in
x-device-os, x-ver-os and x-device-model, and the app's name from its user-agent. We
never keep the id itself, only a hash of it keyed by your partner, and never an address.
A browser that opens the link is never counted as a device: it gets the setup page, which is
not judged, or, with the page off, is judged as an app that names no device.
Each fetch is judged before anything is provisioned, at the hosted URLs and at
GET …/ alike:
- A device that holds a slot is served.
- A device without one, new or back after a silence, is served and takes a slot while fewer devices than the limit hold one (always, without a limit), and is refused when every slot is held.
- A device holds its slot until 30 days pass without a fetch from it, so an old phone stops standing in the way of a new one a month after its last fetch. When it fetches again, it is judged like a new device.
- A device you removed is refused, with a limit or without, until you let it back.
- A fetch that names no device, for a subscriber with a limit, gets what your partner's mode
says: no list (
strict, the default) or the list, not counted (soft). The mode is Apps that do not name the device in the Partner page's Subscription tab (sub_device_mode, see Subscription settings). Strict is the default because a limit that an app can walk around by not naming its device is no limit.
Without a limit no fetch is refused for naming no device, and the devices that name themselves
are still recorded: devices counts them and the list shows them, so you can see how a link is
used before you set one. A subscriber past its end date or its quota gets its empty
subscription as before, and its device is neither judged nor recorded.
A refused fetch is answered 200, not an error: on an error an app keeps the servers it
has, and on this answer it drops them. Nothing is provisioned and no olcRTC key is issued. The
body carries no route:
happand/sub/: one line, in base64 as always, a<sub_token> vless://entry to127.0.0.1:1whose name is the sentence below, so an app that reads no header still shows why, as its only server;ghostlane,/unifiedand/olcrtc: the same line, plain;json: the usual object with no entries,{"entries": [], "upload": N, "download": N}.
Your usual headers stay, so the app still shows the traffic and the end date, and these come with them:
| Header | Value | Read by |
|---|---|---|
x-hwid-active | true | Apps that know device limits |
x-hwid-max-devices-reached | true, when every slot is held or the device was removed | Happ |
x-hwid-not-supported | true, when the fetch named no device | Happ |
x-hwid-limit | true | v2RayTun |
announce | base64: + base64 of the sentence | Happ and v2RayTun, which show it |
announce-url | Your support URL, when one is set | v2RayTun |
The sentence starts with your profile title, or "Your provider" without one:
- every slot held: "My VPN: this link is already in use on its 3 devices. Ask My VPN to free one." For a limit of 1 it reads "its 1 device";
- a removed device: "My VPN: this device was removed from the link. Ask My VPN.";
- no device named: "My VPN: this app does not identify the device, or that is switched off in its settings. Use Happ, v2RayTun or Ghostlane with the device id on."
Every answer that judged the device of a subscriber with a limit, served or refused, also
carries subscription-always-hwid-enable: 1, a parameter of Happ's own, which asks Happ to keep
sending its device id. Whether its user can still switch the id off in the app is Happ's to
decide. A subscriber without a limit gets no such header.
Through your own server, forward the app's request headers with each call to
GET …/: x-hwid, x-device-os,
x-ver-os and x-device-model as they came, and its user-agent in place of your HTTP
client's, which would otherwise be read as the device's app. The call is judged by them
exactly as a hosted URL is. With a limit set and no x-hwid forwarded, a strict partner's
call is refused as one from an app that names no device. Pass the answer's headers back to the
app with the body.
What it stops, and what it does not. The limit caps how many devices receive the list, not how many connect: every device of a subscriber connects with the same credentials, and a device that has the list keeps connecting until its app refreshes and is refused, or until a reset changes the credentials. It stops a link passed to a friend or posted in a chat: the next device gets the sentence instead of servers. It does not stop someone who copies the lines out of an app, or a relay that sends one device id for everyone. A device id is the app's word, and an app can be made to say another.
What you can do:
- A lower limit removes no device. The devices that hold a slot are served on, and no new one is admitted until fewer than the limit hold one: as devices fall silent for 30 days, or as you remove them.
- Remove a device with
DELETE …/: its next fetch is refused and its slot is free at once. It stays refused until you let it back withdevices/ {device_id} POST …/, which answersdevices/ {device_id}/ allow 409 DEVICE_LIMITwhen letting the device back would make it hold a slot past the limit. A removed device silent for more than 30 days holds no slot: it is let back, and its next fetch is judged like a new device's. - A reset forgets every device, the removed ones too: the new link starts with none, and the customer's own devices take their slots again at their next fetches. The limit stays.
- A disabled subscriber answers
404on the three device calls until it is enabled again;PATCHsets itsdevice_limitmeanwhile.
What is kept. A device that is not removed is dropped within minutes of the 180th day after its last fetch. When a new device is recorded, the ones seen longest ago are dropped too, so that with the new one at most 20 devices that are not removed are kept, or as many as the limit when it is higher. Under a limit, a device that holds a slot is never dropped. Removed devices are kept apart, up to 100: past that, the one removed longest ago is dropped. A dropped device that fetches again is a new device.
| Code | Status | When |
|---|---|---|
INVALID_DEVICE_LIMIT | 400 | device_limit is a whole number outside 1 to 100 |
DEVICE_NOT_FOUND | 404 | The subscriber has no device by that device_id |
DEVICE_LIMIT | 409 | Let back, the device would hold a slot past the subscriber's limit |
Idempotency
There is no Idempotency-Key header; each call's own rules decide.
| Call | Repeat it and… |
|---|---|
POST / | …get the same subscriber (created: false); a disabled one is re-enabled. |
POST …/ | …get the same live session; a disabled subscriber is re-enabled. |
POST …/ | …get the same session for the same route; another route opens another session. |
PATCH / | …set the quota again when it names one: the allowance restarts from each call's moment. Not idempotent, by design: it is the renewal. Naming only exit_countries, expires_at or device_limit is idempotent. |
DELETE / | …get 204 again. |
GET …/ | …get the list as it stands; reading it changes nothing. |
DELETE …/ | …get 204 again; the device keeps the time of its first removal. |
POST …/ | …get 204 again: a device let back is not removed. |
DELETE …/ | …get 204 again. |
GET …/ | …get the same lines while nothing changed (the traffic figures move); the first fetch provisions. |
POST …/ | …get a new token and end the sessions again. Not idempotent. |
other GETs | …change nothing, except that …/ can reserve an olcRTC key. |
Billing
Traffic of subscribers you created through this API is charged to your billing account,
not to the subscriber, at each session's locked price (the route's node prices plus the 20%
network fee; a later price drop reaches running sessions once the lower price has stood for
3 hours, a rise does not), per GB, about once a minute as it moves; olcRTC traffic is
charged at the olcRTC price instead while one is in force (olcrtc_pricing on
GET /). Only traffic is charged: there is no monthly, setup or API
fee and no minimum spend.
Your plan is billing_plan on GET /:
prepaid: you pay from your own TON deposit in the ProofKit escrow. There is no credit: new sessions on priced routes need a balance above zero. At zero new connects on priced routes are refused, the session whose traffic report took the balance to zero is closed at once, and your subscribers' otheractivesessions on priced routes are closed within about 15 seconds (apendingone once it turnsactive). The last reports can take the balance slightly below zero; your next deposit covers it first. TON is the only payment method.trial: only by agreement with ProofKit (a partner that signs itself up starts onprepaid). Free within limits set by ProofKit (an end date, a traffic allowance, a number of subscribers; by default 7 days, 50 GB and 10 subscribers). While every limit holds, nothing is charged, new sessions are allowed at any balance and none are closed for balance. Creating a subscriber past the cap answersTRIAL_LIMIT. Past any limit you are charged likeprepaidfrom the next traffic report, with no restart: top up before the trial ends, or your subscribers' running sessions are closed within about 15 seconds of the trial ending (by date, by traffic or by subscriber count), whether or not they are moving traffic.exempt: ProofKit covers your traffic;billing_modereads"free".
A partner that signs itself up also has a stake locked on its balance (stake_nano on
GET /): it is never charged for traffic and cannot be withdrawn while
the partner runs.
To pay, link your wallet on the Partner page (a TonConnect proof that you own it), then top
up from that wallet. Only a Deposit operation or a transfer with the comment deposit,
sent from the linked wallet, is credited; a plain transfer is not credited to anyone. The
Partner page's Top up takes 0.1 TON or more; a transfer with the comment deposit
takes 0.05 TON or more (the escrow refuses less). There is no minimum balance to keep. What
your balance does not owe can be withdrawn back to the linked wallet. Everything about
money is in Payments.
Users who sign up through your Telegram Mini App instead are ordinary ProofKit accounts and pay for themselves. Creating a subscriber through this API is what makes their traffic yours to pay for.
If ProofKit suspends your partner
A suspension (see the partner terms) switches your partner off and ends your subscribers' sessions:
- Your key answers
401 INVALID_PARTNER_KEYand the Partner page answers404 NOT_A_PARTNEReverywhere but its Balance tab. - Every running session of your subscribers is ended at once, so nothing is charged to your billing account after that. Switching the partner back on does not bring the sessions back by itself.
- Your subscribers' hosted
/sub,/unifiedand/olcrtcURLs fall back to our standard unbranded lists (see Hosted subscription URLs). - Your deposit stays yours: withdraw it from the Partner page's Balance tab. A stake you hold stays locked for 30 days after the suspension, and then comes back to the balance.
Link resolver: the one endpoint you implement
Everything above is you calling us. This is the reverse, and it is optional: it lets a
customer who holds one of your opaque happ://… links (for example happ://crypt5/)
import it into Ghostlane without you reissuing anything. We cannot open such a link, and do
not try; you minted it, so we ask you.
What we send. A POST to the URL you gave us, with the shared secret in a header:
POST <your link_resolver_url>
X-ProofKit-Key: <the secret we agreed>
Content-Type: application/json
{ "link": "happ://crypt5/fzvdQQSl2kKPyNPhAeRV4WSh12xLFV8" }
What you answer. Any 2xx with the subscription URL that link stands for:
{ "subscription_url": "https://sub.example.com/sub/j7k9eX" }
Rules on your answer:
- It must start with
https://orhttp://. Anything else is discarded as if you had not answered. - You have 5 seconds. Redirects are not followed.
- Any non-
2xx, a missingsubscription_urlor unparseable JSON means "not mine", and we ask the next partner. Return404for a link you did not mint. Do not guess.
Check X-ProofKit-Key before you answer, in constant time: it is how you know the caller is
us. Make the endpoint safe to call repeatedly. We remember which partner answered a link,
keyed by a SHA-256 of the link rather than the link itself, and ask everyone again when that
partner stops answering for it.
What the app does. It posts the pasted string to our public endpoint:
POST https://proofkit.org/api/v1/partners/resolve-link
{ "link": "happ://crypt5/…" }
→ { "link": "olcrtc://crypt1/…" }
The answer wraps your subscription URL for the app to import (or is the plain URL). The
endpoint needs no authentication, accepts only strings starting with happ:// (at most
4,096 characters), and is limited to 20 requests per hour per IP. When nobody claims a link
it answers 404 ("Link not recognised"), the same as for anything else it cannot resolve,
so it cannot be used to test whether someone else's link is real.
Setup and rollback. ProofKit sets your link_resolver_url and the secret on your
partner record. Clearing the URL is the rollback: we stop asking, and your links stop
importing. Nothing else changes.
Client apps
- Happ, v2rayNG, Hiddify and similar apps import the
happformat. Reality and Hysteria2 run in all three; XHTTP needs an Xray-core app such as Happ or v2rayNG (sing-box-based apps such as Hiddify cannot run it). They cannot run olcRTC, and thehappbody never contains olcRTC lines. - Ghostlane is the client that also runs olcRTC. It imports the
ghostlaneformat (or the hosted/sub/), which carries the same lines plus olcRTC.<sub_token>/ unified - The device id that a device limit counts by is sent by Happ,
v2RayTun and Ghostlane. Hiddify, v2rayNG, Streisand and the sing-box apps send none;
Shadowrocket, Karing, Clash Mi and Throne have it off unless the user turns it on. Under a
limit, an app that sends none gets no list while your partner's mode is
strict.
How to connect a subscriber step by step: Quick start. Which transports and routes to offer users in Russia: Russia.
Error codes
| Code | Status | Where |
|---|---|---|
INVALID_PARTNER_KEY | 401 | Every endpoint: missing, malformed or unknown key, a disabled partner, or one that has not finished setting itself up |
RATE_LIMITED | 429 | Every endpoint: per-key budget or per-IP flood guard |
NOT_FOUND | 404 | Unknown path; a subscriber, session or node that is not yours or does not exist; no origin available in a country |
METHOD_NOT_ALLOWED | 405 | A known path with the wrong method; Allow lists the path's methods |
INVALID_EXTERNAL_ID | 400 | POST /, POST …/ |
PARTNER_NOT_BILLABLE | 400 | POST /, POST …/ |
TRIAL_LIMIT | 403 | POST /, POST …/ |
INVALID_QUOTA | 400 | POST /, PATCH / |
INVALID_EXIT_COUNTRIES | 400 | POST /, PATCH / |
INVALID_EXPIRES_AT | 400 | POST /, PATCH / |
INVALID_DEVICE_LIMIT | 400 | POST /, PATCH / |
DEVICE_NOT_FOUND | 404 | DELETE …/, POST …/ |
DEVICE_LIMIT | 409 | POST …/ |
QUOTA_EXHAUSTED | 403 | POST /, POST …/, POST …/ |
SUBSCRIPTION_EXPIRED | 403 | POST /, POST …/, POST …/ |
INVALID_LIMIT | 400 | GET /, GET / |
INVALID_CURSOR | 400 | GET / |
INVALID_FORMAT | 400 | GET …/ |
SUBSCRIPTION_NOT_ENABLED | 403 | GET …/ |
INVALID_RANGE | 400 | GET /, GET …/ |
MISSING_PARAM | 400 | POST …/ |
TRANSPORT_NOT_SUPPORTED | 400 | POST …/ |
FORBIDDEN | 403 | POST …/: disabled subscriber, or a private node not granted to you; POST …/: a held olcRTC place on such a node |
SESSION_LIMIT | 400 | POST …/, POST …/ |
NODE_OVERLOADED | 400 | POST …/ with origin_id, POST …/ |
NODE_AT_CAPACITY | 400 | POST …/ with origin_id, POST …/ |
TORRENT_GUARD_COOLDOWN | 400 | POST …/, POST …/ |
INSUFFICIENT_BALANCE | 400 | POST …/, POST …/ |
AGENT_OFFLINE | 400 | POST …/, POST …/ |
OLCRTC_NOT_ENABLED | 403 | POST …/, when subscriptions are switched on for your partner and olcRTC is not |
NO_OLCRTC_NODE | 503 | POST …/: no room with a place; with subscriptions switched on, also your share in use, or the subscriber's place outside the request |
OLCRTC_UNAVAILABLE | 503 | POST …/ |
INTERNAL_ERROR | 500 | Our failure, including "Rate limiter unavailable" |
Plus the plain-text 400, 415 and 422 responses and the proxy's HTML 413 described
under Errors. The codes of the dashboard's subscription settings (INVALID_FILTER,
INVALID_BRANDING, INVALID_DEVICE_MODE, FIELD_NOT_EDITABLE) are under
Subscription settings.