proofkit.
Marketplace Network Proofs Speedtest Docs Sign in
Partners

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.

Start with the quick start OpenAPI spec and explorer
  1. 01Partner quick startFrom an API key to a working subscription in Happ: the account, the catalogue, a subscriber and its URL, step by step.13 sections
  2. 02Partner APIEvery endpoint, field, limit and error code of the server-to-server API.30 sections
  3. 03Paying for your subscribers' trafficThe free trial, the partner wallet, top-ups, what a GB costs and withdrawing the unspent deposit, in TON.10 sections
  4. 04Transports and RussiaThe four transports, direct and via-RU routes, and which lines to hand out to users in Russia.10 sections
  5. 05Without code: the dashboardNo backend: add subscribers by hand, limit them, hand over a link or a QR code, renew and stop them.7 sections

No key yet? Request API access · No backend? Start without code · About the Partner API · Partner terms

// contents
/

No section title matches. Your browser’s find (Ctrl+F, or ⌘F on a Mac) searches the text itself.

  1. 01 /Partner quick start
    • Before you start
    • 1. Check the account
    • 2. Read the catalogue
    • 3. Choose what the subscription carries
    • 4. Create a subscriber
    • 5. Get the subscription
    • 6. Open it in Happ
    • 7. olcRTC lines
    • 8. Disable, re-enable, reset
    • 9. Check usage
    • Subscribers in Russia
    • Trial, then prepaid
    • If something is off
  2. 02 /Partner API
    • Getting access
    • Authentication
    • Rate limits
    • Errors
    • Versions and compatibility
    • Endpoint summary
    • GET /account
    • GET /catalogue
    • POST /subscribers
    • GET /subscribers
    • GET /subscribers/{external_id}
    • PATCH /subscribers/{external_id}
    • DELETE /subscribers/{external_id}
    • POST /subscribers/{external_id}/reset
    • GET /subscribers/{external_id}/subscription
    • POST /subscribers/{external_id}/connect
    • GET /subscribers/{external_id}/sessions
    • DELETE /subscribers/{external_id}/sessions/{session_id}
    • GET /subscribers/{external_id}/sessions/{session_id}/configs
    • POST /subscribers/{external_id}/olcrtc
    • GET /usage
    • GET /subscribers/{external_id}/usage
    • Subscriber quotas
    • Subscriber exit countries
    • Subscriber expiry
    • Idempotency
    • Billing
    • Link resolver: the one endpoint you implement
    • Client apps
    • Error codes
  3. 03 /Paying for your subscribers' traffic
    • At a glance
    • 1. Trial, then prepaid
    • 2. The partner dashboard
    • 3. Link the partner wallet
    • 4. Top up
    • 5. What a GB costs
    • 6. The balance and the charges
    • 7. When the balance reaches zero
    • 8. Withdraw the unspent deposit
    • 9. Reference
  4. 04 /Transports and Russia
    • olcRTC for whitelist days
    • In short
    • Terms
    • The four transports
    • Direct or through an entry in Russia
    • Recommendations for Russian mobile networks
    • The host the subscription itself rides on
    • Measure your customers' networks
    • Apps
    • Choosing which lines your subscribers get
  5. 05 /Without code: the dashboard
    • What ProofKit sets up
    • Add a subscriber
    • Limits
    • Hand it over
    • Find, renew, stop
    • What it costs
    • What the Partner API adds
  6. 06 /Changes
    • Revision 1.5
    • Revision 1.4
    • Revision 1.3
    • Revision 1.2
    • Revision 1.1
    • Revision 1.0

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 API in 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, #/partner 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".
  • 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 on code. Every response carries an X-Request-ID header: 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/json, broken JSON, a missing field, a query value or path id that does not parse (for example from=yesterday, or a session_id that 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
  }
}
FieldWhat it tells you
billableMust be true. If it is false, step 4 answers 400 PARTNER_NOT_BILLABLE, and ProofKit has to set your terms first.
billing_plantrial, prepaid, or exempt (ProofKit covers the traffic).
billing_modeKept for existing integrations. It reads free only on exempt and metered otherwise, including during a free trial. Read billing_plan and trial.active instead.
trialPresent only while billing_plan is trial. active: true means traffic is free right now.
balance_nanoYour prepaid balance. Once you pay for traffic, a new session needs it above zero, because credit_limit_nano is 0 on prepaid.
wallet, deposit_linkThe 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_nano is 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_nano is 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_transports lists some of vless_reality, hysteria2, vless_xhttp and olcrtc.
  • agent_online says whether the node is connected right now.
  • sessions_count counts 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) reach max_users_effective, or when its live load is too high. A sessions_count above max_users_effective does 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 (#/partner) 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:

FieldWhat it does
profile titleThe subscription's name in the app. Up to 64 characters, any script and emoji.
support URL, web page URLLinks the app shows. https:// only, visible ASCII with no spaces, up to 512 characters each.
line-name prefixPut in front of every line's name, for example MyVPN in MyVPN DE Direct. Up to 32 characters, none of #, ?, &, $.
update interval, hoursHow often the app re-reads the list, 1 to 168 hours; 1 by default.
exit countriesThe exits to keep, as comma-separated country codes. Blank keeps every exit.
entryAll (the default), Direct only, or Via entry edge only (for example only the via RU routes).
transportsReality, 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 …/connect and the catalogue ignore them. A new olcRTC place is made only on an exit you keep.
  • 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_id is your own id for the person. It must be 1 to 128 bytes of UTF-8 once surrounding whitespace is trimmed; anything else answers 400 INVALID_EXTERNAL_ID.
  • The call is idempotent. The same external_id returns the same subscriber with created: false, so retrying is safe. If the subscriber was disabled, this call enables it again.
  • sub_token is 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_subscribers is refused with 403 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"
formatBodyRead by
happ (default)Base64 of the share linesHapp, Hiddify, v2rayNG
ghostlaneThe same lines as plain text, followed by olcRTC linesGhostlane
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_countryYour own code

The response carries these headers. Return them together with the body:

HeaderValue
profile-titlebase64: followed by your title in base64, when a title is set
profile-update-intervalHours between refreshes, 1 by default
support-url, profile-web-page-urlYour links, when set
subscription-userinfoupload=…; 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-typetext/plain; charset=utf-8, or application/json for json
cache-controlno-store
content-dispositioninline

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/<your token>. It maps that URL to the subscriber's 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, set total= and expire= in subscription-userinfo and keep our upload= and download=. 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 /account's hosted_base says 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/sub/<sub_token> serves the same happ body 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 answer 403: it serves an older, unbranded list with a VLESS Reality line for each session the subscriber already holds from connect or POST …/olcrtc, 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 local socks://127.0.0.1:10808#olcRTC line, 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.

AnswerMeaning
403 SUBSCRIPTION_NOT_ENABLEDProofKit has not switched subscriptions on for your partner.
404 NOT_FOUNDNo such subscriber, or the subscriber is disabled.
400 INVALID_FORMATformat 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> Direct or <prefix> <exit> via <entry>, by country code, for example MyVPN DE Direct and MyVPN DE via RU. When one country has two exits, the second is numbered DE 2; a second entry in one country for the same exit is numbered via 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: DE becomes DE 2 and 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=json and build your own body from each entry's exit_country and entry_country. A JSON fetch still starts every route your filters keep. If olcRTC is on for your partner, every json fetch 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 decoded happ body 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-userinfo shows this calendar month's traffic, with total=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 set total= and expire= 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-interval hours.
  • Changing credentials. A line's credentials last as long as its session. A session ends on a reset, a disable, your DELETE /subscribers/{external_id}/sessions/{session_id}, 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.
  • 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/sub/<sub_token>/unified and https://proofkit.org/sub/<sub_token>/olcrtc (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) and MyVPN 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_free is only an upper bound for you.
  • Keys and formats. A ghostlane or json fetch gives the subscriber an olcRTC key, and a happ fetch never does. Fetch happ for 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 ghostlane or json fetch 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 /subscribers/{external_id}/olcrtc and GET /subscribers/{external_id}/sessions/{session_id}/configs also hand out olcRTC lines (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 …/olcrtc answers 403 OLCRTC_NOT_ENABLED and 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 …/olcrtc answers 503 NO_OLCRTC_NODE. See POST …/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/subscribers/tg_123456789 shows disabled: true. Calling DELETE again changes nothing.
  • To re-enable, call POST /subscribers again with the same external_id. The sub_token stays the same, and the next fetch makes new lines. Note that POST /subscribers/{external_id}/olcrtc also re-enables a disabled subscriber.
  • 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 ghostlane and json bodies 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 answers 404 from 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 ghostlane or json fetch 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. from and to are RFC 3339 timestamps, and the window is [from, to). Write Z, or encode a + offset as %2B. By default the window is the last 30 days. Consecutive windows never overlap, so monthly reports add up. If from is after to, the answer is 400 INVALID_RANGE.
  • All subscribers. /usage returns total_bytes, total_bytes_up, total_bytes_down, total_cost_nano and a subscribers list 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_nano is what the traffic charged to your balance. It is 0 inside the free trial.
  • Balance. For the money you have left, read balance_nano on /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 RU line: 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 ghostlane and json formats.
  • We do not rank exits for Russian networks. The node-level fallback is the entry in Russia (the via RU lines), 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_nano is 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_subscribers is refused with 403 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:

  • until passes.
  • bytes_used reaches max_bytes.
  • ProofKit ends it, or lowers max_subscribers below the number of subscribers you hold.

Then:

  • Charging starts. trial.active becomes false. From the next traffic report on, traffic is charged to your balance at each session's price (step 2): the exit's total_price_per_gb_nano on 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 …/connect and POST …/olcrtc answer 400 INSUFFICIENT_BALANCE.
  • Top up before the trial ends to avoid this.
  • No subscriber cap. Prepaid has no cap on subscribers.
  • Plan fields. billing_plan keeps reading trial until ProofKit closes the trial. After that it reads prepaid, and the trial object 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. wallet on /account then 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_BLOCKED otherwise).
  • 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_link is 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_nano rises 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 getWhy
401 INVALID_PARTNER_KEYThe key is missing, wrong or revoked, a rotated-out key is past its 24 hours, or the partner is disabled.
400 PARTNER_NOT_BILLABLEThere is no billing account yet. ProofKit has to set your terms.
400 INVALID_EXTERNAL_IDThe external_id is empty, or longer than 128 bytes once trimmed (step 4).
400 INVALID_FORMATformat is not happ, ghostlane or json (step 5).
400 INVALID_RANGEfrom is after to (step 9).
400 TORRENT_GUARD_COOLDOWNTorrent 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_BALANCEFrom POST …/connect or POST …/olcrtc: the balance is at zero and no trial is active. A subscription fetch never answers this; it leaves the routes out.
403 TRIAL_LIMITThe trial's subscriber cap is reached.
403 SUBSCRIPTION_NOT_ENABLEDSubscriptions are not switched on for your partner.
403 OLCRTC_NOT_ENABLEDFrom POST …/olcrtc: olcRTC is not switched on for your partner. Nothing was created.
503 NO_OLCRTC_NODEFrom POST …/olcrtc: 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 subscriptionThe subscriber is unknown or disabled.
404 NOT_FOUND "Unknown partner API endpoint"The path does not exist.
405 METHOD_NOT_ALLOWEDThe path exists, but not with this method.
429 RATE_LIMITEDWait Retry-After seconds when it is present. The per-IP limits (6,000 a minute on /api/v1/partner/*, 60 a minute on the hosted /sub/ URLs) send no Retry-After: wait a minute.
Fewer lines than expectedYour 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.

Back to contents

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/api/v1/partner

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 (#/partner) 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;
  • starts a free trial if one is agreed (see Billing);
  • switches on the subscription for your partner, if you use it (see GET /subscribers/{external_id}/subscription), 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.

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:

  1. On the Partner page, press Rotate key. The new key is shown once; copy it then.
  2. Deploy the new key to your backend within 24 hours.
  3. 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:

BucketLimitRequests
Subscription fetches1,200 per minuteGET /subscribers/{external_id}/subscription
Everything else300 per minuteevery 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/v1/partner/*. 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:

PathLimit
Hosted https://proofkit.org/sub/… URLs60 per minute per IP
GET /api/v1/olcrtc/status120 per minute per IP
POST /api/v1/partners/resolve-link20 per hour per IP

GET /account 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/v1/partner 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:

StatusCause
400The body is not valid JSON
415A JSON body sent without Content-Type: application/json
422The JSON is valid but a field is missing or has the wrong type, e.g. no external_id
400A query parameter that does not parse, e.g. from=yesterday or qr=maybe
400A 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/partner-api/versions/1.4.json, 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/v2/partner, announced at least 90 days before v1 stops, and v1 works throughout.

Endpoint summary

All paths are under /api/v1/partner and need X-Partner-Key.

MethodPathPurpose
GET/accountPlan, balance, trial, wallet and deposit link
GET/catalogueNodes you may sell, with the price you pay
POST/subscribersCreate or re-enable a subscriber (idempotent)
GET/subscribersYour subscribers, a page at a time
GET/subscribers/{external_id}One subscriber's state
PATCH/subscribers/{external_id}Set, clear or renew a subscriber's limits
DELETE/subscribers/{external_id}Disable a subscriber
POST/subscribers/{external_id}/resetNew subscription token and credentials, for a leaked link
GET/subscribers/{external_id}/subscriptionThe subscriber's whole subscription, branded
POST/subscribers/{external_id}/connectStart (or return) one session
GET/subscribers/{external_id}/sessionsThe subscriber's live sessions
DELETE/subscribers/{external_id}/sessions/{session_id}End one session
GET/subscribers/{external_id}/sessions/{session_id}/configsEvery transport for one session
POST/subscribers/{external_id}/olcrtcProvision, connect and return olcRTC lines in one call
GET/usageUsage and cost for every subscriber over a window
GET/subscribers/{external_id}/usageUsage 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 …/subscription 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.
  • Per session: POST …/connect for a chosen country or node, then GET …/configs 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).

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
  }
}
FieldMeaning
partner_idYour partner id.
billableWhether your billing account exists. false means POST /subscribers 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_nanoBalance 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_nanoHow far below zero the balance may go, as a positive magnitude. Always 0 on prepaid, and set to 0 when a trial starts.
suspendedAlways 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_minThe general per-key budget, 300.
walletThe TON wallet linked to your billing account, or null before you link one on the Partner page.
escrow_addressThe ProofKit escrow contract your deposits go to.
deposit_linkton://transfer/<escrow>?text=deposit, a transfer to the escrow with the comment deposit. null until a wallet is linked.
hosted_baseThe 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_pricingWhat 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.
trialPresent 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 /catalogue.

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 /subscribers/{external_id}). 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

FieldMeaning
untilWhen the free traffic ends (null if the trial was never started).
max_subscribersHow many subscribers the trial covers.
subscribersSubscribers you have created, disabled ones included.
max_bytesThe trial's traffic allowance in bytes.
bytes_usedTraffic counted against it so far.
activetrue 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/api/v1/providers 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.

FieldMeaning
node_idUse 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).
countryTwo-letter country code, as exit_country / entry_country expect it.
price_per_gb_nanoThe node's price per GB for you, with any price agreed for you applied, before the network fee.
total_price_per_gb_nanoWhat a GB through this node costs you: price_per_gb_nano plus the network fee.
fee_bpsThe 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_transportsAny of vless_reality, hysteria2, vless_xhttp, olcrtc.
olcrtc_carriersThe olcRTC carriers the node serves right now, Telemost first: any of telemost, wbstream, salutejazz, vkcalls. [] when it serves none.
olcrtc_slots_total / olcrtc_slots_freeolcRTC 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_freeolcRTC keys the node may hold, and at most how many it will still hand out. Absent with the slots.
is_publicfalse for a private node granted to you.
sessions_countActive sessions on the node, as origin or edge, idle ones included.
max_usersThe node's own user limit, 0 when it sets none.
max_users_effectiveThe limit actually enforced (max_users, or our default when it is 0). It applies to origins only; see Load below.
agent_onlineWhether 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/api/v1/providers 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 …/olcrtc 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 …/olcrtc 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/sub/<sub_token>, 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 /subscribers/{external_id} 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.

CodeStatusWhen
INVALID_EXTERNAL_ID400external_id empty or over 128 bytes
INVALID_QUOTA400quota_bytes outside 1 MiB..1 PiB (0 included — use null/absent for "no quota")
INVALID_EXIT_COUNTRIES400exit_countries empty, over 64 codes, a code that is not two upper-case letters, or a code listed twice
INVALID_EXPIRES_AT400expires_at is not an RFC 3339 instant with an offset, or not in the future
PARTNER_NOT_BILLABLE400Your billing account is not set up yet
TRIAL_LIMIT403You are inside a free trial and it already covers max_subscribers subscribers
QUOTA_EXHAUSTED403The subscriber's quota is used up: re-enabling them needs a quota_bytes with room in it
SUBSCRIPTION_EXPIRED403An 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…"
}
ParameterMeaning
limitSubscribers per page, 1 to 200. Default 50.
cursorThe previous page's next_cursor, as it came. Absent for the first page.
qKeep the external_ids that start with this, byte for byte: case-sensitive, and % and _ are ordinary characters.
disabledtrue 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 /subscribers/{external_id} 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 /usage 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 /subscribers 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 /subscribers/{external_id} does: it sends only what was changed, so moving the date does not start the quota's allowance again.

CodeStatusWhen
INVALID_LIMIT400limit below 1 or above 200
INVALID_CURSOR400A 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 /subscribers lists them a page at a time, without the sub_token.

PATCH /subscribers/{external_id}

Since 1.3

Changes 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 /subscribers/{external_id} gives, fresh.

{ "quota_bytes": 26843545600, "expires_at": "2026-12-10T20:30:00Z" }

Three meanings for each field:

  • a value sets it;
  • null clears 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_bytes sets the quota and starts its allowance from now: the used counter reads from this call's moment. So a PATCH to the number already in force is the renewal: a subscriber sold "25 GB valid 30 days" gets 25 GiB at purchase and the same PATCH every month, and a top-up is a PATCH to 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_countries gives the subscriber its own list of exits, and ends its sessions on the exits a narrower list leaves out: see Subscriber exit countries.
  • expires_at sets 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.

CodeStatusWhen
INVALID_QUOTA400quota_bytes outside 1 MiB..1 PiB
INVALID_EXIT_COUNTRIES400An empty list, more than 64 codes, a code that is not two upper-case letters, a code listed twice
INVALID_EXPIRES_AT400Not an RFC 3339 instant with an offset, or not in the future
NOT_FOUND404A 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 …/sessions/{session_id}, a close for balance) works the same.
  • Their hosted subscription URLs and GET …/subscription answer 404, and connect answers 403 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 /subscribers and by POST /subscribers/{external_id}/olcrtc 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.

  1. A new sub_token is issued. The old hosted URLs answer 404 from this moment.
  2. Every live session ends, and its credential is removed from its node (what that does to connections still open: see DELETE /subscribers/{external_id}).
  3. 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:

formatContent-TypeBodyolcRTC lines
happ (default; also when absent or empty)text/plain; charset=utf-8Base64 of the newline-joined share links, the list format Happ, Hiddify and v2rayNG importnever
ghostlanetext/plain; charset=utf-8The same links as plain lines, then the olcRTC lineswhen olcRTC is enabled for your partner
jsonapplication/jsonThe lines as data, for building your own formatwhen 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 connect with an origin_id and an edge_id that 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 …/sessions/{session_id} 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:

  1. VLESS Reality over TCP. Present on every route: a route that cannot make it is left out.
  2. Hysteria2 (hysteria2://, Salamander obfuscation, certificate pinned), when the route carries it.
  3. 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.

SettingValuesDefaultThe body keeps
sub_exit_countries1 to 64 two-letter country codes in upper case (ISO 3166 alpha-2), none twice, e.g. ["DE", "NL", "FR"], or nullnull, every exitThe routes whose exit is in one of these countries
sub_entryall, direct or via_edgealldirect: only the routes straight to an exit. via_edge: only the routes through an entry edge, such as … via RU. all: both.
sub_transportsA non-empty list of vless_reality, hysteria2 and vless_xhttp, none twice, or nullnull, all threeThe lines over these transports, on every route kept
  • These are the defaults. A subscriber with exit_countries of its own follows that list in place of sub_exit_countries; sub_entry and sub_transports apply 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_pricing on GET /account), olcRTC traffic is charged at the price of the subscriber's direct route to the exit when it holds one. Under via_edge the 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 from connect, or the direct session the olcRTC call makes on an exit where the subscriber held no session (step 5 of POST …/olcrtc). 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.
  • 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 …/sessions/{session_id} (the ids are in GET …/sessions). Widening the filters again while that session lives brings back the same session and the same line.
  • Names are numbered over the routes in the body, so narrowing a filter can rename a line, for example from DE 2 to DE, 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 200 with the usual headers and an empty body: "" for happ and ghostlane, {"entries": [], "upload": N, "download": N} for json.
  • Only the subscription reads them: this endpoint and the hosted URLs. connect and 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 (see POST …/olcrtc).

Headers and branding

HeaderValue
profile-titlebase64: + base64 of your title, so any script survives. Omitted when no title is set.
profile-update-intervalHours between the app's refreshes, 1 to 168; 1 unless set.
support-urlYour support link. Omitted when not set.
profile-web-page-urlYour web page. Omitted when not set.
subscription-userinfoupload=<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-Controlno-store
Content-Dispositioninline

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:

SettingLimit
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 (#/partner, 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 /catalogue), 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/api/v1/partner-portal/subscription. 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_enabled and olcrtc_share are ProofKit's. olcrtc_share is null while olcRTC is off, otherwise the share that applies: the one agreed with you, or the default. It binds only while sub_enabled is true; before that the olcRTC call and configs are bounded by the rooms alone, and the figure is for information.
  • PUT takes any of the nine as JSON. A field left out is unchanged; null clears sub_exit_countries, sub_transports, sub_title, sub_support_url, sub_web_page_url or sub_remark_prefix; sub_entry, sub_update_hours and sub_setup_page cannot be null. {} changes nothing. The answer is the GET view after the write, with a fresh preview.
  • A value outside the limits answers 400 INVALID_FILTER (the three filters) or 400 INVALID_BRANDING (the rest), with a message that names the field, and nothing is saved. So does an empty support or web page URL (""): send null for none. A list that names a country or a transport twice is 400 INVALID_FILTER too ("… is listed twice").
  • Naming sub_enabled, olcrtc_enabled or olcrtc_max_concurrent (the stored share) with a value, or olcrtc_max_concurrent as null, answers 400 FIELD_NOT_EDITABLE, and nothing in that body is saved. sub_enabled: null and olcrtc_enabled: null are a plain-text 422, as are olcrtc_share, preview and any other unknown field, a wrong type, and null where it is not allowed. So do not send the GET view 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-text 422 does not. The next answers 429 RATE_LIMITED, with no X-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/<your token>), maps it to the subscriber's external_id, calls GET …/subscribers/{external_id}/subscription?format=… 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 /account'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 …/olcrtc and GET …/configs 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 …/olcrtc and GET …/configs).

  • 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 …/olcrtc or GET …/configs 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.
  • 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 …/olcrtc and GET …/configs 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: list GET …/sessions, find through the catalogue the sessions whose origin_id is the dropped exit, and end each with DELETE …/sessions/{session_id}. The key is revoked at the next room-health pass, about 5 minutes later, and the next ghostlane or json fetch 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:

URLBody, once subscriptions are switched on for your partner
https://proofkit.org/sub/<sub_token>The happ body, with your headers
https://proofkit.org/sub/<sub_token>/unifiedThe ghostlane body, with your headers
https://proofkit.org/sub/<sub_token>/olcrtcOnly 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/<sub_token> — the link you hand a subscriber — answers a request whose Accept header names text/html 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/<sub_token> and says it accepts text/html, 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 …/olcrtc). They serve the same lists again if ProofKit later switches subscriptions off for you, or suspends your partner. No session is started:

  • /sub/<sub_token>: 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 socks://127.0.0.1:10808#olcRTC line.
  • /sub/<sub_token>/unified: 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/<sub_token>/olcrtc: only those olcRTC lines, issuing keys the same way.

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.

FieldMeaning
exit_countryTwo-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_idAn exact origin from the catalogue, instead of a country.
service_idLegacy, from before the catalogue; do not use it. It is still accepted, which is why MISSING_PARAM's message names it.
entry_countryOptional 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_idOptional exact edge. With exit_country, the origin is chosen among those linked to it.
transportOnly "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.
countryLegacy 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 …/configs 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 …/olcrtc, 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:

CodeStatusMeaning
MISSING_PARAM400Neither exit_country nor origin_id was given
QUOTA_EXHAUSTED403The subscriber's quota is used up; renew it with PATCH first
SUBSCRIPTION_EXPIRED403The subscriber's end date has passed; renew it with PATCH first
TRANSPORT_NOT_SUPPORTED400transport: "hysteria2" on a route that does not carry it
SESSION_LIMIT400The subscriber already holds 50 live sessions
NODE_OVERLOADED400The named origin is refusing new sessions on its live load
NODE_AT_CAPACITY400The named origin has reached its user limit
TORRENT_GUARD_COOLDOWN400Torrent Guard ended this subscriber's session on this origin in the last hour
INSUFFICIENT_BALANCE400Your balance does not allow a new session (see GET /account)
AGENT_OFFLINE400The command to the node could not be sent
FORBIDDEN403The subscriber is disabled, or the node is private and not granted to you
NOT_FOUND404Unknown 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 /subscribers/{external_id}). 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" }
  ]
}
FieldMeaning
transportsAlways 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.
subscriptionBase64 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_svgQR of subscription; omitted with ?qr=false.
olcrtc_urlThe Telemost olcRTC line, when the session's origin serves Telemost and the subscriber may have a line there (see below). Omitted otherwise.
olcrtc_urlsOne {carrier, url} per carrier the origin serves, Telemost first, all on one key. Omitted when there are none.
sub_transportThe subscriber account's own mode for its older hosted list; "vless_reality" unless changed. This API has no call that changes it.
sub_tokenAs 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 …/olcrtc 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_url and olcrtc_urls are 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/json; 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.

  1. Provisions the subscriber exactly like POST /subscribers: it creates them if new and re-enables them if they were disabled. PARTNER_NOT_BILLABLE and TRIAL_LIMIT apply.
  2. 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 answers OLCRTC_UNAVAILABLE, and a retry reuses the same session and answers the same.
  3. 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):

  1. While olcRTC is not switched on for you, it answers 403 OLCRTC_NOT_ENABLED and 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-text 400 or 415) and 400 INVALID_EXTERNAL_ID.
  2. Provisions the subscriber as above, creating or re-enabling them. Every 503 below 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.
  3. 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 is 503 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 this 503 on every call; the subscription still carries its lines.
  4. A subscriber without a place gets 503 NO_OLCRTC_NODE while 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 is 503 NO_OLCRTC_NODE. Neither refusal makes a session.
  5. 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 …/reset releases it, with everything else the subscriber holds), and a private node whose grant to you was revoked answers 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 …/configs, 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.

CodeStatusMeaning
OLCRTC_NOT_ENABLED403Subscriptions 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_EXHAUSTED403The subscriber's quota is used up; renew it with PATCH first
SUBSCRIPTION_EXPIRED403The subscriber's end date has passed; renew it with PATCH first
NO_OLCRTC_NODE503No 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_UNAVAILABLE503The 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 …/sessions/{session_id}, the id from GET …/sessions).
INVALID_EXTERNAL_ID400As in POST /subscribers
PARTNER_NOT_BILLABLE400As in POST /subscribers
TRIAL_LIMIT403As in POST /subscribers
NOT_FOUND404The 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)
FORBIDDEN403With subscriptions: the held place is on a private node no longer granted to you (step 5)
any connect refusalINSUFFICIENT_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, salutejazz or vkcalls.
  • <transport>: vp8channel for Telemost, WB Stream and VK Calls, datachannel for SaluteJazz.
  • <room>: what the carrier calls a room, passed through untouched. For Telemost it is a join URL (https://telemost.yandex.ru/j/…), for WB Stream a bare room code, for SaluteJazz <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, · SJ or · 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 /account), 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.3

A subscriber can carry an optional traffic quota in bytes — quota_bytes on POST /subscribers, changed any time by PATCH /subscribers/{external_id} (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 /subscribers/{external_id}/usage and call DELETE /subscribers/{external_id} when it trips.

Subscriber exit countries

Since 1.5

A subscriber can carry its own list of exit countries — exit_countries on POST /subscribers and PATCH /subscribers/{external_id}: 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.
  • null clears 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 /catalogue names the countries that have exits.
  • POST …/connect 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.
  • POST …/olcrtc 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 answers 503 NO_OLCRTC_NODE.
CodeStatusWhen
INVALID_EXIT_COUNTRIES400An empty list, more than 64 codes, a code that is not two upper-case letters, a code listed twice

Subscriber expiry

Since 1.5

A subscriber can carry the instant its subscription ends — expires_at on POST /subscribers and PATCH /subscribers/{external_id}: 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 …/connect and POST …/olcrtc 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-userinfo carries expire=<Unix seconds>, before and after it passes; without one it stays expire=0.
  • Renewal is a later date. PATCH with a future expires_at brings the subscriber back on the next fetch; null removes the date. One PATCH with quota_bytes and expires_at is 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, DELETE it.
  • 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 404 whatever 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's max_subscribers.
CodeStatusWhen
INVALID_EXPIRES_AT400Not an RFC 3339 instant with an offset, or not in the future
SUBSCRIPTION_EXPIRED403POST …/connect, POST …/olcrtc, and POST /subscribers 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.

CallRepeat it and…
POST /subscribers…get the same subscriber (created: false); a disabled one is re-enabled.
POST …/olcrtc…get the same live session; a disabled subscriber is re-enabled.
POST …/connect…get the same session for the same route; another route opens another session.
PATCH /subscribers/{external_id}…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 /subscribers/{external_id}…get 204 again.
DELETE …/sessions/{session_id}…get 204 again.
GET …/subscription…get the same lines while nothing changed (the traffic figures move); the first fetch provisions.
POST …/reset…get a new token and end the sessions again. Not idempotent.
other GETs…change nothing, except that …/configs 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 /account). Only traffic is charged: there is no monthly, setup or API fee and no minimum spend.

Your plan is billing_plan on GET /account:

  • 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' other active sessions on priced routes are closed within about 15 seconds (a pending one once it turns active). 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 answers TRIAL_LIMIT. Past any limit you are charged like prepaid from 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_mode reads "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_KEY and the Partner page answers 404 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 exempt plan and a trial no longer apply. On exempt or in a trial with a balance at zero or below, every active session 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, /unified and /olcrtc URLs 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:// or http://. Anything else is discarded as if you had not answered.
  • You have 5 seconds. Redirects are not followed.
  • Any non-2xx, a missing subscription_url or unparseable JSON means "not mine", and we ask the next partner. Return 404 for 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 happ format. 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 the happ body never contains olcRTC lines.
  • Ghostlane is the client that also runs olcRTC. It imports the ghostlane format (or the hosted /sub/<sub_token>/unified), which carries the same lines plus olcRTC.

How to connect a subscriber step by step: Quick start. Which transports and routes to offer users in Russia: Russia.

Error codes

CodeStatusWhere
INVALID_PARTNER_KEY401Every endpoint: missing, malformed or unknown key, or a disabled partner
RATE_LIMITED429Every endpoint: per-key budget or per-IP flood guard
NOT_FOUND404Unknown path; a subscriber, session or node that is not yours or does not exist; no origin available in a country
METHOD_NOT_ALLOWED405A known path with the wrong method; Allow lists the path's methods
INVALID_EXTERNAL_ID400POST /subscribers, POST …/olcrtc
PARTNER_NOT_BILLABLE400POST /subscribers, POST …/olcrtc
TRIAL_LIMIT403POST /subscribers, POST …/olcrtc
INVALID_QUOTA400POST /subscribers, PATCH /subscribers/{external_id}
INVALID_EXIT_COUNTRIES400POST /subscribers, PATCH /subscribers/{external_id}
INVALID_EXPIRES_AT400POST /subscribers, PATCH /subscribers/{external_id}
QUOTA_EXHAUSTED403POST /subscribers, POST …/connect, POST …/olcrtc
SUBSCRIPTION_EXPIRED403POST /subscribers, POST …/connect, POST …/olcrtc
INVALID_LIMIT400GET /subscribers
INVALID_CURSOR400GET /subscribers
INVALID_FORMAT400GET …/subscription
SUBSCRIPTION_NOT_ENABLED403GET …/subscription
INVALID_RANGE400GET /usage, GET …/usage
MISSING_PARAM400POST …/connect
TRANSPORT_NOT_SUPPORTED400POST …/connect
FORBIDDEN403POST …/connect: disabled subscriber, or a private node not granted to you; POST …/olcrtc: a held olcRTC place on such a node
SESSION_LIMIT400POST …/connect, POST …/olcrtc
NODE_OVERLOADED400POST …/connect with origin_id, POST …/olcrtc
NODE_AT_CAPACITY400POST …/connect with origin_id, POST …/olcrtc
TORRENT_GUARD_COOLDOWN400POST …/connect, POST …/olcrtc
INSUFFICIENT_BALANCE400POST …/connect, POST …/olcrtc
AGENT_OFFLINE400POST …/connect, POST …/olcrtc
OLCRTC_NOT_ENABLED403POST …/olcrtc, when subscriptions are switched on for your partner and olcRTC is not
NO_OLCRTC_NODE503POST …/olcrtc: no room with a place; with subscriptions switched on, also your share in use, or the subscriber's place outside the request
OLCRTC_UNAVAILABLE503POST …/olcrtc
INTERNAL_ERROR500Our 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.

Back to contents

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/api/v1/partner. The partner dashboard's API (signed in as the owner) is under https://proofkit.org/api/v1/partner-portal. 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

QuestionAnswer
How you payPrepaid, 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 chargedNothing. 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 accessA 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 GBThe 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-up0.1 TON in the dashboard. The escrow contract itself refuses anything under 0.05 TON.
Minimum balanceNone to keep. Outside a free trial, new sessions need a balance above zero.
Time to creditUsually under a minute after the transaction confirms.
At zero balanceNew 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.
WithdrawingThe 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 /api/v1/partner/account reports it in billing_plan, and the dashboard shows it.

billing_planWhat it means
trialTraffic is free while every trial limit holds. Past any limit you are on prepaid terms.
prepaidEvery GB is charged to your balance. The balance never goes on credit.
exemptProofKit 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_nano in 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 /api/v1/partner/subscribers/{external_id} disables a subscriber but does not free its place.
  • Creating a new subscriber at the cap answers 403 TRIAL_LIMIT. This comes from POST /api/v1/partner/subscribers, from POST /api/v1/partner/subscribers/{external_id}/olcrtc 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.
  • 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 /api/v1/partner/account (or on the dashboard's GET /api/v1/partner-portal/me):

"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 /api/v1/partner/account 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/#/partner. 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 exempt plan, 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 /catalogue). The share binds only once subscriptions are switched on for you; until then the figure is for information.

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_BLOCKED otherwise). 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

  1. From the dashboard. Enter an amount (0.1 TON or more) and confirm in your wallet. The wallet sends the contract's Deposit message. If the wallet you connect is not the linked one, the dashboard links it first, under the rules of section 3.
  2. 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_link on GET /api/v1/partner/account and on GET /api/v1/partner-portal/me is this transfer as a ton://transfer/<escrow>?text=deposit link. It is null until a wallet is linked. The escrow address is also given in escrow_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 /api/v1/partner-portal/wallet/payments).

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 /api/v1/partner/catalogue lists every node you may use with:

FieldMeaning
price_per_gb_nanoThe 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_nanoWhat a GB through this node costs you: price_per_gb_nano plus the fee. This is the number to plan with.
fee_bpsThe 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):

RouteNode pricesYou pay per GB1.5 GB costs
Directorigin 10,000,00012,000,000 (0.012 TON)18,000,000
Two hopsorigin 10,000,000, edge 10,000,00024,000,000 (0.024 TON)36,000,000

Before you have a key. The Marketplace on proofkit.org (https://proofkit.org/providers) 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 /api/v1/partner/subscribers/{external_id}/connect returns total_price_per_gb_nano, the session's price per GB with the fee, and platform_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_nano is the session's price per GB with the fee. The catalogue field of the same name is the price without the fee.
  • GET /api/v1/partner/usage and GET /api/v1/partner/subscribers/{external_id}/usage give each subscriber's cost_nano for a period, and GET /api/v1/partner/usage also gives 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 GBIn TONIn nanoTON
Until 20 November 2026The route's own price, as today——
20 November 2026About $0.030.0208 TON20,800,000
8 December 2026About $0.050.0347 TON34,700,000
22 December 2026About $0.080.0556 TON55,600,000

The rules:

  1. 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.
  2. A rise is announced at least 30 days before it starts. A cut may start at once. The announcement is in olcrtc_pricing on GET /account and in a support ticket.
  3. 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 RU route.
  4. 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.
  5. The free trial covers olcRTC. While a trial is active, nothing is charged for olcRTC traffic either.
  6. 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 /account 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 /usage.

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_30d on GET /api/v1/partner-portal/me: one entry per day with a charge, oldest first).
  • 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 /api/v1/partner/account: 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.

  1. New sessions are refused. POST /api/v1/partner/subscribers/{external_id}/connect and POST /api/v1/partner/subscribers/{external_id}/olcrtc answer 400 INSUFFICIENT_BALANCE on 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, through GET /api/v1/partner/subscribers/{external_id}/subscription or the hosted URL, starts no new routes. The routes it would have started are left out of the answer.
  2. 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.
  3. 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.
  4. 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 answers 404 NOT_A_PARTNER.
  • Running sessions keep being charged to your billing account, as a plain prepaid payer: your credit limit, the exempt plan and a trial stop applying. On exempt or 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.

  1. In the dashboard, enter an amount. The dashboard shows what is in the escrow, what is pending and what can be withdrawn now.
  2. ProofKit checks the limit and signs an approval, which is valid for 5 minutes (POST /api/v1/partner-portal/wallet/withdraw-approve).
  3. Your wallet sends the signed Withdraw message 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.
  4. 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 /api/v1/partner-portal/wallet/withdraw-limit 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_ERROR with a message that starts signing:. 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_nano is null), 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/api/v1/partner-portal.

Method and pathWhat it does
GET /mepartner_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 /wallet/proof-payload{payload, expires_in: 900}: a one-time challenge for the wallet's ton_proof, valid 15 minutes
POST /wallet/link{address, network, state_init, proof}, from TonConnect's account.address, account.chain, account.walletStateInit and tonProof.proof → {"linked": true}
GET /wallet/paymentsDeposits and withdrawals, newest first, at most the latest 100
GET /wallet/withdraw-limitThe withdrawal limit and its parts (section 8)
POST /wallet/withdraw-approve{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 /key/rotateA 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 /subscriptionThe 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 /subscriptionChanges 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 /subscription. At most 30 writes an hour.
GET /subscribersThe 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 /subscribers keeps its four.
POST /subscribers{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 /partner/subscribers' 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 /subscribers/batch{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 /subscribers/{external_id}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 /subscribers/{external_id}/limits{quota_bytes?, exit_countries?, expires_at?}, each left out = unchanged, null = cleared: what PATCH /subscribers/{external_id} does. It answers the subscriber as the dashboard lists it. At most 100 an hour.
GET /countries{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 /api/v1/partner/account, 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": "…"}}.

CodeStatusWhereMeaning
UNAUTHORIZED401dashboardNot signed in, or the session has expired. Sign in again.
NOT_A_PARTNER404dashboardThis 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_REQUIRED403dashboardTurn on an authenticator app or Telegram two-factor codes.
BILLING_NOT_CONFIGURED409dashboardProofKit has not finished setting up the partner's billing.
RATE_LIMITED429key rotation, subscription settings, dashboard addMore 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_FILTER400subscription settingsAn exit country, entry or transport value outside the rules, or a country or transport listed twice; the message names the field.
INVALID_BRANDING400subscription settingsA title, URL, prefix or refresh interval outside the limits; the message names the field.
FIELD_NOT_EDITABLE400subscription settingsThe body named sub_enabled, olcrtc_enabled or olcrtc_max_concurrent, which only ProofKit sets. Nothing was saved.
INVALID_ADDRESS400wallet link, withdrawalThe address is not a raw TON address (0: followed by 64 hex digits).
WRONG_NETWORK400wallet linkThe wallet is not on TON mainnet.
PROOF_INVALID400wallet linkThe challenge expired or was already used, or the proof did not check out. Connect again.
TON_ADDRESS_TAKEN400wallet linkThe wallet is linked to another ProofKit account.
WALLET_CHANGE_BLOCKED400wallet linkOperators 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_UNKNOWN400wallet linkSend one transaction from the wallet, then connect it again.
INVALID_AMOUNT400withdrawalThe amount is 0 or less.
WALLET_NOT_LINKED400withdrawalThe address is not the linked wallet.
INSUFFICIENT_BALANCE400withdrawal, connect, olcRTCMore than the balance was asked for, or the balance is zero.
EXCEEDS_WITHDRAWABLE400withdrawalAbove max_withdrawable_nano.
INTERNAL_ERROR500withdrawalWith a message that starts signing:, the signer refused (section 8). The amount counts as pending for an hour.
TRIAL_LIMIT403POST /api/v1/partner/subscribers, POST /api/v1/partner/subscribers/{external_id}/olcrtc, dashboard addThe trial's subscriber cap is reached. Top up and ask ProofKit to end the trial.
SUBSCRIPTION_NOT_ENABLED409dashboard addSubscriptions 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.

Back to contents

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 json subscription 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 GBIn TONIn nanoTON
Until 20 November 2026The route's own price, as today——
20 November 2026About $0.030.0208 TON20,800,000
8 December 2026About $0.050.0347 TON34,700,000
22 December 2026About $0.080.0556 TON55,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_pricing on GET /account. The partner dashboard's olcRTC line shows what your subscribers' olcRTC traffic of the last 30 days costs at the last step.
  • 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 ghostlane and json formats. Use ?format=ghostlane on the subscription endpoint, or the hosted https://proofkit.org/sub/{sub_token}/unified (every line) or https://proofkit.org/sub/{sub_token}/olcrtc (the olcRTC lines alone). ?format=json carries the olcRTC entries too.

    • The happ format never carries olcRTC, because Happ, Hiddify and v2rayNG cannot run it.
    • A happ fetch never takes an olcRTC place.
  • 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 · SJ and {prefix} DE · olcRTC · VK. A service appears only while the exit serves it.

  • Outside the subscription, on the same terms. POST /api/v1/partner/subscribers/{external_id}/olcrtc and GET /api/v1/partner/subscribers/{external_id}/sessions/{session_id}/configs also return olcRTC lines (olcrtc_url, olcrtc_urls). With subscriptions switched on for you, they follow the subscription's rules:

    • While olcRTC is off for you, POST …/olcrtc answers 403 OLCRTC_NOT_ENABLED and configs carries 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 …/olcrtc answers 503 NO_OLCRTC_NODE and configs leaves 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.

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 json entries yourself, keep the olcRTC ones whatever their exit_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 …/sessions, then DELETE …/sessions/{session_id} for each whose origin_id is that exit). The key is revoked about 5 minutes later, and the next ghostlane or json fetch 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_slot in 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

WordIn the APIWhat it is
Exitnode_type: "origin"Runs Xray, decrypts the traffic and sends it to the internet. Every route ends at one exit.
Entrynode_type: "edge"A relay in front of an exit. It runs no Xray and never decrypts anything.
Routea sessionOne 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).
Lineone item in the listOne route over one transport, such as DE via RU · Hysteria2.

The four transports

TransportLine in the listHow it travelsDirect: the app connects to
VLESS Realityvless://…?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
Hysteria2hysteria2://…?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
XHTTPvless://…?type=xhttp&security=reality&…&mode=packet-up#…VLESS sent as many HTTP requests, with REALITY in front.The exit, TCP 8444
olcRTColcrtc://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 /account), 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

TransportDirectThrough the entryTwo-hop?
VLESS Realityexit, TCP 443entry, 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
Hysteria2exit, UDP 443entry, one UDP port per exit in 30000–39999, relayed to the exitYes, when the entry–exit link has a Hysteria2 relay port
XHTTPexit, TCP 8444entry, one TCP port per exit in 40000–49999, relayed to the exitYes, when the entry–exit link has an XHTTP relay port
olcRTCthrough 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 /api/v1/partner/catalogue, 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 RU route 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 RU line 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:

FieldMeaning
price_per_gb_nanoThe node's price for you, with any agreed price applied, before the fee
total_price_per_gb_nanoThe same with the fee: what a GB through this node alone costs you
fee_bpsThe 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):

RouteBefore the feeYou pay per GB
Direct10,000,00012,000,000 nano (0.012 TON)
Via the entry20,000,00024,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.

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

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

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

  4. If Reality connects but stalls or drops, try Hysteria2 when UDP passes, and XHTTP when it does not.

  5. 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 ghostlane and json formats. We do not rank exits for Russian networks; the node-level fallback is the entry in Russia, not a particular exit.

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

  7. 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 seesLine to try
Nothing unusualDE Direct (Reality)
An exit's direct lines fail or are very slowDE via RU
Reality stalls or drops, UDP passesDE Direct · Hysteria2, then DE via RU · Hysteria2
Reality stalls or drops, UDP blockedDE Direct · XHTTP, then DE via RU · XHTTP
Only ports 80 and 443 openReality lines (TCP 443), direct or via RU; DE Direct · Hysteria2 (UDP 443) where UDP passes
Only a few whitelisted services reachableolcRTC (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 /account's hosted_base names 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

TransportApps
VLESS RealityHapp, v2rayNG, Hiddify, Streisand, Ghostlane, and other Xray-core or sing-box apps
Hysteria2Happ, v2rayNG, Hiddify, Ghostlane
XHTTPXray-core apps: Happ, v2rayNG, Ghostlane. sing-box-based apps cannot run it.
olcRTCAn 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/sub/… 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 /api/v1/partner/subscribers/{external_id}/subscription) 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 (#/partner). 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 tabAPI nameChoices
exit countriessub_exit_countriesCountry codes such as DE, NL; blank keeps every exit
entrysub_entryAll (all), Direct only (direct), Via entry edge only (via_edge)
transportssub_transportsReality, 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_pricing on GET /account), 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 the via RU route's price, unless the subscriber holds a direct session on that exit anyway: one you made with connect, or the direct session POST …/olcrtc 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.

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
    }
    • transport is vless_reality, hysteria2, vless_xhttp or olcrtc. An olcRTC entry also has carrier: telemost, wbstream, salutejazz or vkcalls.
    • remark is the line's name, your prefix included.
    • entry_country is the entry's country ("RU" for the entry in Russia), and edge_id is the entry's id. Both are null on a direct line and on every olcRTC line, so pick olcRTC lines by transport, not by a missing entry.
    • upload and download are 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 /api/v1/partner/subscribers/{external_id}/connect with exit_country and "entry_country": "RU", then read the route's lines from GET /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_id in the answer: null means direct.
    • Use the country code as the catalogue writes it ("RU").
    • To name the entry exactly, pass its edge_id from the catalogue together with exit_country. The exit is then chosen among those linked to that entry.
    • If you pass both origin_id and edge_id, the call does not check that the two are linked. Name only a pair you know is linked: the subscription's json entries show the pairs that exist.
    • The lines from configs are 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_id and edge_id that 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, configs also returns olcRTC lines and can take a room key there, on the terms in How your subscribers get it.

Back to contents

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 #/partner 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:

LimitIn the formWhat it does
End datevalid for: no end, 1, 3, 7 or 30 days, or date… with a day and a timeFrom that moment the access stops by itself
Traffictraffic, GBOnce the subscriber has moved that much, upload and download together, the access stops until you raise the figure or start the allowance again
CountriescountriesThe 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 /subscribers is Add, PATCH /subscribers/{external_id} 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.

Back to contents

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_countries and expires_at on POST /subscribers and PATCH /subscribers/{external_id}; exit_countries, expires_at and expired on the subscriber wherever it is answered (GET /subscribers/{external_id}, POST /subscribers/{external_id}/reset) and in the list.
  • expire= in the subscription-userinfo header carries the subscriber's end date when it has one.
  • The error codes INVALID_EXIT_COUNTRIES, INVALID_EXPIRES_AT and SUBSCRIPTION_EXPIRED.
  • PATCH /subscribers/{external_id} with "quota_bytes": null clears the quota, as this reference has said since revision 1.3. Until now such a call changed nothing.
  • POST /subscribers with quota_bytes answers created: true for a subscriber the call made, as it does without one. Until now such a call answered created: false.

Revision 1.4

11 October 2026

  • Every answer names the revision that produced it, in the X-Partner-Api-Version header. See Versions and compatibility.
  • The OpenAPI document of every revision stays available, at https://proofkit.org/partner-api/versions/1.4.json for this one.

Revision 1.3

10 October 2026

  • Subscriber quotas: an optional traffic allowance per subscriber, which the platform enforces itself. See Subscriber quotas.
  • quota_bytes on POST /subscribers; quota_bytes, quota_used_bytes, quota_remaining_bytes and over_quota on the subscriber wherever it is answered (GET /subscribers/{external_id}, POST /subscribers/{external_id}/reset), in the list, on each row of GET /usage and on GET /subscribers/{external_id}/usage.
  • PATCH /subscribers/{external_id}.
  • The error codes INVALID_QUOTA and QUOTA_EXHAUSTED.

Revision 1.2

10 October 2026

  • hosted_base on GET /account: the base of your hosted subscription URLs while a domain of your own is active.

Revision 1.1

4 October 2026

  • The olcRTC part of usage: olcrtc_bytes, olcrtc_cost_nano and olcrtc_list_cost_nano on each row of GET /usage and on GET /subscribers/{external_id}/usage, with their totals.
  • olcrtc_pricing on GET /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.

Back to contents

proofkit.

A pay-per-GB VPN on TON. Four transports, one of them shaped like a video call; an open network where anyone can run a node; a non-custodial escrow you withdraw from.

Sign in · Help center

Product

  • Marketplace
  • Network status
  • Proofs explorer
  • Speedtest
  • Pay per GB
  • Paying with TON
  • Partner API

Use cases

  • Anti-censorship VPN
  • No-logs VPN
  • Private VPN node
  • VPN for Russia
  • VPN for Iran
  • VPN for China

Protocols & apps

  • VLESS Reality
  • Hysteria2
  • XHTTP
  • olcRTC (video call)
  • Protocols compared
  • Ghostlane setup
  • Hiddify setup
  • V2RayNG setup

Run a node

  • Become an operator
  • Operator guide
  • Self-hosted VPN
  • How the network works
  • Telegram bot

Legal

  • Privacy
  • Terms
  • Acceptable use
  • Refunds
  • Cookies
  • Imprint & contact
  • Cookie settings
© 2026 ProofKit · An open network: anyone can run a node. Ghostlane, an open-source client: App Store · Google Play · GitHub Reality · Hysteria2 · XHTTP · olcRTC