VCP 1 Documentation›Publishing
Authentication
The service token authenticates the gateway; this page is about the end user — three auth modes, plus an optional user token for privileged access.
Your manifest's auth.type declares one of three modes: none,api_key, or pairing. User credentials are held on the device, relayed per-request by the gateway, and never persisted by the VC service. Independently of the mode, you can also accept a user token that upgrades what the API returns.
| Mode | User experience | Best for |
|---|---|---|
none | Nothing to do | Fully public catalogs. |
api_key | Pastes a key from your settings page | Sites that already hand out personal keys. |
pairing | Approves the device in their own browser | Recommended — no credentials ever typed into the player. |
none — public library
No handshake. Requests carry only the service token.
"auth": { "type": "none" }api_key — pasted key
For sites that hand users a personal key on a settings page. The client shows yourkey_hint, optionally deep-links key_url, and the user pastes the key. It's relayed on every request as Authorization: Bearer vcpk_….
"auth": {
"type": "api_key",
"key_hint": "Find your key at example.com/settings/vcp",
"key_url": "https://example.com/settings/vcp"
}Provider requirements:
- Keys should carry a recognizable prefix (
vcpk_) so leaked keys can be found by secret scanners. - Keys must be revocable per-device/per-key from the user's settings page.
- Keys must carry at least 128 bits of entropy.
pairing — device authorization (recommended)
Modeled on the OAuth 2.0 Device Authorization Grant (RFC 8628). The user never types credentials into the player: the app shows a short code, and the user approves it in their own logged-in browser session on your site. All /pair and/token calls are made by the gateway (with the service token) on behalf of the device; only the approval leg is direct user↔provider.
Client ──▶ VC service Provider User's browser │ POST /pair (via gateway) │ │ │ ────────────────────────────────▶ │ │ │ ◀──────────────────────────────── 201: pairing_code, poll_token, │ │ verification_url, expires_in │ │ │ │ │ (shows code "XKCD-9204" and │ user logs in, enters or │ │ opens verification_url) │◀─ confirms the code ─────────│ │ │ │ │ POST /token (grant_type=pairing_poll, poll_token) ... every 5 s │ │ ────────────────────────────────▶ │ │ │ ◀── 400 authorization_pending (until approved) │ │ ◀── 200: access_token, refresh_token, expires_in │
The flow in code
# Step 1 - create a pairing request
curl -X POST "https://api.example.com/vcp/v1/pair" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "Content-Type: application/json" \
-d '{
"client_name": "VClarity",
"client_version": "2.4.1",
"device_model": "iPhone16,2",
"device_name": "Ash phone"
}'
# Step 2 - poll until the user approves in their browser
# (400 authorization_pending until then)
curl -X POST "https://api.example.com/vcp/v1/token" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "Content-Type: application/json" \
-d '{ "grant_type": "pairing_poll", "poll_token": "vcpp_9f31c6..." }'import time
import requests
API = "https://api.example.com/vcp/v1"
HEADERS = {"VCP-Service-Token": "vcps_a81f42..."}
# Step 1 - create a pairing request
pairing = requests.post(f"{API}/pair", headers=HEADERS, json={
"client_name": "VClarity",
"client_version": "2.4.1",
"device_model": "iPhone16,2",
"device_name": "Ash phone",
}).json()
print("Show the user:", pairing["pairing_code"])
print("They approve at:", pairing["verification_url"])
# Step 2 - poll until approved
interval = pairing["poll_interval"]
while True:
time.sleep(interval)
resp = requests.post(f"{API}/token", headers=HEADERS, json={
"grant_type": "pairing_poll",
"poll_token": pairing["poll_token"],
})
body = resp.json()
if resp.ok:
print("access_token:", body["access_token"])
break
code = body["error"]["code"]
if code == "slow_down":
interval += 5
elif code != "authorization_pending":
raise RuntimeError(code) # pairing_expired -> restart the flowrequire "rest-client"
require "json"
API = "https://api.example.com/vcp/v1"
HEADERS = { "VCP-Service-Token" => "vcps_a81f42...", content_type: :json }
# Step 1 - create a pairing request
pairing = JSON.parse(RestClient.post("#{API}/pair", {
client_name: "VClarity",
client_version: "2.4.1",
device_model: "iPhone16,2",
device_name: "Ash phone"
}.to_json, HEADERS).body)
puts "Show the user: #{pairing["pairing_code"]}"
puts "They approve at: #{pairing["verification_url"]}"
# Step 2 - poll until approved
interval = pairing["poll_interval"]
loop do
sleep interval
begin
resp = RestClient.post("#{API}/token", {
grant_type: "pairing_poll",
poll_token: pairing["poll_token"]
}.to_json, HEADERS)
puts "access_token: #{JSON.parse(resp.body)["access_token"]}"
break
rescue RestClient::BadRequest => error
code = JSON.parse(error.response.body).dig("error", "code")
interval += 5 if code == "slow_down"
next if ["authorization_pending", "slow_down"].include?(code)
raise code # pairing_expired -> restart the flow
end
endconst API = 'https://api.example.com/vcp/v1'
const HEADERS = {
'VCP-Service-Token': 'vcps_a81f42...',
'Content-Type': 'application/json',
}
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000))
// Step 1 - create a pairing request
const pairing_resp = await fetch(API + '/pair', {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({
client_name: 'VClarity',
client_version: '2.4.1',
device_model: 'iPhone16,2',
device_name: 'Ash phone',
}),
})
const pairing = await pairing_resp.json()
console.log('Show the user:', pairing.pairing_code)
console.log('They approve at:', pairing.verification_url)
// Step 2 - poll until approved
let interval = pairing.poll_interval
while (true) {
await sleep(interval)
const resp = await fetch(API + '/token', {
method: 'POST',
headers: HEADERS,
body: JSON.stringify({ grant_type: 'pairing_poll', poll_token: pairing.poll_token }),
})
const body = await resp.json()
if (resp.ok) {
console.log('access_token:', body.access_token)
break
}
if (body.error.code === 'slow_down') interval += 5
else if (body.error.code !== 'authorization_pending') throw new Error(body.error.code)
}The pairing request returns:
201 Created
{
"pairing_code": "XKCD-9204",
"poll_token": "vcpp_9f31c6...",
"verification_url": "https://example.com/vcp/approve?code=XKCD-9204",
"expires_in": 600,
"poll_interval": 5
}While pending, the token endpoint answers 400 authorization_pending — poll every poll_interval seconds, and add 5 seconds whenever you seeslow_down. After expires_in seconds it answers400 pairing_expired and the client restarts pairing. On approval:
200 OK
{
"access_token": "vcpa_71be02...",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "vcpr_c04d11...",
"scope": "library.read progress.write"
}Provider requirements for pairing codes:
- Codes must be single-use, expire in 10 minutes or less, and be unpredictable. As little as 20 bits shown to the user is acceptable only because approval happens in an authenticated browser session, attempts are rate-limited, and pairing can only be initiated through the gateway. The
poll_tokenitself must carry at least 128 bits. - The approval page must display the requesting
client_nameanddevice_name, and require an authenticated session. - Code-entry and approval attempts must be rate-limited (e.g. 5 tries per code).
Refreshing tokens
Access tokens should be short-lived (24 hours or less). When one expires, the gateway exchanges the refresh token for a new pair:
curl -X POST "https://api.example.com/vcp/v1/token" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "Content-Type: application/json" \
-d '{ "grant_type": "refresh_token", "refresh_token": "vcpr_c04d11..." }'import requests
resp = requests.post("https://api.example.com/vcp/v1/token",
headers={"VCP-Service-Token": "vcps_a81f42..."},
json={"grant_type": "refresh_token", "refresh_token": "vcpr_c04d11..."})
resp.raise_for_status()
tokens = resp.json()
# Refresh tokens rotate: persist BOTH new tokens, the old refresh token is dead.
print(tokens["access_token"], tokens["refresh_token"])require "rest-client"
require "json"
resp = RestClient.post("https://api.example.com/vcp/v1/token",
{ grant_type: "refresh_token", refresh_token: "vcpr_c04d11..." }.to_json,
{ "VCP-Service-Token" => "vcps_a81f42...", content_type: :json })
tokens = JSON.parse(resp.body)
# Refresh tokens rotate: persist BOTH new tokens, the old refresh token is dead.
puts tokens["access_token"], tokens["refresh_token"]const resp = await fetch('https://api.example.com/vcp/v1/token', {
method: 'POST',
headers: {
'VCP-Service-Token': 'vcps_a81f42...',
'Content-Type': 'application/json',
},
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: 'vcpr_c04d11...' }),
})
if (!resp.ok) throw new Error('refresh failed: ' + resp.status)
const tokens = await resp.json()
// Refresh tokens rotate: persist BOTH new tokens, the old refresh token is dead.
console.log(tokens.access_token, tokens.refresh_token)User tokens — privileged access
Independently of auth.type, you may accept a user token: a credential the user copies from their signed-in account on your website (a premium membership, a private-library grant) and pastes into the client next to the server URL. Presenting it upgrades what the API returns — typically unlocking privileged stream sources (higher resolutions, members-only items) that anonymous or merely-paired sessions don't get.
Advertise support in your manifest:
"user_token": {
"accepted": true,
"hint": "Paste your access token from example.com/account/token",
"url": "https://example.com/account/token"
}The token travels in its own header on every API request — never inAuthorization — so it composes with any auth mode without collision. Effective access is the union of what the session credential and the user token each grant:
# The user token travels in its own header, alongside whatever session
# credential the request already carries:
curl "https://api.example.com/vcp/v1/videos/vid_88213" \
-H "VCP-Service-Token: vcps_a81f42..." \
-H "Authorization: Bearer vcpa_71be02..." \
-H "VCP-User-Token: vcpu_5f19ab..."Rules:
- User tokens should carry the
vcpu_prefix, must have at least 128 bits of entropy, and must be revocable from the user's account page on your site. - An invalid or expired user token is a request failure —
401with codeinvalid_user_token— never a silent downgrade to public access. A silent downgrade would make the client cache public sources against a user who believes they have privileged access. - Clients respond to
invalid_user_tokenby keeping the library, marking the server "token needs attention", and prompting for a new token. The stale token is not retried automatically. - The token lives in the device secure store; the gateway relays it per-request and never persists it.
- Scope privileged behavior to content (which sources and items are visible), not protocol shape — responses stay structurally identical with and without a token.
Scopes
Scope names follow one convention: library.read covers every read endpoint, and each capability that writes provider-side state defines a<capability>.write scope. You may ignore scoping entirely and treat every credential as all-scopes.
| Scope | Grants |
|---|---|
library.read | All read endpoints. |
progress.write | PUT /videos/{id}/progress |
reactions.write | Reaction writes. |
playlists.write | Playlist CRUD and reordering. |
A future or vendor capability that introduces writes reuses the same pattern (x_acme_comments.write), so clients can reason about scopes without knowing the feature.