Partner API documentation
Partner API v1 · revision 1.5 · 11 October 2026 · What changed
The server-to-server API for resellers: your backend creates subscribers, each one gets a subscription URL, and their traffic is charged per GB to a balance you prepay in TON. With no backend you add them by hand in the dashboard: Without code.
- 01Partner quick startFrom an API key to a working subscription in Happ: the account, the catalogue, a subscriber and its URL, step by step.
- 02Partner APIEvery endpoint, field, limit and error code of the server-to-server API.
- 03Paying for your subscribers' trafficThe free trial, the partner wallet, top-ups, what a GB costs and withdrawing the unspent deposit, in TON.
- 04Transports and RussiaThe four transports, direct and via-RU routes, and which lines to hand out to users in Russia.
- 05Without code: the dashboardNo backend: add subscribers by hand, limit them, hand over a link or a QR code, renew and stop them.
No key yet? Request API access · No backend? Start without code · About the Partner API · Partner terms
01 /Partner quick start
This guide takes you from an API key to a working subscription in Happ, once ProofKit has set up your partner. Every call is server to server. The key goes in the X-Partner-Key header on your backend and never into an app or a web page you serve. Browsers cannot send the header to us from another site anyway. To try calls by hand, use the Partner API explorer on proofkit.org. For every endpoint, field and error code, see the reference, Partner API. Paying for traffic is covered in Paying for your subscribers' traffic, and subscribers in Russia in Transports and Russia.
Before you start
- No backend? You do not need this guide. A partner that adds its subscribers by hand works from the dashboard alone, with no key: see Without code: the dashboard.
- Access. There is no self-serve sign-up. Sign in at proofkit.org (or create a free account) and open a ticket under Support, category "Other", with
Partner APIin the subject (the link fills in both, and the questions): what you run, the countries and transports you need, the traffic you expect, and which account will run your partner dashboard (one that signs in with an email address, not only with Telegram, and runs no other partner). ProofKit sets up your partner, starts a free trial if one is agreed, and issues your first key. The terms (prices, the free trial, Torrent Guard, acceptable use, suspension; no monthly fee, no minimum spend, no SLA) are at proofkit.org/partner-terms. - An owner account. ProofKit attaches your partner to one ProofKit account, the email account you named. That account must have two-factor sign-in on (an authenticator app or Telegram codes). It then gets the partner dashboard,
#/on proofkit.org, with the plan, trial, balance, wallet, top-up, withdrawal, API key, the subscription's settings and, when olcRTC is on for you, olcRTC in use against your share. Any other account sees "This account does not run a partner".partner - An API key (
pk_…). ProofKit issues your first key; if you have none, the owner account can create one in the dashboard (Create key). A key is shown once; we keep only its prefix and a hash of it. The owner account rotates the key in the dashboard, at most 5 times an hour. The new key is shown once, and the key it replaces keeps working for 24 hours. Only one previous key is kept: rotating again within those 24 hours ends the older key at once. A key ProofKit issues by hand, without an overlap, ends every older key at once. - Subscriptions switched on for your partner. ProofKit switches them on, and olcRTC too if agreed. Your branding and which routes the subscription carries are yours to set in the dashboard (step 3). Until subscriptions are on, step 5 answers
403 SUBSCRIPTION_NOT_ENABLED. - A free trial or a balance. The trial runs on the live network; there is no separate test environment. See Trial, then prepaid.
- Happ on a phone, plus an app that runs olcRTC, such as Ghostlane, if ProofKit has switched olcRTC on for you.
PK=https://proofkit.org/api/v1/partner
KEY=pk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # your key
Conventions used throughout:
- Money is always an integer number of nanoTON: 1 TON = 1,000,000,000 nano.
- GB, in prices and limits, means 1,073,741,824 bytes, with upload and download added together.
- Errors come back as
{"error": {"code": "…", "message": "…"}}. Branch oncode. Every response carries anX-Request-IDheader: quote it when you write to us about a call. - A plain-text 400, 413, 415 or 422 is not from our handlers. The request was refused before a handler ran: no
Content-Type: application/, broken JSON, a missing field, a query value or path id that does not parse (for examplejson from=yesterday, or asession_idthat is not a UUID), or a body over 2 MB.
1. Check the account
curl -s -H "X-Partner-Key: $KEY" $PK/account
{
"partner_id": "…",
"billable": true,
"billing_mode": "metered",
"billing_plan": "trial",
"balance_nano": 0,
"credit_limit_nano": 0,
"suspended": false,
"rate_limit_per_min": 300,
"wallet": null,
"escrow_address": "…",
"deposit_link": null,
"trial": {
"until": "2026-10-03T12:00:00Z",
"max_subscribers": 10,
"subscribers": 0,
"max_bytes": 53687091200,
"bytes_used": 0,
"active": true
}
}
| Field | What it tells you |
|---|---|
billable | Must be true. If it is false, step 4 answers 400 PARTNER_NOT_BILLABLE, and ProofKit has to set your terms first. |
billing_plan | trial, prepaid, or exempt (ProofKit covers the traffic). |
billing_mode | Kept for existing integrations. It reads free only on exempt and metered otherwise, including during a free trial. Read billing_plan and trial.active instead. |
trial | Present only while billing_plan is trial. active: true means traffic is free right now. |
balance_nano | Your prepaid balance. Once you pay for traffic, a new session needs it above zero, because credit_limit_nano is 0 on prepaid. |
wallet, deposit_link | The partner wallet linked for top-ups, and a ton:// deposit link for it. Both are null until a wallet is linked (see Link the partner wallet). |
2. Read the catalogue
curl -s -H "X-Partner-Key: $KEY" $PK/catalogue
The catalogue has one entry for each node you may sell: the nodes ProofKit has approved for automatic routing, public ones plus nodes granted to you. Here is an edge. The prices are illustrative.
{
"node_id": "…",
"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"],
"olcrtc_carriers": [],
"is_public": true,
"sessions_count": 32,
"max_users": 0,
"max_users_effective": 500,
"agent_online": true
}
price_per_gb_nanois the node's price for you, with your agreed price applied if you have one. It is the price before the network fee.total_price_per_gb_nanois what a GB through this node costs you: the price plus the 20% fee (fee_bps: 2000). A session's price is fixed when the session starts. A later drop in the node's price lowers it; a rise does not reach a running session. olcRTC traffic is the exception: from 20 November 2026 it has a price of its own, read when the traffic moves (see The olcRTC price).- A route through an edge costs the origin's price plus the edge's price, with the fee taken once on the sum: the two nodes' totals added together, within a nanoton. For example, 10,000,000 + 10,000,000 gives a route price of 24,000,000.
entry_region: "RU"marks an edge in Russia. The field is absent on every other node.supported_transportslists some ofvless_reality,hysteria2,vless_xhttpandolcrtc.agent_onlinesays whether the node is connected right now.sessions_countcounts the sessions provisioned on the node, not people: a subscription keeps a session on every route of every subscriber, used or not. An exit is full when the people on it (its sessions that carried traffic in the last 15 minutes) reachmax_users_effective, or when its live load is too high. Asessions_countabovemax_users_effectivedoes not mean the node is full.- Origins that serve olcRTC also carry
olcrtc_*capacity fields.
You do not pick nodes for a subscription one by one. It carries every route you may use, less what your filters leave out (step 3). The catalogue tells you which exits there are and what their routes cost.
Before you have a key, the public marketplace, proofkit.org/providers, lists the exits and their prices. Those prices already include the 20% fee: they are the total_price_per_gb_nano you pay, unless we have agreed a price with you.
3. Choose what the subscription carries
Sign in with the owner account, open the dashboard (#/) and find the Subscription tab ("branding & filters"). You can fill it in before subscriptions are switched on, and change it at any time.
What ProofKit switches on, and the tab does not change:
- Subscriptions. The tab's first line says whether they are on for your partner. Until they are, step 5 answers
403 SUBSCRIPTION_NOT_ENABLED. - olcRTC, and your olcRTC share. The dashboard's olcRTC figure, "in rooms / your share", shows how many of your subscribers' olcRTC keys count as present against your share, or
off(see step 7). The share binds only once subscriptions are on; until then it is for information.
Ask ProofKit to change either.
What you set:
| Field | What it does |
|---|---|
| profile title | The subscription's name in the app. Up to 64 characters, any script and emoji. |
| support URL, web page URL | Links the app shows. https:// only, visible ASCII with no spaces, up to 512 characters each. |
| line-name prefix | Put in front of every line's name, for example MyVPN in MyVPN DE Direct. Up to 32 characters, none of #, ?, &, $. |
| update interval, hours | How often the app re-reads the list, 1 to 168 hours; 1 by default. |
| exit countries | The exits to keep, as comma-separated country codes. Blank keeps every exit. |
| entry | All (the default), Direct only, or Via entry edge only (for example only the via RU routes). |
| transports | Reality, Hysteria2 and XHTTP, the lines each route carries. All three are checked by default. |
A blank field means none, and no value may contain control characters.
For example, Europe only: enter DE, ES, FR, GB, IT, NL, PL, SE under exit countries, keep entry at All and all three transports checked, and press Save. The preview under Save then shows the first lines a subscriber would get, such as MyVPN DE Direct, MyVPN DE Direct · Hysteria2 and MyVPN DE via RU. "No route matches these filters yet." means the filters keep nothing. The catalogue (step 2) lists the exit countries there are; a code with no exit behind it keeps nothing.
What a save does:
- Nothing is sent to the nodes and no session ends. Each subscriber's next fetch serves the new settings, and the app shows them after its next refresh.
- A route the filters leave out is never started. A route a subscriber already holds that a new filter leaves out drops out of its list: if its session never carried traffic, it is closed once it is an hour old; otherwise it keeps working until it ends for another reason. See Route filters.
- The filters apply to every subscriber of your partner at once, and only to the subscription:
POST …/and the catalogue ignore them. A new olcRTC place is made only on an exit you keep.connect - The dashboard takes up to 30 saves an hour, refused ones included.
The tab is the owner's alone: the API key cannot read or change these settings. The dashboard's own endpoint behind it is described under Subscription settings.
4. Create a subscriber
curl -s -X POST $PK/subscribers \
-H "X-Partner-Key: $KEY" -H "Content-Type: application/json" \
-d '{"external_id": "tg_123456789"}'
{
"external_id": "tg_123456789",
"account_id": "…",
"sub_token": "…64 hex characters…",
"created": true,
"disabled": false,
"created_at": "2026-09-26T15:04:05.123456Z"
}
external_idis your own id for the person. It must be 1 to 128 bytes of UTF-8 once surrounding whitespace is trimmed; anything else answers400 INVALID_EXTERNAL_ID.- The call is idempotent. The same
external_idreturns the same subscriber withcreated: false, so retrying is safe. If the subscriber was disabled, this call enables it again. sub_tokenis the subscriber's secret for the hosted subscription URLs. It changes only on a reset (step 8).- Nothing starts on the nodes and nothing is charged yet.
- During a trial, creating a subscriber beyond
trial.max_subscribersis refused with403 TRIAL_LIMIT. Disabled subscribers count toward that cap.
5. Get the subscription
Your server fetches it. URL-encode the external_id in the path.
curl -s -D - -H "X-Partner-Key: $KEY" \
"$PK/subscribers/tg_123456789/subscription?format=happ"
format | Body | Read by |
|---|---|---|
happ (default) | Base64 of the share lines | Happ, Hiddify, v2rayNG |
ghostlane | The same lines as plain text, followed by olcRTC lines | Ghostlane |
json | {"entries": [...], "upload": N, "download": N}, where each entry has url, transport, carrier (olcRTC lines only), remark, session_id, origin_id, edge_id, exit_country, entry_country | Your own code |
The response carries these headers. Return them together with the body:
| Header | Value |
|---|---|
profile-title | base64: followed by your title in base64, when a title is set |
profile-update-interval | Hours between refreshes, 1 by default |
support-url, profile-web-page-url | Your links, when set |
subscription-userinfo | upload=…; download=…; total=…; expire=…: this calendar month in UTC with total=0; expire=0, or the subscriber's quota and end date once you set them |
content-type | text/, or application/ for json |
cache-control | no-store |
content-disposition | inline |
There are three ways to hand the subscription to the app:
- Under your own domain, your server (production). Your server answers its own URL, for example
https://sub.example.com/. It maps that URL to the subscriber's<your token> external_id, calls the endpoint above, and returns the body unchanged, with the headers. You may rewrite any header: to show your own plan in the app, settotal=andexpire=insubscription-userinfoand keep ourupload=anddownload=. The user only ever sees your domain, and the key stays on your server. - Under your own domain, our serving (production, one DNS record). Add
sub.yourdomain.com CNAME subs.proofkit.org— a plain record, DNS-only — and request the domain on the Partner page. Once we approve it, your subscribers' links answer on it with our certificate;GET /'saccount hosted_basesays which host they ride on. Free outside the trial, up to three domains. See Serving it under your domain. - Hosted by ProofKit (quickest for a test).
https://proofkit.org/serves the samesub/ <sub_token> happbody with the same headers. It needs no key, so the URL itself is the secret, and it shows proofkit.org. It allows 60 requests a minute per IP. Until ProofKit switches subscriptions on for your partner, this URL does not answer403: it serves an older, unbranded list with a VLESS Reality line for each session the subscriber already holds fromconnectorPOST …/, usually none. When olcRTC is on for you, building that list also issues the subscriber an olcRTC key on every olcRTC exit where it holds a session, outside your share, and ends the list with a localolcrtc socks://127.0.0.1:10808#olcRTCline, which does not work in Happ. The hosted URLs serve these older lists again if ProofKit later switches subscriptions off for you. See Hosted subscription URLs.
Rate limits. Budgets are counted per key prefix (pk_ and 8 hex characters), before the key is checked: 1,200 subscription fetches a minute, and 300 a minute for every other call. A request with a wrong key that carries your prefix spends your budget, and requests without X-Partner-Key share one bucket. During a rotation's 24-hour overlap, the old and the new key each have their own. 1,200 fetches a minute carry about 72,000 subscribers refreshing every hour; see At scale under Serving it under your domain for more. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; pace your proxy by them. See Rate limits.
First fetch. The first fetch starts a session on every route the subscriber lacks among those your filters keep, and spends up to 5 seconds doing so. Only traffic is charged: a route nobody uses costs nothing. Some routes cannot be made right away, and those are left out while the rest of the body comes back:
- a node of the route is offline, full or overloaded;
- your balance does not allow a new session;
- the subscriber already holds 50 sessions, the most one subscriber may hold;
- Torrent Guard ended the subscriber's session on that exit in the last hour;
- the 5 seconds run out. Each of our servers starts at most 8 sessions at a time, for every fetch of every partner together, so when you import many subscribers, spread their first fetches over time.
A later fetch adds a left-out route once its cause has passed; a route past the 50-session cap stays out while the subscriber holds 50. While nothing changes, later fetches return the same body.
| Answer | Meaning |
|---|---|
403 SUBSCRIPTION_NOT_ENABLED | ProofKit has not switched subscriptions on for your partner. |
404 NOT_FOUND | No such subscriber, or the subscriber is disabled. |
400 INVALID_FORMAT | format is not happ, ghostlane or json. |
6. Open it in Happ
Copy the URL, either your own or the hosted one, and add it in Happ as a subscription. Happ lists the lines; the user picks one and connects. Hiddify and v2rayNG read the same URL.
Or give the hosted link to the person as it is. Opened in a browser it is a page that says how to connect: an app to install, one tap to add, and the link to copy for any other app (see Hosted subscription URLs). On the Partner page, a subscriber's QR button shows that link as a code to scan, share or print.
What the user sees:
- Branding. The subscription carries your title, support link and web-page link, when they are set (step 3).
- Line names. Each route gets a line named
<prefix> <exit> Director<prefix> <exit> via <entry>, by country code, for exampleMyVPN DE DirectandMyVPN DE via RU. When one country has two exits, the second is numberedDE 2; a second entry in one country for the same exit is numberedvia RU 2. The numbers count the routes in that body, so a route that is left out on one fetch (its node offline or full) and back on the next can rename the others:DEbecomesDE 2and back. Apps key their lists on names, so this can show as a duplicate or a missing line until the next refresh. - Transports. Every route has a VLESS Reality line. Routes that carry them also get a Hysteria2 line (
… · Hysteria2) and an XHTTP line (… · XHTTP). A transport you unchecked in step 3 is missing from every route. - List length. Without filters the list is long: every exit the subscriber may use, both direct and through each entry edge. To hand out fewer lines to everyone, set the filters in step 3, for example Europe only. To give different subscribers different lists, fetch
format=jsonand build your own body from each entry'sexit_countryandentry_country. A JSON fetch still starts every route your filters keep. If olcRTC is on for your partner, everyjsonfetch also gives the subscriber an olcRTC key and takes one of your olcRTC places (step 7), whether or not you pass the olcRTC entries on. For subscribers whose app cannot run olcRTC, filter the decodedhappbody instead (each line's name, percent-encoded after#, carries its exit and entry), or ask ProofKit to leave olcRTC off for you. - Usage and limits.
subscription-userinfoshows this calendar month's traffic, withtotal=0; expire=0, until you give the subscriber limits. A subscriber can carry a traffic quota, its own exit countries and an end date, and ProofKit enforces them: see Subscriber quotas, Subscriber exit countries and Subscriber expiry. The header then carries the quota and the date. If you keep your own limits instead, your proxy may settotal=andexpire=to show them in the app (step 5), and you enforce them by disabling the subscriber (step 8). - Refresh. The app re-reads the list every
profile-update-intervalhours. - Changing credentials. A line's credentials last as long as its session. A session ends on a reset, a disable, your
DELETE /, a balance at zero, Torrent Guard, its node being offline for 30 minutes, or ProofKit taking its node or link out of service. An active line is not closed for being idle, except on a node ProofKit has paused, where a line that has carried nothing for two hours is closed and not made again (see the catalogue in the API reference); a line still pending (its node never confirmed it) that carried nothing is closed once it is an hour old. The next fetch then makes a new session, and the app picks up the new line on its next refresh.subscribers/ {external_id}/ sessions/ {session_id} - Russia. For subscribers in Russia, see Subscribers in Russia.
7. olcRTC lines
In a subscription, olcRTC lines come in the ghostlane and json formats, and from the hosted https://proofkit.org/ and https://proofkit.org/ (the olcRTC lines alone). The happ format never carries them, because Happ, Hiddify and v2rayNG cannot run olcRTC. Ghostlane is the client that also runs olcRTC. It also reads the Reality, Hysteria2 and XHTTP lines of the same body, so one URL is enough.
- Switch. The lines appear only after ProofKit switches olcRTC on for your partner. With subscriptions on, the switch governs the per-session calls below too.
- One exit, one key. A subscriber is given an olcRTC key on one exit of its body, so on an exit your filters keep. That one place counts whichever call made it: a subscriber that got its key from the per-session calls below gets no second one from the subscription. Any exit in its body where it holds a key keeps its lines. Each exit has one line for each call service it has a room on, all sharing one key:
MyVPN DE · olcRTC(Yandex Telemost),MyVPN DE · olcRTC · WB(WB Stream),MyVPN DE · olcRTC · SJ(SaluteJazz) andMyVPN DE · olcRTC · VK(VK Calls). - Limited places. Two caps apply to a new key. The number of your subscribers' keys that count as present at once is capped at your share: the larger of 20% of the network's olcRTC slots and 8 plus one for every 15.625 GiB of olcRTC traffic your subscribers moved in the last 30 days. A key counts as present while its subscriber has moved traffic through that exit in the last 5 minutes on any line, Reality, Hysteria2 and XHTTP included, not only olcRTC, and each key counts on its own. And in each room you may hold only your part of its keys, idle keys included: a fifth at the default share (7 of 32 today), up to half as your traffic grows. So a room can be closed to you while your share still has room. The subscriber is then seated on another exit; when no exit has a place, its olcRTC lines are left out and the rest of the body still comes back. They appear on a later fetch once a place frees up. Your new keys never make a room take back anyone else's key, so the catalogue's
olcrtc_keys_freeis only an upper bound for you. - Keys and formats. A
ghostlaneorjsonfetch gives the subscriber an olcRTC key, and ahappfetch never does. Fetchhappfor Happ users. - A quiet key can be taken back. A key held for 30 minutes, whose subscriber has sent no traffic through that exit for 30 minutes, can be taken back when its room needs the place. Its lines stop working until a later
ghostlaneorjsonfetch seats the subscriber again. On the same exit it gets the same key back, so the line it holds works again unchanged. - Per-session calls.
POST /andsubscribers/ {external_id}/ olcrtc GET /also hand out olcRTC lines (subscribers/ {external_id}/ sessions/ {session_id}/ configs olcrtc_url,olcrtc_urls), one session at a time. With subscriptions switched on for you, they follow the same terms: the olcRTC switch (while it is off,POST …/answersolcrtc 403 OLCRTC_NOT_ENABLEDand configs carry no olcRTC fields), your share and your part of each room, one place per subscriber, and a new place only on an exit your filters keep. A subscriber that holds its place is answered from it and never refused for the share. When no place can be made,POST …/answersolcrtc 503 NO_OLCRTC_NODE. SeePOST …/.olcrtc
8. Disable, re-enable, reset
Disable:
curl -s -X DELETE -H "X-Partner-Key: $KEY" $PK/subscribers/tg_123456789 # 204
- Every running session ends, and its credentials are removed from the nodes. From then on, the subscription call and the hosted URLs answer
404. - The subscriber's olcRTC keys stop working at the next room check, about 5 minutes later, sometimes longer. Until then an olcRTC line they already hold can still carry traffic.
- The subscriber and its usage history are kept, and
GET $PK/showssubscribers/ tg_123456789 disabled: true. Calling DELETE again changes nothing. - To re-enable, call
POST /again with the samesubscribers external_id. Thesub_tokenstays the same, and the next fetch makes new lines. Note thatPOST /also re-enables a disabled subscriber.subscribers/ {external_id}/ olcrtc - A re-enabled subscriber does not get its olcRTC place back: it needs a new one, under your share and a room's capacity, with a new key. Until one is free, its
ghostlaneandjsonbodies come without olcRTC lines.
Reset, for a link that leaked:
curl -s -X POST -H "X-Partner-Key: $KEY" $PK/subscribers/tg_123456789/reset
- The subscriber gets a new
sub_token, which comes back in the answer. The old hosted URL answers404from now on. - Every running session ends and leaves its node, and the subscriber's olcRTC keys are revoked at once.
- The subscriber stays enabled or disabled, as it was. The next fetch makes fresh credentials on every route it can start. Its olcRTC place is given up: the next
ghostlaneorjsonfetch needs a new place under your share and a room's capacity, with a new key, and comes without olcRTC lines until one is free. - A reset is not idempotent: each call issues a new token and ends the sessions again.
- The S2S path (
external_id) does not change. If you serve the subscription under your own URL and that URL leaked, replacing it is up to you.
9. Check usage
curl -s -H "X-Partner-Key: $KEY" \
"$PK/subscribers/tg_123456789/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z"
{
"external_id": "tg_123456789",
"from": "2026-09-01T00:00:00Z",
"to": "2026-10-01T00:00:00Z",
"bytes": 2147483648,
"bytes_up": 214748364,
"bytes_down": 1932735284,
"cost_nano": 24000000
}
For every subscriber at once:
curl -s -H "X-Partner-Key: $KEY" "$PK/usage?from=2026-09-01T00:00:00Z&to=2026-10-01T00:00:00Z"
- Window.
fromandtoare RFC 3339 timestamps, and the window is[from, to). WriteZ, or encode a+offset as%2B. By default the window is the last 30 days. Consecutive windows never overlap, so monthly reports add up. Iffromis afterto, the answer is400 INVALID_RANGE. - All subscribers.
/usagereturnstotal_bytes,total_bytes_up,total_bytes_down,total_cost_nanoand asubscriberslist of{external_id, bytes, bytes_up, bytes_down, cost_nano}. The list includes every subscriber you created, including disabled ones and ones with no traffic, busiest first. It is not paginated. - Cost.
cost_nanois what the traffic charged to your balance. It is 0 inside the free trial. - Balance. For the money you have left, read
balance_nanoon/account.
Subscribers in Russia
The full guide is Transports and Russia, with its recommendations for Russian mobile networks. In short:
- We have no measurements from inside Russian mobile networks. The advice below follows from what each transport is built to survive, not from results per mobile operator.
- Hand out the whole list: in step 3, keep the entry at All and all three transports, and narrow only the exits if you must. A subscriber whose line stops working switches to another line in the same list, with no call from your backend. Route filters in the dashboard says how to offer only the routes through the entry in Russia, or only direct ones, if you choose to.
- Start with the direct Reality lines. When an exit's direct lines fail or crawl, try the same exit's
… via RUline: the phone then connects to a server in Russia, and the route costs both nodes' prices plus the fee. - When Reality connects but stalls, try Hysteria2 where UDP passes (it runs over UDP), and XHTTP where it does not (it runs over TCP, on port 8444 by default).
- On days when only whitelisted services work, olcRTC in an app that runs it, such as Ghostlane, is what is left: it works where the video service it rides on is on the whitelist. See olcRTC for whitelist days; its price from 20 November 2026 is in What it costs. Subscribers who use only Happ, Hiddify or v2rayNG have no line for those days: olcRTC comes only in the
ghostlaneandjsonformats. - We do not rank exits for Russian networks. The node-level fallback is the entry in Russia (the
via RUlines), not a particular exit.
Trial, then prepaid
The free trial
ProofKit starts the trial and sets its three limits: an end date, a number of subscribers and a traffic allowance. The defaults are 7 days, 10 subscribers and 50 GB. trial on /account shows the limits and what you have used (step 1).
While trial.active is true:
- Traffic is free. Nothing is charged to your balance,
cost_nanois 0, and sessions start even with an empty balance. Steps 3 to 9 work (step 5 once subscriptions are switched on). - The subscriber cap applies. Creating a subscriber beyond
max_subscribersis refused with403 TRIAL_LIMIT. Disabled subscribers still count toward the cap. To add more subscribers during the trial, top up and ask ProofKit to end the trial.
When the trial ends
The trial ends as soon as any one of these happens:
untilpasses.bytes_usedreachesmax_bytes.- ProofKit ends it, or lowers
max_subscribersbelow the number of subscribers you hold.
Then:
- Charging starts.
trial.activebecomesfalse. From the next traffic report on, traffic is charged to your balance at each session's price (step 2): the exit'stotal_price_per_gb_nanoon a direct route, and the exit's plus the entry's, within a nanoton, on a route through an entry (olcRTC traffic, from 20 November 2026, at the olcRTC price). The report that uses up the allowance is the last free one. - Sessions keep running. Nothing restarts, and sessions keep running for as long as the balance stays above zero.
- At zero balance:
- The session whose traffic report took the balance to zero is disconnected at once, and the other active sessions on priced routes within about 15 seconds. Their lines stop working. A session still pending is closed once it turns active; one on a route priced at 0 keeps running and keeps its lines.
- From then on, every subscription fetch starts no new route, so a subscription whose sessions were all closed comes back with no lines (still
200) until you top up. The next fetch after a top-up makes new credentials on every route. POST …/andconnect POST …/answerolcrtc 400 INSUFFICIENT_BALANCE.
- Top up before the trial ends to avoid this.
- No subscriber cap. Prepaid has no cap on subscribers.
- Plan fields.
billing_plankeeps readingtrialuntil ProofKit closes the trial. After that it readsprepaid, and thetrialobject is gone. - No notice. Nothing is sent to you when the trial ends, so watch
GET /.account
Top up (prepaid)
The full rules are in Paying for your subscribers' traffic.
- Toncoin only, from your linked wallet. Top-ups go into the ProofKit escrow contract (
escrow_address) from your partner wallet. Link that wallet first, with TonConnect, in the partner dashboard.walleton/accountthen shows it. - Which wallet. Use a wallet that is not linked to any other ProofKit account, your own personal account included: such a wallet is refused with
TON_ADDRESS_TAKEN. Choose it with care. The link moves to another wallet only once nothing depends on the old one: no balance held under it in the escrow and no charges waiting to be paid out of it (WALLET_CHANGE_BLOCKEDotherwise). - How to send. Send either the escrow's Deposit message (the dashboard's Top up button sends it) or a transfer with the text comment
deposit.deposit_linkis exactly such a transfer. - Transfers that are not credited.
- A plain transfer without the comment is not credited to anyone. A transfer with any other comment is rejected.
- A transfer from any other wallet is not credited to your balance. That includes a withdrawal from an exchange straight to the escrow, whose sender is the exchange's wallet: move the TON to your linked wallet first, then deposit from there.
- USDT and other jettons sent to the escrow are not credited.
- Minimum. The contract refuses deposits under 0.05 TON. The dashboard asks for at least 0.1 TON.
- Crediting. Our payment checker reads the escrow every 30 seconds.
balance_nanorises once the checker has seen the deposit. If a deposit from the linked wallet has not appeared 15 minutes after its transaction was confirmed, write to support with the transaction hash. - Withdrawing. The part of your deposit that your subscribers' traffic has not spent can be withdrawn to the linked wallet from the dashboard. See Withdraw the unspent deposit.
If something is off
Open a ticket under Support on proofkit.org, signed in as your owner account, and quote the call's X-Request-ID. The full list of codes is in Error codes.
| You get | Why |
|---|---|
401 INVALID_PARTNER_KEY | The key is missing, wrong or revoked, a rotated-out key is past its 24 hours, or the partner is disabled. |
400 PARTNER_NOT_BILLABLE | There is no billing account yet. ProofKit has to set your terms. |
400 INVALID_EXTERNAL_ID | The external_id is empty, or longer than 128 bytes once trimmed (step 4). |
400 INVALID_FORMAT | format is not happ, ghostlane or json (step 5). |
400 INVALID_RANGE | from is after to (step 9). |
400 TORRENT_GUARD_COOLDOWN | Torrent Guard ended the subscriber's session on that exit in the last hour. Every exit runs it today: once a session has uploaded more than 30 GB, it is ended if it has uploaded more than one and a half times what it downloaded, whatever the traffic is. For an hour that exit then refuses the subscriber, and its subscription leaves that exit's routes out. See Torrent Guard in the partner terms. |
400 INSUFFICIENT_BALANCE | From POST …/ or POST …/: the balance is at zero and no trial is active. A subscription fetch never answers this; it leaves the routes out. |
403 TRIAL_LIMIT | The trial's subscriber cap is reached. |
403 SUBSCRIPTION_NOT_ENABLED | Subscriptions are not switched on for your partner. |
403 OLCRTC_NOT_ENABLED | From POST …/: olcRTC is not switched on for your partner. Nothing was created. |
503 NO_OLCRTC_NODE | From POST …/: no room has a place for the subscriber, your subscribers' keys that count as present fill your share, or the subscriber holds its olcRTC place on another exit than the one asked for. Fall back to another transport; retry later. |
404 NOT_FOUND on the subscription | The subscriber is unknown or disabled. |
404 NOT_FOUND "Unknown partner API endpoint" | The path does not exist. |
405 METHOD_NOT_ALLOWED | The path exists, but not with this method. |
429 RATE_LIMITED | Wait Retry-After seconds when it is present. The per-IP limits (6,000 a minute on /api/, 60 a minute on the hosted /sub/ URLs) send no Retry-After: wait a minute. |
| Fewer lines than expected | Your filters leave them out (step 3); a node is offline, full or overloaded; the subscriber already holds 50 sessions; Torrent Guard ended its session on that exit in the last hour; the 5-second budget ran out (spread a bulk import's first fetches over time); or, for olcRTC lines only, your olcRTC share or your part of a room is reached, or the subscriber's olcRTC place is on an exit your filters have since left out (end its sessions on that exit to move it: see olcRTC in the subscription). Fetch again, and check /account. |
No lines at all (200) | The balance is at zero (or the trial is over with no balance): top up, and the next fetch makes new lines. Or your filters keep no route: the preview in step 3 then says "No route matches these filters yet." On the hosted URL, it can also mean subscriptions are not switched on yet. |
02 /Partner API
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 free trial, 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.
There is no self-serve sign-up. To get access or a free trial, sign in at proofkit.org (or
create a free account) and open a ticket under Support,
category "Other", with Partner API in the subject: what you run, the countries and
transports you need, the traffic you expect, and which account will run your partner
dashboard (one that signs in with an email address, not only with Telegram, and runs no
other partner). ProofKit sets up your partner and, in doing so:
- creates your partner record together with the billing account your subscribers' traffic is charged to;
- issues your first API key (shown once). While your partner has no key, the owner can also create it on the Partner page with Create key;
- attaches the email account that owns the partner. That account signs in on
proofkit.org with two-factor sign-in (authenticator app or Telegram codes) and gets a
Partner page (
#/) with the plan, trial, balance, 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 - starts a free trial if one is agreed (see Billing);
- switches on the subscription for your partner, if you use it (see
GET /), and olcRTC, if agreed, with your olcRTC share. The 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.subscribers/ {external_id}/ subscription
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. 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, 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 |
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 |
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,
"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 + referral credits − traffic charges − withdrawals. The connect gate, the session enforcer and the Partner page also subtract stake locked in nodes your billing account owns (none unless it runs nodes). |
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),
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"],
"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, olcrtc. |
olcrtc_carriers | The olcRTC carriers the node serves right now, Telemost first: any of telemost, wbstream, 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, 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 and VK Calls; SaluteJazz needs none), our rollout covers the
node, and, for SaluteJazz and VK Calls, its agent can run the carrier. 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, 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" }
{
"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
}
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.
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 |
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. 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. 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…, which does what
PATCH / does: it sends only what
was changed, so moving the date does not start the quota's allowance again.
| 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. 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;- 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.
A field does not touch the others: naming only exit_countries or expires_at 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 |
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 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 and created: false. 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 or olcrtc; 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, 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.
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" or " · XHTTP" 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 and vless_xhttp, none twice, or null | null, all 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. - 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
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.
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;
- 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 and XHTTP. All three checked is the same as no filter; at least one must be;
- 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 nothing else: no node is sent anything and no session ends. Each subscriber's next fetch serves the new settings (see Route filters for a route that leaves the body). You can save before subscriptions are switched on.
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. 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_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 nine fields are yours: the filters, the branding and the setup page's switch.
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 nine 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_hoursandsub_setup_pagecannot 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) 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) 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 nine 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.
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, 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"," · SJ"or" · VK"appended for WB Stream, 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 |
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 |
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").
/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, 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 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
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", "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
(see Price under the catalogue). A repeated connect that returns the session returns
its current price. platform_fee_nano is the fee part of that price; after a drop 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 |
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 serves Telemost
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 serves Telemost, 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
serves Telemost 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 place on an origin that serves WB Stream, SaluteJazz or VK Calls but not Telemost gets this503on every call; the subscription still carries its lines. - 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 serve Telemost, 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").
Origins that serve no Telemost room (only WB Stream, SaluteJazz or VK Calls) are never chosen here.
olcrtc_url is the Telemost line. 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 serving Telemost (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 Telemost 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,salutejazzorvkcalls.<transport>:vp8channelfor Telemost, WB Stream 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
our BitTorrent detection does not see it; for olcRTC these figures are the only signal.
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.
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 |
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 or expires_at is idempotent. |
DELETE / | …get 204 again. |
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, 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: 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".
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; it does not end your subscribers' sessions by itself:
- Your key answers
401 INVALID_PARTNER_KEYand the Partner page answers404 NOT_A_PARTNER. - Running sessions keep running and keep being charged to your billing account, now as a
plain prepaid payer: your credit limit, the
exemptplan and a trial no longer apply. Onexemptor in a trial with a balance at zero or below, everyactivesession on a priced route is closed within about 15 seconds. With a positive balance they keep running and draining it until it reaches zero, and then they are closed. - Your subscribers' hosted
/sub,/unifiedand/olcrtcURLs fall back to our standard unbranded lists (see Hosted subscription URLs). - Your deposit stays yours: write to us to withdraw it.
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
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, or a disabled partner |
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 / |
QUOTA_EXHAUSTED | 403 | POST /, POST …/, POST …/ |
SUBSCRIPTION_EXPIRED | 403 | POST /, POST …/, POST …/ |
INVALID_LIMIT | 400 | 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, FIELD_NOT_EDITABLE) are under
Subscription settings.
03 /Paying for your subscribers' traffic
This page is for resellers who use the Partner API. It explains how a partner pays for its subscribers' traffic: the free trial, the partner dashboard, linking a wallet, topping up, what a GB costs, what happens at zero balance, and how to withdraw what you did not spend.
Units. Every amount in the API is an integer number of nanoTON: 1 TON = 1,000,000,000 nano. "GB" always means GiB, 1,073,741,824 bytes, with upload and download added together.
Paths. The Partner API (server to server, with your API key) is under
https://proofkit.org/. The partner dashboard's API (signed in as the owner)
is under https://proofkit.org/. This page gives full paths.
Support. Where this page says "write to support": open a ticket under Support on proofkit.org while signed in as the partner's owner account.
See also. Partner quick start, Partner API reference, Transports and Russia, Without code: the dashboard, and the binding Partner Terms.
At a glance
| Question | Answer |
|---|---|
| How you pay | Prepaid, in TON only. The partner wallet you link deposits into the ProofKit escrow contract, and your subscribers' traffic is charged to your partner balance per GB. Only the dashboard's top-up (the contract's Deposit message) or a transfer from the linked wallet with the comment deposit is credited. A plain transfer is credited to no one. There is no credit, no invoice and no other payment method. |
| What else is charged | Nothing. There is no API fee, no monthly or set-up fee and no minimum spend. You pay only for traffic, per GB. Your wallet pays TON's own network fees. |
| Test access | A free trial with limits, set by ProofKit. The defaults are 7 days, 50 GB and 10 subscribers. It needs no wallet and no deposit. |
| Price per GB | The node's price plus the 20% network fee. The catalogue gives both: price_per_gb_nano (without the fee) and total_price_per_gb_nano (what you pay). olcRTC traffic has a price of its own from 20 November 2026 (see The olcRTC price). |
| Smallest top-up | 0.1 TON in the dashboard. The escrow contract itself refuses anything under 0.05 TON. |
| Minimum balance | None to keep. Outside a free trial, new sessions need a balance above zero. |
| Time to credit | Usually under a minute after the transaction confirms. |
| At zero balance | New sessions are refused. The session whose traffic took the balance to zero is disconnected at once, and every other running session you pay for within about 15 seconds. |
| Withdrawing | The unspent part of your deposit, to your linked wallet only. ProofKit co-signs each withdrawal and your wallet sends it. |
1. Trial, then prepaid
Every partner is on one plan at a time. GET / reports it in
billing_plan, and the dashboard shows it.
billing_plan | What it means |
|---|---|
trial | Traffic is free while every trial limit holds. Past any limit you are on prepaid terms. |
prepaid | Every GB is charged to your balance. The balance never goes on credit. |
exempt | ProofKit covers the traffic and nothing is charged. Only ProofKit sets this plan. |
The free trial
ProofKit starts the trial and sets its three limits: an end date, a traffic allowance in GB and a subscriber cap. The defaults are 7 days, 50 GB and 10 subscribers. Ask for a trial when you request partner access (see Getting access). A trial needs no wallet and no deposit: a backend needs only its API key, and a partner that adds subscribers by hand only the dashboard. ProofKit can extend a trial or end it early.
While the trial runs:
- Traffic costs you nothing. No charge is written to your balance, and
cost_nanoin usage reports is 0. - Sessions start even with an empty balance.
- Each GB your subscribers move on a priced node is counted against the allowance.
- The cap counts every subscriber you have created, disabled ones included.
DELETE /disables a subscriber but does not free its place.api/ v1/ partner/ subscribers/ {external_id} - Creating a new subscriber at the cap answers
403 TRIAL_LIMIT. This comes fromPOST /, fromapi/ v1/ partner/ subscribers POST /with an external ID you have not used before, and from the dashboard's "add a subscriber by hand". Re-provisioning a subscriber you already have, disabled or not, is never refused.api/ v1/ partner/ subscribers/ {external_id}/ olcrtc - To add subscribers past the cap, top up and ask ProofKit to end the trial early. After the trial there is no subscriber cap.
- You can top up. Nothing is taken from the balance until the trial ends.
The trial ends as soon as any limit is passed: the end date arrives or the allowance is used up. It also ends if you hold more subscribers than the cap, which can happen only if ProofKit lowers the cap. You are then on prepaid terms from the next traffic report. Nothing restarts, and no session is dropped because of the switch itself. The report that crosses the traffic allowance is the last free one, so usage can go slightly past it.
Top up before the trial ends. If your balance is zero when the trial ends, your subscribers' sessions are closed within about 15 seconds and new ones are refused, exactly as described in section 7. ProofKit sends your backend no warning when a trial is about to end or has ended.
To follow the trial, read the trial object on GET / (or on the
dashboard's GET /):
"trial": {
"until": "2026-10-03T12:00:00Z",
"max_subscribers": 10,
"subscribers": 3,
"max_bytes": 53687091200,
"bytes_used": 4563402752,
"active": true
}
active: true means traffic is free right now. active: false means a limit was passed and
you are paying. trial is absent (or null on /me) when you are not on a trial plan.
Note on billing_mode: this older field on GET / keeps its
published meaning. It reads "free" only when ProofKit covers your traffic (the exempt
plan) and "metered" otherwise, including during a trial. Use billing_plan and
trial.active to tell a free trial from paid traffic.
2. The partner dashboard
Linking a wallet and withdrawing happen only in the partner dashboard. The Partner API key cannot move money.
Who can open it. ProofKit attaches your partner to one ProofKit account, its owner:
- The owner is an account that signs in with an email address. A Telegram-only account cannot be the owner, but an email account can link Telegram for two-factor codes.
- One account runs at most one partner.
- When ProofKit names the owner, that account is signed out everywhere. Sign in again.
How to get there. Sign in on proofkit.org with the owner account, then open
https://proofkit.org/. The site navigation shows a link to it for the owner
account.
Two-factor sign-in is required. Turn on an authenticator app or Telegram two-factor codes
for the owner account. Without either, every dashboard request is refused with
403 TWO_FACTOR_REQUIRED, and the page asks you to turn one on before it continues.
Every dashboard action works on your partner's billing account, never on the owner's personal ProofKit balance or wallet.
The dashboard shows:
- your plan, and the trial's use while it runs;
- the balance, and what traffic cost per day over the last 30 days;
- when your subscribers moved olcRTC traffic in those 30 days, a line under that chart: how
much it was, how much of the charges was at the olcRTC price once any traffic was charged
at it, and, while the last step of the olcRTC price schedule names a price and has not
started, what the same traffic costs at that step (left out on the
exemptplan, where nothing is charged); - the number of subscribers you have provisioned, disabled ones included;
- the partner wallet, top-up and withdrawal;
- the deposits and withdrawals made so far;
- your API key prefix and key rotation, and the per-key rate limits;
- the Subscription tab: whether subscriptions are on for you, and the subscription's branding, its route filters and the setup page's switch, which you edit there (see Subscription settings);
- when olcRTC is on for you, the olcRTC figure "in rooms / your share": how many of your
subscribers' olcRTC keys count as present now against your share. A key counts as present
while its subscriber has moved traffic through that exit in the last 5 minutes on any
line, Reality included, not only olcRTC (see olcRTC under
GET /). The share binds only once subscriptions are switched on for you; until then the figure is for information.catalogue
3. Link the partner wallet
Before the first top-up, connect the wallet that will pay and link it to the partner.
Linking uses a TonConnect ton_proof: your wallet signs a one-time challenge, which proves
that you control it. Use a wallet app that supports TonConnect.
Rules:
Link from the dashboard on proofkit.org. The proof names the site's domain, and a challenge is valid for 15 minutes and for one attempt.
TON mainnet only. A wallet on another network is refused (
WRONG_NETWORK).One wallet belongs to one ProofKit account. A wallet already linked to any other account is refused (
TON_ADDRESS_TAKEN), and that includes the owner's own personal account. Use a wallet kept for the partner.Choose the wallet with care. The link moves to another wallet only when nothing depends on the old one (
WALLET_CHANGE_BLOCKEDotherwise). Two things must hold:- every traffic charge from your subscribers has been paid out to the operators from the old wallet's escrow deposit — apart from less than 0.001 TON owed to any one operator, which is too little to claim and does not hold the link (it is claimed later, once more traffic adds to it, from the wallet of your latest top-up);
- less than 0.001 TON of your balance is still held under the old wallet in the escrow.
Withdrawing alone is not enough. Operators are paid out when they claim the charges on-chain. ProofKit prepares those claims every hour, and a charge counts as paid only once its claim is on-chain. While your subscribers use the network, new charges keep coming. So a change takes these steps: withdraw what you can to the old wallet (this closes your subscribers' sessions, section 7), wait until the remaining charges have been paid out, withdraw the rest, then link the new wallet. Write to support before you start, and ProofKit tells you when the change can go through. If the escrow cannot be read at that moment, the link also answers
WALLET_CHANGE_BLOCKED, asking you to try again in a minute. There is no way to unlink a wallet from the dashboard.If the wallet has never been used on-chain and its key cannot be read, linking answers
WALLET_KEY_UNKNOWN. Send one transaction from the wallet, then connect it again.
4. Top up
A top-up is a deposit into the ProofKit escrow contract from the linked wallet. The contract keeps each deposit under the wallet that sent it, and ProofKit credits your balance because that wallet is linked to your partner.
Two ways to send it
- From the dashboard. Enter an amount (0.1 TON or more) and confirm in your wallet. The
wallet sends the contract's
Depositmessage. If the wallet you connect is not the linked one, the dashboard links it first, under the rules of section 3. - From any app that holds the linked wallet. Send TON to the escrow address with the
text comment
deposit: exactly that word, lower case, nothing else.deposit_linkonGET /and onapi/ v1/ partner/ account GET /is this transfer as aapi/ v1/ partner-portal/ me ton://transfer/link. It is<escrow>?text=deposit nulluntil a wallet is linked. The escrow address is also given inescrow_address.
The whole amount the escrow receives is credited. Your wallet pays its usual network fee on top. Keep about 0.1 TON in the wallet after a top-up: a later withdrawal is sent from this same wallet and needs at least 0.08 TON for its gas, and the dashboard warns you when a top-up would leave less.
What is not credited
Only two messages count: the Deposit message and a transfer with the comment deposit,
both sent from the linked wallet. Everything else is not credited:
- A plain transfer with no comment. The contract keeps it for nobody, and your balance does not change. Only ProofKit can recover it, by hand: write to support with the transaction hash.
- A transfer with any other comment. The contract rejects it.
- Less than 0.05 TON. The contract rejects it.
- Any deposit while the contract is paused. The contract rejects it. While it is paused, co-signed withdrawals are refused too (section 8).
- A transfer from any other wallet. It becomes that wallet's deposit on-chain, never the partner's. If another ProofKit account has linked that wallet (your owner's personal account, for example), it is credited to that account's balance. Otherwise it is credited to no one. This includes a withdrawal from an exchange straight to the escrow: the sender is the exchange's wallet, not yours. Move the TON to your linked wallet first, then deposit from there. TON credited to another account can reach the partner only if that account withdraws it and the linked wallet deposits it again.
When the contract rejects a transfer. A bounceable transfer comes back to the sending wallet, minus network fees. The dashboard's top-up is always sent bounceable. A non-bounceable transfer that fails stays in the contract, credited to no one: write to support with the transaction hash.
Link first, then deposit. A deposit sent before its wallet was linked is picked up once the wallet is linked, but only while it is still among the escrow's 50 most recent transactions (see below). The escrow serves the whole network, so that can pass quickly. After that, ProofKit has to credit it by hand.
How long it takes
ProofKit checks the escrow for new deposits every 30 seconds. Once your transaction is confirmed on TON, the balance normally rises within a minute. When the TON data provider limits our requests, the checks slow down, with at most about 8.5 minutes between two checks, until it recovers.
Each check reads only the escrow's 50 most recent transactions. This applies to every deposit, from a linked wallet too: if more than 50 escrow transactions pass between two checks, a deposit can be missed, and ProofKit then credits it by hand.
If a deposit sent from the linked wallet has not appeared after 15 minutes, write to support with the transaction hash.
A credited deposit shows in the balance (balance_nano on /account and /me) and in the
dashboard's list of payments (GET /).
5. What a GB costs
A route leaves through an exit node, called the origin in the API. A two-hop route enters through a second node first, the edge. The price of one GB on a route is fixed when the session starts (olcRTC traffic aside, from 20 November 2026: see The olcRTC price):
total per GB = (origin price + edge price) + 20% of that sum
On a direct route the edge price is 0. On a two-hop route, for example one that enters through an edge in Russia, you pay for both hops, and the fee is added once to their sum.
GET / lists every node you may use with:
| Field | Meaning |
|---|---|
price_per_gb_nano | The node's price for you, without the network fee. If ProofKit agreed a price for a node with you, this is that price. |
total_price_per_gb_nano | What a GB through this node costs you: price_per_gb_nano plus the fee. This is the number to plan with. |
fee_bps | The network fee in basis points: 2000 = 20%. |
entry_region | "RU" on an edge in Russia; absent elsewhere. |
A two-hop route costs the origin's total_price_per_gb_nano plus the edge's, within one
nanoTON.
Example prices (read the catalogue for real ones):
| Route | Node prices | You pay per GB | 1.5 GB costs |
|---|---|---|---|
| Direct | origin 10,000,000 | 12,000,000 (0.012 TON) | 18,000,000 |
| Two hops | origin 10,000,000, edge 10,000,000 | 24,000,000 (0.024 TON) | 36,000,000 |
Before you have a key. The Marketplace on proofkit.org (https://proofkit.org/)
shows the public nodes' prices with the 20% fee already included. That is what you pay on a
node where ProofKit has not agreed a different price with you.
Agreed prices. An agreed price is never below the node's own price, and the 20% fee is the same for every partner.
When the charge is made. Each running session reports its traffic about once a minute, and each report is charged at once: bytes × total per GB ÷ 1,073,741,824. A report under 1 MiB (1,048,576 bytes) waits and is added to the next one.
Price changes. For Reality, Hysteria2 and XHTTP traffic, a session keeps the price it started with, with one exception: if a node's own price later goes down, running sessions follow it down. If it goes up, running sessions keep the old price, and only new sessions pay the new one. On a node where ProofKit agreed a price with you, sessions follow the agreed price instead, so a change to the node's own price does not move them. If ProofKit changes your agreed price, count on the new price only for sessions started after the change. olcRTC traffic follows its own rule, below.
Where you see the price and the cost.
POST /returnsapi/ v1/ partner/ subscribers/ {external_id}/ connect total_price_per_gb_nano, the session's price per GB with the fee, andplatform_fee_nano, the fee part of that price. After a price drop, a connect that returns a running session gives the lowered total and the lowered fee.- In
GET /,api/ v1/ partner/ subscribers/ {external_id}/ sessions price_per_gb_nanois the session's price per GB with the fee. The catalogue field of the same name is the price without the fee. GET /andapi/ v1/ partner/ usage GET /give each subscriber'sapi/ v1/ partner/ subscribers/ {external_id}/ usage cost_nanofor a period, andGET /also givesapi/ v1/ partner/ usage total_cost_nano.
The olcRTC price
From 20 November 2026, olcRTC traffic has a price of its own. Until then it costs what the session costs, like the other transports. The new price is one price per GB for the whole network, whatever the exit or the route, and it is the payer's price, with the 20% network fee included. It is set and charged in TON, at the amounts below; the dollar figures are approximate, at 1 TON = $1.44. If the TON rate moves by more than 10 %, we may adjust the TON amounts; a rise is announced 30 days ahead, like any rise. A GB is 1,073,741,824 bytes, upload and download together.
| From (00:00 UTC) | olcRTC, per GB | In TON | In nanoTON |
|---|---|---|---|
| Until 20 November 2026 | The route's own price, as today | — | — |
| 20 November 2026 | About $0.03 | 0.0208 TON | 20,800,000 |
| 8 December 2026 | About $0.05 | 0.0347 TON | 34,700,000 |
| 22 December 2026 | About $0.08 | 0.0556 TON | 55,600,000 |
The rules:
- It applies when the traffic moves. The olcRTC price is read at every charge, and a session never locks it in. The other transports keep their rule: a session keeps the price it started with, or a lower one after a drop, and that does not change.
- A rise is announced at least 30 days before it starts. A cut may start at once. The
announcement is in
olcrtc_pricingonGET /and in a support ticket.account - A route through the entry in Russia adds nothing to it. olcRTC never passes the entry
node, so a GB of olcRTC costs the same on a direct route and on a
via RUroute. - The olcRTC part of a charge goes 4/5 to the exit node's operator and 1/5 to the network, as any charge does.
- The free trial covers olcRTC. While a trial is active, nothing is charged for olcRTC traffic either.
- A route priced at 0 stays free. Its olcRTC traffic is not charged either.
Your share grows with your olcRTC traffic. Your olcRTC share is the larger of two numbers: a fifth of the network's olcRTC places, rounded up, and 8 places plus one for every 15.625 GiB of olcRTC traffic your subscribers moved in the last 30 days ($1.25 of traffic at the top price of about $0.08 per GB, counted in bytes so the TON rate does not move it). Traffic inside a free trial counts. A share ProofKit agrees with you replaces this one. The share, and your part of each exit's keys, are explained in Transports and Russia.
Reading the schedule. olcrtc_pricing on GET / has two fields.
total_price_per_gb_nano is the price in force now, with the network fee, and is null while
olcRTC is still charged at each session's own price. schedule lists every step, oldest
first, each with effective_from and total_price_per_gb_nano: the steps in force, the steps
past and the steps announced. From 12 October 2026 it holds the three steps above. To
reconcile a window, cut it at the effective_from dates.
The usage reports give each subscriber olcrtc_bytes, the part of bytes that went through
olcRTC, and olcrtc_cost_nano, the part of cost_nano charged at the olcRTC price. They also
give olcrtc_list_cost_nano: the same bytes at the price of the last step of the schedule (the
step of 22 December 2026 while it holds the three steps above), whether or not its date has
come. That is a quote, not a charge. The dashboard's olcRTC line, under the charges chart,
reads the same way: your subscribers' olcRTC traffic of the last 30 days, what was charged at
the olcRTC price and, until the last step starts, what that traffic costs at it. See
GET /.
6. The balance and the charges
Your balance is: deposits − traffic charges − withdrawals.
- The dashboard shows the balance, and what your subscribers' traffic cost per UTC day over
the last 30 days (
charges_30donGET /: one entry per day with a charge, oldest first).api/ v1/ partner-portal/ me - Operators are paid from your escrow deposit afterwards. An operator's claim takes the charge from your deposit on-chain, and the contract splits it 80% to the operator and 20% to ProofKit. The claim does not change your balance, because the charge was already booked when the traffic moved. For a while, the escrow therefore holds more than your balance. The withdrawal limit (section 8) accounts for the difference.
Quotas do not cap the bill. A subscriber's quota_bytes is a control the platform
enforces (sessions end when the allowance is used), not a boundary on what is charged:
every byte that moved is billed as it always was, including the overshoot that lands
while a cut is on its way. The quota's used bytes and your invoice read the same metered
intervals, so they can never disagree about the same traffic.
Watching the balance from your backend. ProofKit does not notify your backend when the
balance runs low or reaches zero. Poll GET /: balance_nano is the
figure the connect check uses. That response also has credit_limit_nano. The check is
balance_nano > -credit_limit_nano, and on the prepaid plan credit_limit_nano is always 0,
so a new session needs a balance above zero. The dashboard shows a warning when you pay for
traffic and the balance is under 1 TON.
7. When the balance reaches zero
"Zero" means a balance of 0 or less. On the prepaid plan there is no credit.
- New sessions are refused.
POST /andapi/ v1/ partner/ subscribers/ {external_id}/ connect POST /answerapi/ v1/ partner/ subscribers/ {external_id}/ olcrtc 400 INSUFFICIENT_BALANCEon a priced route (a route priced at 0 still connects). A call for a route the subscriber still holds returns that session until the enforcer closes it (step 3). A subscription fetch, throughGET /or the hosted URL, starts no new routes. The routes it would have started are left out of the answer.api/ v1/ partner/ subscribers/ {external_id}/ subscription - The session whose report took the balance to zero is ended on that report, and its credentials are removed from the node at once, so its line stops working.
- Every other active session you pay for on a priced route is closed within about 15 seconds. A session still pending (its node has not confirmed it) is closed once it turns active; one on a route priced at 0 keeps running. The session enforcer runs every 15 seconds. It checks your balance once more just before it acts, so a top-up that arrived in the meantime keeps the sessions. For each session it closes, it removes the subscriber's credentials from the node and then sends the node its full list of live sessions. This covers sessions started through the API and through a subscription alike. Your subscribers' olcRTC keys are withdrawn at the next room check, which runs about every 5 minutes.
- The balance can end slightly below zero. Charges are booked as reports arrive, so the traffic reported in those last seconds is still charged. Your next deposit covers it first.
After a top-up, nothing reconnects by itself. Any positive balance lets sessions start again:
- With the API, connect the subscriber again.
- With a subscription, through the API or the hosted URL, the next fetch starts the missing routes. Clients fetch again at the subscription's update interval (1 hour by default), and a subscriber can also refresh by hand.
- A new session gets new credentials, so the lines a subscriber had before stop working, and a refreshed subscription carries the new ones.
Charges arrive every minute from every running session. A small balance lasts only a short time, so keep enough to cover your peak traffic between two top-ups.
If ProofKit suspends your partner
A suspension switches your partner off without ending your subscribers' sessions:
- The Partner API key answers
401 INVALID_PARTNER_KEY, and the dashboard answers404 NOT_A_PARTNER. - Running sessions keep being charged to your billing account, as a plain prepaid payer:
your credit limit, the
exemptplan and a trial stop applying. Onexemptor in a trial with a balance at zero or below, your subscribers' active sessions on priced routes are closed within about 15 seconds. With a positive balance they keep running, and keep spending it, until it reaches zero; then they are closed as in step 3 above. - Your subscribers' hosted subscription URLs fall back to our unbranded standard lists.
- Your deposit stays yours: write to support to withdraw what the balance does not owe.
8. Withdraw the unspent deposit
You can take back the part of your deposit that your subscribers' traffic has not spent.
Which wallet. Only the linked wallet. ProofKit signs each withdrawal for that wallet's address. The contract accepts the signature only from that wallet, takes the amount from the deposit held under it and pays it back to that same wallet. A withdrawal cannot be sent to any other address.
Steps.
- In the dashboard, enter an amount. The dashboard shows what is in the escrow, what is pending and what can be withdrawn now.
- ProofKit checks the limit and signs an approval, which is valid for 5 minutes
(
POST /).api/ v1/ partner-portal/ wallet/ withdraw-approve - Your wallet sends the signed
Withdrawmessage to the escrow. It attaches 0.05 TON for network fees, and the contract returns the unused part to the wallet as a separate "ProofKit gas refund" message. Keep at least 0.08 TON in the wallet for this; the dashboard checks before it asks for an approval. - The contract pays the amount to your wallet with the comment "ProofKit withdrawal". ProofKit sees it at its next check, usually within a minute, and deducts it from your balance.
The limit. GET / returns
max_withdrawable_nano:
max_withdrawable = min( balance,
deposit held in the escrow under your wallet − pending )
pending = traffic charges not yet paid out to operators
+ operator claims signed but not yet on-chain
+ withdrawal approvals issued in the last 10 minutes
(the last hour when the escrow's figure cannot be read)
The response also gives on_chain_deposit_nano (the escrow's own figure for your wallet,
or null if it could not be read; ProofKit then estimates it from its records),
pending_charges_nano, vpn_balance_nano and estimated_on_chain_nano. Normally the
limit equals your balance. It is lower while a claim or an earlier withdrawal is in flight.
Other limits.
The dashboard's smallest withdrawal is 0.05 TON.
ProofKit's signer refuses a withdrawal in three cases:
- it is over 100 TON (split larger amounts);
- it would pass the signer's daily ceiling, currently 1,000 TON per UTC day, shared by every withdrawal and operator payout on the network;
- more than 10 signing requests arrive within a minute (withdrawals and operator payouts count together). Try again a minute later.
A refusal answers
500 INTERNAL_ERRORwith a message that startssigning:. The approval is recorded before the signer is asked, so the amount you asked for still counts as pending for 10 minutes (an hour while the escrow's figure cannot be read). Before you withdraw several hundred TON in one day, write to support.An approval that your wallet does not use within its 5 minutes expires, and its amount counts as pending for 10 minutes after it was issued (twice its lifetime, a margin for clock differences). If you cancel the prompt in your wallet, the amount is back in the limit 10 minutes after you asked for the approval. While the escrow's figure cannot be read (
on_chain_deposit_nanoisnull), an approval counts as pending for an hour instead: until ProofKit's payment checker books a withdrawal, its records cannot tell one that went through from one that did not.Withdrawing everything is the same as running out. Once the withdrawal is booked, your balance is zero and your subscribers' sessions are closed (section 7).
If ProofKit cannot co-sign. While the escrow contract is paused, it refuses deposits and
co-signed withdrawals. Once it has been paused for a net 30 days, every depositor can take
its whole deposit out without ProofKit's signature. The wallet that holds the deposit sends
the contract's SelfWithdraw message: a message body of only the 32-bit op code
0xf1a2b3c4. The contract must still be paused when it arrives. Attach about 0.05 TON for
gas, as for a withdrawal; the unused part comes back. The dashboard has no button for this.
"Net" means that paused time counts up and time running unpaused counts it back down,
second for second. So unpausing and pausing again does not restart the count, but the
unpaused time in between is taken off it.
9. Reference
Dashboard API
These routes serve the dashboard. They need the owner's signed-in session (a JWT access
token in Authorization: Bearer …), with two-factor sign-in turned on. They do not accept
the Partner API key. Base: https://proofkit.org/.
| Method and path | What it does |
|---|---|
GET / | partner_id, name, the plan (billing_mode: exempt / trial / prepaid, as stored), trial, balance_nano, wallet, escrow_address, deposit_link, subscribers (disabled ones included), api_key_prefix, previous_key_expires_at (while a rotated-out key still works, else null), rate_limit_per_min (Partner API requests per minute per key), subscription_rate_limit_per_min (subscription fetches per minute per key), olcrtc ({in_use, share}: your subscribers' olcRTC keys that count as present now, and your share, which binds only once subscriptions are on; null while olcRTC is off for you), charges_30d, olcrtc_30d (your subscribers' olcRTC traffic over the same 30 days: bytes; cost_nano, what it was charged at the olcRTC price, 0 while none was in force and for traffic that was not charged (a trial, the exempt plan); list_effective_from and list_total_price_per_gb_nano, the last step of the olcRTC price schedule, when it names a price (a date, and a price per GB with the network fee); list_cost_nano, bytes at that price, what the same traffic costs once the step is in force (a list quote, not a charge: nothing is charged on the exempt plan or inside a trial); the three list_ fields are null when the schedule is empty or its last step names no price) |
GET / | {payload, expires_in: 900}: a one-time challenge for the wallet's ton_proof, valid 15 minutes |
POST / | {address, network, state_init, proof}, from TonConnect's account.address, account.chain, account.walletStateInit and tonProof.proof → {"linked": true} |
GET / | Deposits and withdrawals, newest first, at most the latest 100 |
GET / | The withdrawal limit and its parts (section 8) |
POST / | {amount_nano, user_ton_address} → {amount_nano, nonce, valid_until, sig_r, sig_s}, valid 5 minutes. user_ton_address is the linked wallet in raw form: 0: followed by 64 hex digits. A friendly EQ… or UQ… address is refused (INVALID_ADDRESS). |
POST / | A new Partner API key, shown once: {api_key, api_key_prefix, previous_key_expires_at}. The key it replaces keeps working for 24 hours. A key rotated out earlier than that stops at once. At most 5 rotations an hour. |
GET / | The subscription's settings: the route filters and branding you set, whether a subscriber's link shows a setup page in a browser, whether subscriptions and olcRTC are on, your olcRTC share, and a preview of the first line names. See Subscription settings. |
PUT / | Changes any of the filters, the branding fields and the setup page's switch the body names (left out = unchanged, null clears a field that can be empty) and answers as GET /. At most 30 writes an hour. |
GET / | The partner's subscribers a page at a time, newest first: limit (1 to 200, 50 when left out), cursor (the last answer's next_cursor), q (matches anywhere in the external_id, whatever the case), state (active, expired, used_up or disabled) and order (newest or oldest) → {subscribers, next_cursor, counts: {all, active, expired, used_up, disabled}}. The counts are of all the partner's subscribers, whatever q says. These parameters are the dashboard's own: the Partner API's GET / keeps its four. |
POST / | {external_id, quota_bytes?, exit_countries?, expires_at?, only_new?} → the subscriber as the dashboard lists it (traffic, last seen, its limits, happ_url, ghostlane_url) and created: 201 when new, 200 when it existed: it is enabled again if it was disabled, and the limits you name are written on it — a quota_bytes among them starts its allowance again from now — while a limit you leave out stays as it was. POST /' rules: INVALID_EXTERNAL_ID, TRIAL_LIMIT, and for the three limits INVALID_QUOTA, INVALID_EXIT_COUNTRIES, INVALID_EXPIRES_AT; 409 SUBSCRIPTION_NOT_ENABLED until ProofKit switches your subscriptions on. With only_new: true, a number that exists is answered 200 with left_as_is: true and nothing is done to it: the dashboard asks before it enables one, because its old link would work again. Adds are counted in subscribers, at most 300 an hour, this call one. |
POST / | {external_ids: [1 to 100], quota_bytes?, exit_countries?, expires_at?} → {results: [{external_id, outcome, code?, message?}]}, a line a number, in order: created (with the limits), existed (left exactly as it is: not enabled, not given the limits) or refused. Everything is checked before anybody is made: INVALID_BATCH for none or more than 100; INVALID_EXTERNAL_ID and DUPLICATE_EXTERNAL_ID name the line (a number is 1 to 128 bytes and holds no tab or other control character); the limits refuse as above; 409 SUBSCRIPTION_NOT_ENABLED as the single add. A trial at its cap ends the batch there: the numbers after it that would be made are refused with TRIAL_LIMIT, and the ones that exist are existed. A fault of ours ends it with NOT_ADDED. The batch runs to its end whatever becomes of the request. It spends one of the hour's adds for each number it names, and a batch that does not fit is refused whole (429). No link is in the answer. |
GET / | One subscriber's card: the row as the list gives it, with days — [{day, bytes, cost_nano}], its metered traffic on the 30 UTC days ending today, oldest first, a day without traffic left out — and routes — [{exit_country, entry_country, bytes, started_at, last_traffic_at}], its live sessions, the one that moved last first (bytes since the session began, bytes_24h in the last 24 hours; entry_country null = a direct route) — and today, the UTC day the 30 end on. A read: it changes nothing. 404 NOT_FOUND for a number you do not have. |
POST / | {quota_bytes?, exit_countries?, expires_at?}, each left out = unchanged, null = cleared: what PATCH / does. It answers the subscriber as the dashboard lists it. At most 100 an hour. |
GET / | {countries: [{code, direct_from_nano, via_entry_from_nano}], partner_default}: the exit countries a subscriber's own list can name — every country its routes can exit in by your entry setting, whatever your own exit list says — each with the cheapest price per GB of a direct route and of a route through an entry (with the network fee, at your price; null where your entry setting keeps none). partner_default is your own exit list, null for every exit. |
On /me, billing_mode is the stored plan. On the S2S GET /, the
plan is in billing_plan, and billing_mode means "free" or "metered" (section 1).
After a trial passes one of its limits, both still say trial, with trial.active: false,
until ProofKit moves the partner to prepaid. Either way you are paying.
Errors you may meet
Errors come back as {"error": {"code": "…", "message": "…"}}.
| Code | Status | Where | Meaning |
|---|---|---|---|
UNAUTHORIZED | 401 | dashboard | Not signed in, or the session has expired. Sign in again. |
NOT_A_PARTNER | 404 | dashboard | This account does not run a partner, or the partner is suspended (see If ProofKit suspends your partner). A suspension does not take your deposit: write to support to withdraw it. |
TWO_FACTOR_REQUIRED | 403 | dashboard | Turn on an authenticator app or Telegram two-factor codes. |
BILLING_NOT_CONFIGURED | 409 | dashboard | ProofKit has not finished setting up the partner's billing. |
RATE_LIMITED | 429 | key rotation, subscription settings, dashboard add | More than 5 key rotations, more than 30 subscription-settings writes, or more than 100 subscribers added from the dashboard, in an hour (refused ones included). |
INVALID_FILTER | 400 | subscription settings | An exit country, entry or transport value outside the rules, or a country or transport listed twice; the message names the field. |
INVALID_BRANDING | 400 | subscription settings | A title, URL, prefix or refresh interval outside the limits; the message names the field. |
FIELD_NOT_EDITABLE | 400 | subscription settings | The body named sub_enabled, olcrtc_enabled or olcrtc_max_concurrent, which only ProofKit sets. Nothing was saved. |
INVALID_ADDRESS | 400 | wallet link, withdrawal | The address is not a raw TON address (0: followed by 64 hex digits). |
WRONG_NETWORK | 400 | wallet link | The wallet is not on TON mainnet. |
PROOF_INVALID | 400 | wallet link | The challenge expired or was already used, or the proof did not check out. Connect again. |
TON_ADDRESS_TAKEN | 400 | wallet link | The wallet is linked to another ProofKit account. |
WALLET_CHANGE_BLOCKED | 400 | wallet link | Operators have not yet been paid for charges on the current wallet (0.001 TON or more to one operator), its escrow deposit still backs your balance, or the escrow could not be read; the message says which (section 3). |
WALLET_KEY_UNKNOWN | 400 | wallet link | Send one transaction from the wallet, then connect it again. |
INVALID_AMOUNT | 400 | withdrawal | The amount is 0 or less. |
WALLET_NOT_LINKED | 400 | withdrawal | The address is not the linked wallet. |
INSUFFICIENT_BALANCE | 400 | withdrawal, connect, olcRTC | More than the balance was asked for, or the balance is zero. |
EXCEEDS_WITHDRAWABLE | 400 | withdrawal | Above max_withdrawable_nano. |
INTERNAL_ERROR | 500 | withdrawal | With a message that starts signing:, the signer refused (section 8). The amount counts as pending for an hour. |
TRIAL_LIMIT | 403 | POST /, POST /, dashboard add | The trial's subscriber cap is reached. Top up and ask ProofKit to end the trial. |
SUBSCRIPTION_NOT_ENABLED | 409 | dashboard add | Subscriptions are not switched on for your partner, so a subscriber added by hand would get no routes. Write to support: ProofKit switches them on. The dashboard turns the form off and says so while they are off. |
04 /Transports and Russia
This guide is for partners whose subscribers are in Russia, where home and mobile networks filter VPN traffic with deep packet inspection (DPI). It covers:
- olcRTC, for days when only whitelisted services work: what it is and is not, what it costs and how your subscribers get it;
- the four transports the network offers, and what each one is for;
- direct routes, and two-hop routes through an entry node in Russia;
- what we recommend for Russian mobile networks, and what we cannot promise;
- which apps to give your subscribers;
- the route filters, for example to offer only the routes through the entry in Russia, or only direct ones.
Endpoints, fields and error codes are in the API reference, Partner API. Creating subscribers and fetching their subscription are covered in the quick start, and paying for traffic in Paying for your subscribers' traffic. Money is in nanoTON: 1 TON = 1,000,000,000 nano.
olcRTC for whitelist days
On some days, some mobile networks let through only a whitelist of approved services. Reality, Hysteria2 and XHTTP then have nothing to connect to.
olcRTC travels inside a video call on one of four video services: Yandex Telemost, WB Stream, Sber SaluteJazz or VK Calls. On a whitelist day it works where one of these services is on the whitelist. We cannot tell you in advance which of them are.
What it is and is not
- It is a tunnel inside a video call on Yandex Telemost, WB Stream, Sber SaluteJazz or VK Calls, services a whitelist may leave reachable. A subscriber has one key and one line per service, and its usage is metered per subscriber.
- It needs an olcRTC-capable client: Ghostlane, or your own app built on the open-source
engine (the
jsonsubscription format is there for that). - Its availability is not promised. It depends on other companies' video services, so we cannot say that it works on a given network or day. You pay only for traffic that went through.
- Its places are limited. Everyone on the network shares them, and a partner has a share of them.
- It carries less than the other transports. Everyone in a room shares what the room carries.
What it costs
From 20 November 2026 olcRTC traffic has a price of its own: one price per GB for the whole network, whatever the exit or the route. It is the payer's price, with the 20% network fee included. It is set and charged in TON, at the amounts below; the dollar figures are approximate, at 1 TON = $1.44. If the TON rate moves by more than 10 %, we may adjust the TON amounts; a rise is announced 30 days ahead, like any rise. A GB is 1,073,741,824 bytes, upload and download together.
| From (00:00 UTC) | olcRTC, per GB | In TON | In nanoTON |
|---|---|---|---|
| Until 20 November 2026 | The route's own price, as today | — | — |
| 20 November 2026 | About $0.03 | 0.0208 TON | 20,800,000 |
| 8 December 2026 | About $0.05 | 0.0347 TON | 34,700,000 |
| 22 December 2026 | About $0.08 | 0.0556 TON | 55,600,000 |
- The olcRTC price applies when the traffic moves: it is read at every charge and never locked into a session. The other transports keep their rule: a session keeps the price it started with, or a lower one after a drop.
- A rise is announced at least 30 days before it starts. A cut may start at once. The price in
force and the steps to come are in
olcrtc_pricingonGET /. The partner dashboard's olcRTC line shows what your subscribers' olcRTC traffic of the last 30 days costs at the last step.account - olcRTC never passes the entry node in Russia, so a route through the entry adds nothing to it.
- A free trial covers olcRTC traffic: nothing is charged inside it.
- Reality, Hysteria2 and XHTTP keep their prices: the node's price per GB plus the 20% fee, locked when the session starts.
- The same prices apply to partners and to people who use ProofKit directly.
The rest of the rules are in The olcRTC price; how your olcRTC share grows with your olcRTC traffic is in Your share.
How your subscribers get it
In the subscription, in the
ghostlaneandjsonformats. Use?format=ghostlaneon the subscription endpoint, or the hostedhttps://proofkit.org/(every line) orsub/ {sub_token}/ unified https://proofkit.org/(the olcRTC lines alone).sub/ {sub_token}/ olcrtc ?format=jsoncarries the olcRTC entries too.- The
happformat never carries olcRTC, because Happ, Hiddify and v2rayNG cannot run it. - A
happfetch never takes an olcRTC place.
- The
Only once ProofKit switches olcRTC on for your partner account. This switch, the one exit and the share below apply to the subscription and, once subscriptions are switched on for you, to the per-session calls as well.
After the other lines. The olcRTC lines come at the end of the list: one line per video service on one exit, all on the subscriber's own key. They are named
{prefix} DE · olcRTC,{prefix} DE · olcRTC · WB,{prefix} DE · olcRTC · SJand{prefix} DE · olcRTC · VK. A service appears only while the exit serves it.Outside the subscription, on the same terms.
POST /andapi/ v1/ partner/ subscribers/ {external_id}/ olcrtc GET /also return olcRTC lines (api/ v1/ partner/ subscribers/ {external_id}/ sessions/ {session_id}/ configs olcrtc_url,olcrtc_urls). With subscriptions switched on for you, they follow the subscription's rules:- While olcRTC is off for you,
POST …/answersolcrtc 403 OLCRTC_NOT_ENABLEDandconfigscarries no olcRTC fields. - A subscriber holds one olcRTC place, whichever call made it. A place made by one of these calls is that place: the subscription serves it while its exit is in the list, and makes no second one.
- A new place needs room in your share and in your part of the room, and an exit your
filters keep. When none can be made,
POST …/answersolcrtc 503 NO_OLCRTC_NODEandconfigsleaves the olcRTC fields out. - Do not call either endpoint for a subscriber unless you mean to hand out olcRTC.
A partner without subscriptions switched on is bounded by the rooms' own capacity alone on these two calls: the switch and the share do not apply to it.
- While olcRTC is off for you,
One exit per subscriber.
- The subscription gives each subscriber an olcRTC key on one exit of its list.
- Subscribers are spread over the exits: each one starts at a place in the list fixed by its id.
- A subscriber tries the same exit first on every refresh, so it gets its old place back when that place is free.
- The exit is one of those your filters keep; which one, you cannot choose. With no exit
filter it can be any exit, in Europe or not. If you filter the
jsonentries yourself, keep the olcRTC ones whatever theirexit_country, or the subscriber holds a place it cannot use. - A subscriber that holds a key gets that exit's lines back on every fetch, while the exit is in its list and serves a video service.
- If your exit filter later drops that exit, the key stays while the subscriber has any live
session there, and its list carries no olcRTC line at all: the place still counts, so no
second one is made. A dropped route that has carried traffic stays open, so this can last.
To move the subscriber's olcRTC to an exit you keep, end its sessions on the dropped exit
(
GET …/, thensessions DELETE …/for each whosesessions/ {session_id} origin_idis that exit). The key is revoked about 5 minutes later, and the nextghostlaneorjsonfetch seats the subscriber on an exit you keep, within your share. - olcRTC lines come only for exits in the list. With the entry filter set to routes through the entry only, an exit that has no entry linked to it has no route in the list, and so no olcRTC line either.
Your share
- olcRTC places are scarce: 16 people at a time on each exit today, across all of its video services. Our own users and every partner share them.
- Your share is the larger of a fifth of the network's olcRTC places, rounded up, and 8 places plus one for every 15.625 GiB of olcRTC traffic your subscribers moved in the last 30 days (traffic inside a free trial counts). ProofKit can agree a different share with you; it replaces this one.
- A subscriber counts against your share while it holds an olcRTC key on an exit and has moved
any traffic through that exit in the last 5 minutes, on any of its lines there, Reality
included, not only olcRTC. The same rule takes a slot on that exit, and sets
holds_slotin the occupancy Ghostlane shows. The share counts keys: a subscriber with keys on two exits counts on each where it is present. - A new subscriber is given a place only while fewer of your subscribers than your share are counted.
- On any one exit you hold a part of its keys, idle keys included: a fifth of them at the default share (7 of the 32 keys an exit of 16 places holds), more in proportion as your traffic earns you a larger share, and never more than half (a share ProofKit agrees with you does not change it).
- When you are at your share, or no exit has a free place, the olcRTC lines are left out of that subscriber's list. The rest of the list is served as usual.
- A key held for 30 minutes, whose subscriber has moved no traffic through that exit for 30 minutes, can be taken back for someone who needs the place. Its next refresh asks for a place again. When it is seated on the same exit again, it gets the same key back, so the line it holds works again unchanged.
What to expect
- olcRTC is slower than the other transports: a call carries less.
- It relays UDP too, for example voice calls, at olcRTC's lower speed.
- It leaves to the internet from the exit's own address.
Keep Ghostlane current. Older releases do not know every video service.
In short
- Every route in a subscription comes with up to three lines: VLESS Reality (always), Hysteria2 and XHTTP (when the route carries them). One Happ subscription URL holds them all.
- Every exit comes direct, and through each entry node linked to it. Lines through an entry in
Russia are named
… via RU. - Your route filters, set in the dashboard, can narrow that for all your subscribers: certain exits only, only the routes through the entry in Russia, only direct routes, fewer transports. For Russian mobile networks we recommend both kinds of route and all three transports (see Choosing which lines your subscribers get).
- A route that cannot be started on a fetch is left out of that answer, for example when a node is offline or full, your balance is at zero, the fetch's 5 seconds run out, or the subscriber holds 50 sessions. A later fetch adds it once the cause has passed.
- ProofKit switches subscriptions on for each partner. Until then the subscription endpoint
answers
403 SUBSCRIPTION_NOT_ENABLED, and the hosted URLs serve an older list (see the quick start). - Reality, Hysteria2 and XHTTP can all go through the entry in Russia. olcRTC never does.
- olcRTC runs only in an olcRTC-capable app, such as Ghostlane. Each subscriber gets olcRTC on one exit your filters keep, within your partner share, once ProofKit switches olcRTC on for you.
- olcRTC has a price of its own from 20 November 2026, one price per GB for the whole network. Until then it costs the route's price (see What it costs).
- We have no measurements from inside Russian mobile networks. The recommendations below follow from what each transport is built to survive and what it costs. They are not results per mobile operator.
Terms
| Word | In the API | What it is |
|---|---|---|
| Exit | node_type: "origin" | Runs Xray, decrypts the traffic and sends it to the internet. Every route ends at one exit. |
| Entry | node_type: "edge" | A relay in front of an exit. It runs no Xray and never decrypts anything. |
| Route | a session | One exit, either direct or through one entry. It has one price per GB, set when the route starts. The price follows a later drop in a node's own price, never a rise (olcRTC traffic aside: see What it costs). |
| Line | one item in the list | One route over one transport, such as DE via RU · Hysteria2. |
The four transports
| Transport | Line in the list | How it travels | Direct: the app connects to |
|---|---|---|---|
| VLESS Reality | vless://…?type=tcp&security=reality&…&flow=xtls-rprx-vision#… | TCP. A real TLS 1.3 handshake that names the exit's cover site (its SNI). | The exit, TCP 443 |
| Hysteria2 | hysteria2://…?sni=…&obfs=salamander&obfs-password=…&insecure=0&pinSHA256=…#… | QUIC over UDP, with Salamander obfuscation. The link pins the exit's certificate hash. | The exit, UDP 443 |
| XHTTP | vless://…?type=xhttp&security=reality&…&mode=packet-up#… | VLESS sent as many HTTP requests, with REALITY in front. | The exit, TCP 8444 |
| olcRTC | olcrtc://telemost…, olcrtc://wbstream…, olcrtc://salutejazz…, olcrtc://vkcalls… | Inside a video call on Yandex Telemost, WB Stream, Sber SaluteJazz or VK Calls. The exit's olcRTC server waits in the same room. | The video service |
Hysteria2 links use the hysteria2:// scheme, never hy2://. The ports shown here and below
are the defaults. A node can use others, and each line always carries the right one.
A GB costs the same whichever of Reality, Hysteria2 and XHTTP carries it, because the price
belongs to the route. All three lines of a route share its credential. olcRTC traffic is
metered per GB like the rest. While no olcRTC price is in force (olcrtc_pricing on
GET /), it is charged at the price of the subscriber's
direct route to that exit: the exit's price plus the fee; only a subscriber with no direct
route to that exit is charged a via RU route's price for it. With an olcRTC price in force
it costs that price, whatever the route. The steps are in What it costs.
What each one is for
VLESS Reality is the floor. Every route has a Reality line, and a route that cannot make one is left out of the list.
- To an observer it looks like HTTPS to the cover site.
- A probe that connects to the exit is passed through to the real cover site.
- It does not hide the exit's IP address, and a filter can block or slow an address without identifying the protocol.
- It is one long TLS connection, and behaviour-based DPI can flag that shape.
- If the cover site itself is blocked on a network, the line goes with it. Each exit has its own cover site.
Hysteria2 is for networks where UDP passes but TCP is slowed, and for lossy links.
- Salamander scrambles every packet, the first one included, so there is no QUIC header or server name to fingerprint.
- It needs UDP. Where UDP is blocked, as on some mobile networks, it does not connect. Where UDP is throttled, it is slow.
XHTTP is for networks whose DPI flags a long Reality connection while TCP still works, and where UDP is blocked.
- The traffic is split into separate HTTP requests and responses.
- It uses its own port: TCP 8444 direct, or a port between 40000 and 49999 on the entry. It fails on networks that let only ports 80 and 443 through.
- It needs an Xray-core app (see Apps).
olcRTC is for days when a network lets through only a whitelist of approved services, so the other three have nothing to connect to. See olcRTC for whitelist days.
- It is slower than the other three, because a video call carries less.
- Treat it as the fallback, not the default.
Direct or through an entry in Russia
For each exit, the subscription gives:
- a direct route, where the app connects to the exit itself;
- one two-hop route through each entry linked to that exit. The app connects to the entry, and the entry forwards the bytes, still encrypted, through a tunnel between the two servers.
An entry sees the subscriber's IP address, not the sites they visit. The exit sees the sites, and the connection reaches it from the entry. The encryption of Reality, Hysteria2 and XHTTP runs from the app to the exit, and the entry does not decrypt it.
Why an entry in Russia. A filter that targets connections to foreign addresses does not touch a connection to a server inside the country. From the entry on, the traffic is a tunnel between two servers, not a phone talking to a VPN node abroad. That is why the route exists. We have not measured how often it makes the difference on a given network. The link between the entry and the exit crosses the border too. It has been filtered before, and we changed its tunnel to keep it working.
Which transports go through the entry
| Transport | Direct | Through the entry | Two-hop? |
|---|---|---|---|
| VLESS Reality | exit, TCP 443 | entry, TCP 443. The entry picks the exit by the cover-site name, and the Reality keys and SNI are the exit's. | Yes, on every two-hop route |
| Hysteria2 | exit, UDP 443 | entry, one UDP port per exit in 30000–39999, relayed to the exit | Yes, when the entry–exit link has a Hysteria2 relay port |
| XHTTP | exit, TCP 8444 | entry, one TCP port per exit in 40000–49999, relayed to the exit | Yes, when the entry–exit link has an XHTTP relay port |
| olcRTC | through the video service to the exit | — | No. olcRTC never uses the entry. |
Hysteria2 through the entry is not refused: the entry relays its UDP. A route whose link has no
Hysteria2 relay port carries no Hysteria2, and it has no Hysteria2 line in the list. If you
connect on that route with "transport": "hysteria2" (see
Choosing which lines your subscribers get), the
answer is 400 TRANSPORT_NOT_SUPPORTED. XHTTP lines follow the same rule with their own relay
port: no XHTTP relay port, no XHTTP line. connect checks only Hysteria2 and ignores every other
transport value. The subscription simply leaves out the lines a route does not carry.
Through the entry, Hysteria2 and XHTTP use high ports, and only Reality stays on 443. So on a
network that opens only ports 80 and 443, the one … via RU line that can still connect is
Reality. Direct lines stay on 443 too: Reality on TCP 443, and Hysteria2 on UDP 443 where UDP
passes.
Finding the entries in Russia
In GET /, an entry in Russia has node_type: "edge" and
country: "RU". It also carries entry_region: "RU", which appears only on an entry in Russia.
An exit located in Russia is not an entry and has no entry_region. The catalogue lists only the
nodes that are active now, and agent_online says whether a node's agent is connected at this
moment. A node that stops reporting leaves the catalogue after 5 minutes, but its routes stay in
your subscribers' lists until it has been offline for 30 minutes.
On 2026-09-26:
- There is one entry in Russia. It is linked to every exit, and each link has a Hysteria2 and an
XHTTP relay port, so every exit has a
via RUroute with all three lines. - The exits are in CA, DE, ES, FR, GB, IL, IN, IT, JP, KR, MX, NL, PL, SE, US and ZA. The ones in Europe are DE, ES, FR, GB, IT, NL, PL and SE.
- The one entry is shared by everyone on the network: our own users and every partner. If it is
offline, every
via RUline fails at once, and the direct lines are what is left.
The catalogue gives the live list. The subscription's json entries show which routes exist and
which transports each one carries.
What a route through the entry costs
A route's price per GB is set when it starts:
route price = (exit price + entry price) + 20% network fee (fee rounded down, once, on the sum)
direct = exit price + 20% network fee
The catalogue quotes each node for you:
| Field | Meaning |
|---|---|
price_per_gb_nano | The node's price for you, with any agreed price applied, before the fee |
total_price_per_gb_nano | The same with the fee: what a GB through this node alone costs you |
fee_bps | The fee in basis points: 2000 = 20% |
A two-hop route costs the exit's total_price_per_gb_nano plus the entry's, or 1 nano more,
because the fee is rounded down once, on the sum.
Example. Take an exit at 10,000,000 nano per GB and an entry at 10,000,000 nano per GB (these are example prices; read yours from the catalogue):
| Route | Before the fee | You pay per GB |
|---|---|---|
| Direct | 10,000,000 | 12,000,000 nano (0.012 TON) |
| Via the entry | 20,000,000 | 24,000,000 nano (0.024 TON) |
On 2026-09-26, the entry's list price is 10,000,000 nano per GB before the fee. Every
exit's is the same except DE's, which is 1,000,000. So a via RU route costs twice the direct
one on most exits, and eleven times on DE. An agreed price changes these numbers; the catalogue
gives yours.
- A GB here is a GiB (1,073,741,824 bytes), with upload and download added together.
- Only traffic is charged. A line nobody uses costs nothing.
- If a node's own price drops later, running routes follow the drop. A rise does not reach a route that is already running.
- A change to your agreed price reaches only the routes started after it.
Recommendations for Russian mobile networks
What we can tell you. We have no measurement point inside Russian mobile networks.
- Nothing in our data says which operator lets which transport through, in which region, or on which day.
- Blocking differs from one Russian network to another, and it changes over time.
- We do not promise speeds, or results per operator, or that any transport "works on all Russian mobile networks".
What the list is built for: a subscriber whose line stops working switches to another line in the same list, with no call from your backend. The list holds every exit direct and through the entry, each with Reality, and with Hysteria2 and XHTTP where the route carries them, less any route a fetch could not start and anything your route filters leave out.
Give your subscribers the whole list. Do not cut it down to a single transport or a single path. Hysteria2 alone fails wherever UDP is blocked. Reality alone has nothing to fall back to when an exit's address or its long connections are flagged. In the dashboard's Subscription tab, that means entry "All" and all three transports checked; narrowing the exits is fine.
Direct Reality first. A direct line never costs more than the same exit
via RU, which adds the entry's price. Reality is the transport every listed app runs.If an exit's direct lines fail or crawl, try the same exit
via RU. The phone then connects to a server in Russia. It costs both nodes' prices plus the fee.If Reality connects but stalls or drops, try Hysteria2 when UDP passes, and XHTTP when it does not.
If only a handful of services work at all (a whitelist day), switch to olcRTC in an olcRTC-capable app. See olcRTC for whitelist days. Subscribers who use only Happ, Hiddify or v2rayNG have no line for those days: olcRTC comes only in the
ghostlaneandjsonformats. We do not rank exits for Russian networks; the node-level fallback is the entry in Russia, not a particular exit.Keep the subscription refreshing. A line's credential stays the same while its route runs. A route ends, for example:
- when your balance reaches zero. The route whose traffic took the balance to zero is disconnected at once, and every other route you pay for within about 15 seconds. Fetches start no new ones, so your subscribers' lists come back without them. After a top-up the next fetch starts every route again, each with a new credential. See When the balance reaches zero.
- when its node has been offline for 30 minutes. The next fetch starts it again with a new credential.
An olcRTC room that dies is replaced with a new room address and the same key. The app picks up these changes on its next refresh. The list tells the app how often to refresh: every hour by default. You set the refresh interval in the dashboard's Subscription tab, from 1 to 168 hours.
Test with your own users before you scale. Try each kind of line on the mobile networks your subscribers use. Nobody can measure those networks from outside. You can do this in the free trial: by default 7 days, 10 subscribers and 50 GB (see The free trial).
| What the subscriber sees | Line to try |
|---|---|
| Nothing unusual | DE Direct (Reality) |
| An exit's direct lines fail or are very slow | DE via RU |
| Reality stalls or drops, UDP passes | DE Direct · Hysteria2, then DE via RU · Hysteria2 |
| Reality stalls or drops, UDP blocked | DE Direct · XHTTP, then DE via RU · XHTTP |
| Only ports 80 and 443 open | Reality lines (TCP 443), direct or via RU; DE Direct · Hysteria2 (UDP 443) where UDP passes |
| Only a few whitelisted services reachable | olcRTC (Ghostlane format) |
DE stands for any exit. A second exit in one country is numbered (DE 2), and so is a second
entry in one country for the same exit (via RU 2). The names carry your prefix when you have
set one in the dashboard's Subscription tab, for example My VPN DE via RU · Hysteria2. A
prefix is at most 32 characters and cannot contain #, ?, &, $ or control characters.
Russian sites. The list carries no routing rules, so every site sees a foreign address. On today's exits that is a Cloudflare WARP address for TCP traffic, and the exit's own address for UDP and for olcRTC. Some Russian sites refuse visitors from foreign addresses. Sending Russian sites straight from the phone, and everything else through the VPN, is a setting in the app, not in the list. Ghostlane calls this mode "Bypass Russia", and other apps have their own routing rules.
The host the subscription itself rides on
Everything above is about the lines inside a subscription — where the traffic goes. The subscription URL is the other thing a filter can see: it is the address your subscribers' apps come back to for their config, on every refresh.
By default that address is on proofkit.org. If that name is blocked, live tunnels keep
working (the lines point at node addresses), but nobody can refresh: no new config, no
changed routes, no olcRTC lines. Two ways off the shared name:
- Your own domain, our serving. Add
sub.yourdomain.com CNAME subs.proofkit.org(DNS-only) and request the domain on the Partner page. Your links then ride on a name nobody else blocks for you — a different name on direct IP addresses, not the shared host. If it gets blocked in its turn, disable it and request another: three at a time, replaced in minutes.GET /'saccount hosted_basenames the host in force. - Your own domain, your server. The S2S proxy under Serving it under your domain: the same independence, your infrastructure too.
Measure your customers' networks
Which transports and which exits work on an operator's network is measured from phones on that network, and the phones are yours. In the Partner page's Subscription tab, Network watch takes a phone by name and gives you a link to download the ProofKit Reach app for it: install the app on a phone with a local SIM, open it once, and it checks your lines from that operator's network every 30 minutes, on at most 20 MB of the SIM's data a day. The phone is bound to its network by its first report; Network watch then shows what each of your lines (every exit country you sell, direct and through the entry, on each transport) looks like from that network, with how long the state has held. A phone on Wi-Fi or with a VPN running measures nothing until it is back on mobile data, and one reporting from another operator than the one it was bound to reads away. Ten phones at a time; a download link opens a page whose button downloads the app once, within ten minutes (the link itself can be sent through any chat); New link makes a new one, with a new key, so an app installed from an older link stops. Removing a phone there ends it at once. Network watch is switched on per partner by ProofKit: ask in a ticket if your dashboard does not show it.
Apps
| Transport | Apps |
|---|---|
| VLESS Reality | Happ, v2rayNG, Hiddify, Streisand, Ghostlane, and other Xray-core or sing-box apps |
| Hysteria2 | Happ, v2rayNG, Hiddify, Ghostlane |
| XHTTP | Xray-core apps: Happ, v2rayNG, Ghostlane. sing-box-based apps cannot run it. |
| olcRTC | An olcRTC-capable app. Ghostlane is one. |
Which subscription format to give each app:
happ(the default). A base64 list for Happ, Hiddify and v2rayNG: Reality, Hysteria2 and XHTTP, and no olcRTC.ghostlane. The same lines as plain text, followed by the olcRTC lines when olcRTC is on for your account.
A subscriber can use both formats at once, for example the Happ URL in Happ and the Ghostlane URL in Ghostlane. They carry the same routes and the same credentials.
Serve ?format=ghostlane under your own domain the same way as happ: your server answers its
own URL and passes the body on unchanged, with the headers (which you may rewrite, for
example to show your own quota and expiry in subscription-userinfo). The hosted
https://proofkit.org/ URLs show proofkit.org. Ghostlane also reads olcRTC room occupancy from proofkit.org (see
Ghostlane shows occupancy).
Ghostlane is an open-source client (github.com/ghostlane-project) that runs Reality, Hysteria2 and XHTTP, and also runs olcRTC. Your subscribers need it only for olcRTC; for the other three, any app in the table works.
Choosing which lines your subscribers get
The subscription (GET /) gives every
route the subscriber may use that your route filters keep. With no filters, that is every exit
direct, and through every entry linked to it.
Route filters in the dashboard
The owner sets three filters in the Subscription tab of the partner dashboard (#/).
They apply to every subscriber's list at once, in every format and at the hosted URLs, from each
subscriber's next fetch. The full rules are in Route filters.
| Field in the tab | API name | Choices |
|---|---|---|
| exit countries | sub_exit_countries | Country codes such as DE, NL; blank keeps every exit |
| entry | sub_entry | All (all), Direct only (direct), Via entry edge only (via_edge) |
| transports | sub_transports | Reality, Hysteria2 and XHTTP; all three checked is the same as no filter |
Only the routes through the entry in Russia. Set entry to "Via entry edge only"
(via_edge). Every Reality, Hysteria2 and XHTTP line then connects to the entry in Russia, is
named … via RU, and costs the exit's price plus the entry's, plus the fee.
- An exit with no entry linked to it drops out of the list, and so does its olcRTC line.
- If the one entry is offline, all those lines fail at once: there are no direct lines to fall back to.
- olcRTC never uses the entry, so its lines still come, on one exit, when olcRTC is on for you.
While no olcRTC price is in force (
olcrtc_pricingonGET /), its traffic is charged at the price of the subscriber's direct route to that exit only while the subscriber holds one (see The four transports). Under this filter the subscription gives the subscriber no direct route, so its olcRTC traffic is charged theaccount via RUroute's price, unless the subscriber holds a direct session on that exit anyway: one you made withconnect, or the direct sessionPOST …/makes on an exit where the subscriber held no session. While that session lives, its olcRTC traffic is charged at the direct price. With an olcRTC price in force, it costs that price whatever the route.olcrtc
Only direct routes. Set entry to "Direct only" (direct). The list has no … via RU lines,
and each route costs the exit's price plus the fee. Your subscribers lose the route that first
reaches a server inside Russia, the fallback for an exit whose direct lines fail.
Transports for Russian mobile networks. Keep all three.
- Reality is the floor: every listed app runs it, and through the entry it is the one line that stays on port 443. Do not leave it out.
- Hysteria2 is the line for networks where UDP passes but TCP is slowed.
- XHTTP is the line for networks where UDP is blocked and a long Reality connection is flagged. Leave it out only if your subscribers' app cannot run it: sing-box-based apps such as Hiddify cannot, Happ and v2rayNG can.
- A transport left out is missing from every route. Hysteria2 alone fails wherever UDP is blocked.
What we recommend for Russian mobile networks is both kinds of route and all three transports: entry "All" and every transport checked. Narrowing the exits, for example to Europe, does not weaken the list in the same way: each exit kept still comes direct and through the entry, with all three transports.
The filters apply to the subscription only: connect and the catalogue ignore them.
Lists per subscriber
To give some subscribers a narrower list than the others, fetch
?format=json, keep the entries you want and serve your own body. The answer looks like this:{ "entries": [ { "url": "vless://…", "transport": "vless_reality", "remark": "My VPN DE via RU", "session_id": "…", "origin_id": "…", "edge_id": "…", "exit_country": "DE", "entry_country": "RU" } ], "upload": 0, "download": 0 }transportisvless_reality,hysteria2,vless_xhttporolcrtc. An olcRTC entry also hascarrier:telemost,wbstream,salutejazzorvkcalls.remarkis the line's name, your prefix included.entry_countryis the entry's country ("RU"for the entry in Russia), andedge_idis the entry's id. Both arenullon a direct line and on every olcRTC line, so pick olcRTC lines bytransport, not by a missing entry.uploadanddownloadare the subscriber's bytes this calendar month (UTC).- A JSON fetch still starts every route.
- When olcRTC is on for your account, a JSON fetch also takes an olcRTC place, as a Ghostlane fetch does.
Or connect route by route. Call
POST /withapi/ v1/ partner/ subscribers/ {external_id}/ connect exit_countryand"entry_country": "RU", then read the route's lines fromGET /.api/ v1/ partner/ subscribers/ {external_id}/ sessions/ {session_id}/ configs - If no entry in Russia is linked to the chosen exit and free, the route is created
direct, without an error. Check
edge_idin the answer:nullmeans direct. - Use the country code as the catalogue writes it (
"RU"). - To name the entry exactly, pass its
edge_idfrom the catalogue together withexit_country. The exit is then chosen among those linked to that entry. - If you pass both
origin_idandedge_id, the call does not check that the two are linked. Name only a pair you know is linked: the subscription'sjsonentries show the pairs that exist. - The lines from
configsare named after the route (DE via RU), without your prefix. - A route you connect yourself is closed if it has carried no traffic an hour after it was
made, and its credential is removed from the node. Connect again, or use the subscription,
whose routes are kept while they are active. Once subscriptions are on for you, a
subscription fetch for the same subscriber lists a route you connected, if your filters
keep it, and adopts it as a subscription line: it is then kept like the others. A pair of
origin_idandedge_idthat is not linked shows up that way too, with lines that do not work, until you end its session. - On an exit that serves a video service,
configsalso returns olcRTC lines and can take a room key there, on the terms in How your subscribers get it.
- If no entry in Russia is linked to the chosen exit and free, the route is created
direct, without an error. Check
05 /Without code: the dashboard
This guide is for a business with no backend: a rental, a hotel, a school, an office or a shop that gives its customers or its staff VPN access. You add each person by hand in the partner dashboard on proofkit.org, hand over a link or a QR code, and the access stops when it is over. Nothing here needs a developer, a server or an API key. If your own backend creates subscribers, start with the Partner quick start instead; the dashboard and the Partner API work on the same subscribers, and you can use both.
What ProofKit sets up
There is no self-serve sign-up. Sign in at proofkit.org (or create a free account), turn on two-factor sign-in, and open a ticket under Support. The link fills in the subject, Partner dashboard access, and four questions: what you do and who gets the access, how many people you expect, the countries they should appear from, and which account will run the dashboard. ProofKit then:
- creates your partner and attaches it to the account you named: one that signs in with an email address, not only with Telegram, and runs no other partner. Attaching signs that account out everywhere, so sign in again;
- starts a free trial if one is agreed: an end date, a traffic allowance and a cap on subscribers, sized to what you told us. The cap counts every subscriber you ever add, so say how many people you expect in all, not how many at once;
- switches subscriptions on, with the exit countries you asked for. Until they are on, the Subscribers tab says so and Add is off.
You need no API key. The dashboard is #/ on proofkit.org (the site's navigation shows Partner to the owner account), and it opens only with two-factor sign-in on. The terms are at proofkit.org/partner-terms.
Add a subscriber
Open Subscribers, type the subscriber's number and press Add (or Enter).
What to write as the number. A number from your own records: an order, a booking, a contract. Not a name, a phone number or an email address: it is the only thing ProofKit keeps to tell one subscriber from another, and it should mean nothing outside your system. It can be up to 128 bytes long and is unique within your partner. Capital and small letters differ: A-17 and a-17 are two subscribers. If your records have no numbers, Suggest writes a random code such as K7Q-4MD.
One number, one person. The same number again is the same subscriber, not a second one. The page asks before it touches one that is there already: Use it again enables it if it was disabled, writes the limits the form names on it, and its old link works again; Leave it changes nothing and shows it in the list. The link works again only if the limits leave it running: one whose access had ended needs a new end date, and one whose traffic was used up a new figure, so the page tells you to set it instead of asking. So do not pass a number from one customer to the next. If you must, leave it and press Reset link… first: otherwise the person who had it before has the access again.
You can add 300 subscribers an hour. While a free trial is at its subscriber cap, Add is refused (see What it costs).
Many at once
Add several… opens a box for up to 100 numbers, one a line: a column pasted from a spreadsheet will do. Each gets the limits the form holds at that moment. A line that cannot go is named before anything is sent: a number written twice, one longer than 128 bytes. A line that holds a tab is one too: it is two columns pasted together, and the second would become part of the number.
Several at once only add. A number that is there already is counted and left exactly as it is: not enabled, not given the limits. Such numbers are not lost in the count: they stay in the box, each with the link and the limits it had, for you to look at in the list. If a free trial reaches its cap part-way, the ones before it are added and the rest stay in the box with the reason. Each number counts toward the 300 an hour.
Limits
Three limits can be set when a subscriber is added, each of them optional:
| Limit | In the form | What it does |
|---|---|---|
| End date | valid for: no end, 1, 3, 7 or 30 days, or date… with a day and a time | From that moment the access stops by itself |
| Traffic | traffic, GB | Once the subscriber has moved that much, upload and download together, the access stops until you raise the figure or start the allowance again |
| Countries | countries | The exit countries this subscriber gets, in place of your subscription's own list |
- A length is counted from the moment you press Add. Three days is 72 hours from then, not the end of the third day. A date and a time are read on your own clock, and before anything is sent the form says the moment both ways:
Ends 16 Oct 2026, 20:30 your time (UTC+4) · 16:30 UTC. - Countries opens the list of countries a subscriber can be given, each with the price per GB its routes start from. Left at As in your subscription, the subscriber gets whatever your Subscription tab names, now and after you change it.
- The form keeps what you last set, so the next subscriber is a number and Enter. Look at the line under valid for before each Add: a length is counted afresh from that press, and a date you typed is still the same date.
When the end date comes or the traffic is used up, the subscriber's connections end within about 15 seconds (an olcRTC line, if olcRTC is on for you, within 5 minutes) and its link serves no lines; the app shows the access as ended or used up. The subscriber stays in your list, and you can bring it back (see Renew or change).
Hand it over
After an add, Hand over opens by itself; later, QR on the subscriber's row opens it again. It shows:
- the link as a QR code, for the person to scan with a phone's camera at your counter;
- what the subscriber is limited to;
- Copy link, to send it in a message;
- Share, where the browser has a share sheet (a phone);
- Print, while the setup page is on: a card with your title, the code, the link, the end date and three steps, to hand over on paper. A subscriber that is disabled, ended or out of traffic gets no card until it is brought back;
- Other links, folded away: the link Ghostlane reads.
On the row, Link copies the same link without showing it, and Ghostlane copies the one that app reads.
A link is the access. Anyone holding it can use it, so give it to that person only. If one gets out, Reset link… in the row's menu makes a new link at once, and the old one stops working.
What the subscriber sees
Opened in a browser, the link is a setup page: your profile title, the end date and the traffic, an app to install (Ghostlane, an open-source client, with its store links), one tap that adds the access to it, the link to copy for Happ, Hiddify, V2Box or another app, and your support link. Past its end date or its traffic the page says so and, if you have set a profile title, to ask you by it. Its words name neither ProofKit nor the subscriber's number. The link itself is on proofkit.org until you have a domain of your own for it: your domain in the Subscription tab, once the free trial is over.
Set the profile title and the support URL in the Subscription tab before you hand out the first link: the title is the name the person sees on the page and in the app, and the support URL is where the page sends their questions. The same tab has the page's switch, setup page. Off, a browser shows the list's text, as an app gets it.
An app that fetches the link gets the list and never the page, so a link that is already in an app keeps working as before.
Find, renew, stop
The list is newest first and shows each subscriber's status, its traffic and cost over the last 30 days, its limits and when it last moved traffic. The search field finds a number by any part of it, whatever the case. The number you type in whole stands first, however old it is. The chips above the list filter it by state: All, Active, Expired, Used up and Disabled, each with how many subscribers are in it.
One subscriber
A press on a subscriber's number, or Details… in its menu, opens its card: its status and its limits, its traffic on each of the last 30 days as bars, with their total and what it cost, and Connected through — the routes it used in the last 24 hours, such as Germany, or Germany via Russia when it enters through another country, each with its traffic in those 24 hours and when it last moved, and how many more routes its link holds unused. A subscriber that used none reads Not connected now. The days are UTC days, so their total can differ a little from the list's "last 30 days", which counts back from this minute.
The card only shows. Quota, end date, countries… and Hand over on it open the same editor and the same dialog as the row does. There is no button that ends a connection: an app makes its connections again the next time it refreshes the link, so to cut a subscriber off use Disable… or an end date.
Renew or change
Quota, end date, countries… in a row's menu (the ⋯ button) opens the editor. It sends only what you changed:
- a later end date renews a subscriber that has ended, or extends one that has not;
- a new traffic figure starts the allowance again from now, and Start the allowance again from now does the same at its present size;
- taking a country away ends the subscriber's connections through it, and Save asks once more before it does.
Moving the date does not start the traffic allowance again, and the other way round. A renewed subscriber keeps its link: the person does nothing, and the app has its lines again at its next refresh. You can save 100 changes an hour.
Stop
- Disable… ends the subscriber's connections at once (an olcRTC line within 5 minutes) and switches its link off: it answers as an unknown link. Enable brings it back with the same link.
- Reset link… replaces the link and ends the connections made with the old one.
- There is no delete. A disabled subscriber keeps its number and its place under a free trial's cap, and adding the number again enables it.
Reset, Disable and Enable share 30 an hour with the Subscription tab's saves.
The list as a file
Export CSV saves the list as it is filtered on the page, by the search and the chip, as subscribers-YYYY-MM-DD.csv for a spreadsheet: the number, the status, when it was added, until when it is valid, the quota and what is used of it, the traffic and the cost of the last 30 days, when it was last seen, and its countries. Times are UTC, sizes are GB, amounts are TON. Up to 10,000 subscribers go into one file; past that, narrow the list first.
The cells are parted by commas and the decimals written with a dot. Tick Semicolons for a spreadsheet that writes one and a half as 1,5: the cells are then parted by semicolons and the decimals written with a comma, and the file opens in columns there. The box is ticked for you when your browser writes numbers that way. A number that begins with =, +, - or @ is written with an apostrophe in front, so that a spreadsheet does not read it as a formula.
Links are left out unless you tick Include links. A link is the access: keep a file that holds them as you keep the links.
What it costs
You pay for the traffic your subscribers move, per GB, from your partner balance. Adding a subscriber, a link, the setup page and the dashboard itself cost nothing. The list's cost · 30 d column shows what each subscriber cost, and countries shows each country's price per GB before you choose it.
- During a free trial traffic is free while all three of its limits hold: the end date, the traffic allowance and the subscriber cap. The cap counts every subscriber you have added, disabled and ended ones too, so a business whose customers come and go spends one place for each customer. At the cap, Add is refused: ask us to raise the cap, or top up and ask us to end the trial.
- After the trial, or once its end date or its traffic allowance is passed, traffic is charged to the balance, and there is no cap on subscribers. At a zero balance your subscribers' connections end, and their links serve no lines until you top up.
- The balance is topped up in TON from a wallet you link in the Balance tab. It must be a wallet no other ProofKit account is linked to, your own personal account included.
Everything about money (the trial, the wallet, top-ups, prices, and withdrawing what you did not spend) is in Paying for your subscribers' traffic.
What the Partner API adds
Everything above is also a call of the Partner API, for the day you connect a booking system or a bot: POST / is Add, PATCH / is the editor, and the number you type is the API's external_id. A key is made in the dashboard's Overview with Create key. A subscriber added by hand and one made by a call are the same subscriber.
Only the API can read usage over any window and a subscriber's sessions, start a route to a named country or node and read its configs, ask for an olcRTC line, and serve the subscription from your own server. The quick start walks through it.
06 /Changes
What was added to the Partner API, and when. The API has one major version, v1, and it only
grows: see Versions and compatibility. A revision
is listed on the day it went live. Revisions 1.0 to 1.3 were numbered afterwards, from the
history of this documentation.
Revision 1.5
11 October 2026
- Subscriber exit countries: a subscriber's own list of exit countries, in place of your partner's list for that subscriber. See Subscriber exit countries.
- Subscriber expiry: the instant a subscriber's subscription ends, which the platform enforces itself. See Subscriber expiry.
exit_countriesandexpires_atonPOST /andsubscribers PATCH /;subscribers/ {external_id} exit_countries,expires_atandexpiredon the subscriber wherever it is answered (GET /,subscribers/ {external_id} POST /) and in the list.subscribers/ {external_id}/ reset expire=in thesubscription-userinfoheader carries the subscriber's end date when it has one.- The error codes
INVALID_EXIT_COUNTRIES,INVALID_EXPIRES_ATandSUBSCRIPTION_EXPIRED. PATCH /withsubscribers/ {external_id} "quota_bytes": nullclears the quota, as this reference has said since revision 1.3. Until now such a call changed nothing.POST /withsubscribers quota_bytesanswerscreated: truefor a subscriber the call made, as it does without one. Until now such a call answeredcreated: false.
Revision 1.4
11 October 2026
- Every answer names the revision that produced it, in the
X-Partner-Api-Versionheader. See Versions and compatibility. - The OpenAPI document of every revision stays available, at
https://proofkit.org/for this one.partner-api/ versions/ 1.4.json
Revision 1.3
10 October 2026
- Subscriber quotas: an optional traffic allowance per subscriber, which the platform enforces itself. See Subscriber quotas.
quota_bytesonPOST /;subscribers quota_bytes,quota_used_bytes,quota_remaining_bytesandover_quotaon the subscriber wherever it is answered (GET /,subscribers/ {external_id} POST /), in the list, on each row ofsubscribers/ {external_id}/ reset GET /and onusage GET /.subscribers/ {external_id}/ usage PATCH /.subscribers/ {external_id} - The error codes
INVALID_QUOTAandQUOTA_EXHAUSTED.
Revision 1.2
10 October 2026
hosted_baseonGET /: the base of your hosted subscription URLs while a domain of your own is active.account
Revision 1.1
4 October 2026
- The olcRTC part of usage:
olcrtc_bytes,olcrtc_cost_nanoandolcrtc_list_cost_nanoon each row ofGET /and onusage GET /, with their totals.subscribers/ {external_id}/ usage olcrtc_pricingonGET /.account
Revision 1.0
27 September 2026
- The first published contract: the account, the catalogue, subscribers and their list, connect, sessions and their configs, the subscription, olcRTC, and usage.