Guide · needs your code
Sign your viewers
A viewer token (vt) tells the Wolvy player who is watching, in a form nobody can forge. It is what puts a real person’s id on the watermark and on every session record — and what enforcement checks before a video plays.
On this page
What a viewer token is#
A token is a tiny signed statement: “this is viewer user-8813, valid until this time”, signed with a secret only your server and Wolvy know. The player sends it to Wolvy, which checks the signature and the expiry before trusting the id.
With a verified token, Wolvy:
- Draws the viewer id on the watermark, if the watermark is on — so a recording that leaks shows whose account it came from.
- Records the id against the playback session.
viewer_idin viewer sessions and security events is server-verified, never a value the viewer typed. - Lets playback through when enforcement is on. Without a valid token the player refuses to start.
How it flows#
Your code lives in steps 2 and 3. Everything else is the browser and Wolvy.
Pick how to sign#
| Option A · Ask the API | Option B · Sign it yourself | |
|---|---|---|
| What you store | Only your API key | Your API key and the signing secret |
| Per token | One HTTP call to api.wolvy.net | Nothing — a local HMAC in microseconds |
| Rate limits | Each token counts toward 120/min and 10,000/day | Unaffected |
| Returns | vt, embed_url, deep_link, iframe_html | You build the URL (one line) |
| Best for | Getting started, low traffic, no secret handling | Busy pages, many videos per page — the WordPress plugin does this |
Both produce byte-identical tokens. You can start with A and move to B without changing anything else.
Option A · Ask the API#
Call it from your backend when you render the page for a signed-in viewer:
curl -X POST "https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/playback-tokens" \
-H "Authorization: Bearer $WOLVY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"viewer_id": "user-8813",
"ttl_seconds": 7200
}' const res = await fetch("https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/playback-tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WOLVY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
viewer_id: "user-8813",
ttl_seconds: 7200,
}),
});
const playback = await res.json();
if (!res.ok) throw new Error(`${res.status} ${playback.error.code}`); import os
import requests
res = requests.post(
"https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/playback-tokens",
headers={"Authorization": f"Bearer {os.environ['WOLVY_API_KEY']}"},
json={"viewer_id": "user-8813", "ttl_seconds": 7200},
timeout=30,
)
playback = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {playback['error']['code']}") <?php
$ch = curl_init('https://api.wolvy.net/v1/videos/a1b2c3d4e5f60718293a/playback-tokens');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('WOLVY_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'viewer_id' => 'user-8813',
'ttl_seconds' => 7200,
]),
]);
$playback = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException("$status {$playback['error']['code']}");
} {
"object": "playback_token",
"video_id": "a1b2c3d4e5f60718293a",
"viewer_id": "user-8813",
"vt": "eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
"expires_at": "2026-09-21T14:13:20+00:00",
"embed_url": "https://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=?vt=eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
"deep_link": "wolvy://play/a1b2c3d4e5f60718293a/NDI=/eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
"iframe_html": "<iframe src=\"https://embed.wolvy.stream/…?vt=…\" … allowfullscreen></iframe>"
} viewer_id— required, 1–64 characters. See choosing a viewer_id.ttl_seconds— 60 to 86400. Default 21600 (6 hours).- The response
iframe_htmlis minimal. Prefer the recommended iframe, which addspicture-in-pictureand areferrerpolicy.
Option B · Sign it yourself#
- Copy the signing secretIn the dashboard, open Settings → Watermark → Signing secret, reveal and copy it. It is 64 hex characters.
- Store it like a passwordAn environment variable or secret manager on the server —
WOLVY_VIEWER_SECRETin these examples. It must never reach a browser or a mobile app. - Sign per viewerUse the function below whenever you render a page for a signed-in viewer.
import crypto from "node:crypto";
/** Sign a viewer token. exp is a Unix time in SECONDS. */
export function viewerToken(viewerId, exp, secret) {
const payload = Buffer.from(JSON.stringify({ vid: viewerId, exp })).toString("base64url");
const sig = crypto.createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${sig}`;
}
const exp = Math.floor(Date.now() / 1000) + 3600; // Date.now() is milliseconds
const vt = viewerToken(String(user.id), exp, process.env.WOLVY_VIEWER_SECRET); <?php
/** Sign a viewer token. $exp is a Unix time in seconds. PHP 7.4+. */
function wolvy_viewer_token(string $viewerId, int $exp, string $secret): string
{
$b64url = fn(string $bytes): string => rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
$payload = $b64url(json_encode(['vid' => $viewerId, 'exp' => $exp]));
return $payload . '.' . $b64url(hash_hmac('sha256', $payload, $secret, true));
}
$vt = wolvy_viewer_token((string) $user->id, time() + 3600, getenv('WOLVY_VIEWER_SECRET')); import base64
import hashlib
import hmac
import json
import os
import time
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def viewer_token(viewer_id: str, exp: int, secret: str) -> str:
"""Sign a viewer token. exp is a Unix time in seconds."""
payload = b64url(json.dumps({"vid": viewer_id, "exp": exp}, separators=(",", ":")).encode())
sig = b64url(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())
return f"{payload}.{sig}"
vt = viewer_token(str(user.id), int(time.time()) + 3600, os.environ["WOLVY_VIEWER_SECRET"]) package wolvy
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
)
type viewerPayload struct {
Vid string `json:"vid"`
Exp int64 `json:"exp"`
}
// ViewerToken signs a viewer token. exp is a Unix time in seconds.
func ViewerToken(viewerID string, exp int64, secret string) (string, error) {
body, err := json.Marshal(viewerPayload{Vid: viewerID, Exp: exp})
if err != nil {
return "", err
}
payload := base64.RawURLEncoding.EncodeToString(body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// vt, err := wolvy.ViewerToken(userID, time.Now().Unix()+3600, os.Getenv("WOLVY_VIEWER_SECRET")) require "base64"
require "json"
require "openssl"
# Sign a viewer token. exp is a Unix time in seconds.
def wolvy_viewer_token(viewer_id, exp, secret)
payload = Base64.urlsafe_encode64(JSON.generate({ vid: viewer_id, exp: exp }), padding: false)
sig = Base64.urlsafe_encode64(OpenSSL::HMAC.digest("SHA256", secret, payload), padding: false)
"#{payload}.#{sig}"
end
vt = wolvy_viewer_token(current_user.id.to_s, Time.now.to_i + 3600, ENV.fetch("WOLVY_VIEWER_SECRET")) using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static class WolvyTokens
{
static string B64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
/// <summary>Sign a viewer token. exp is a Unix time in seconds. .NET 6+.</summary>
public static string ViewerToken(string viewerId, long exp, string secret)
{
var payload = B64Url(JsonSerializer.SerializeToUtf8Bytes(new { vid = viewerId, exp }));
var sig = B64Url(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(payload)));
return $"{payload}.{sig}";
}
}
// var vt = WolvyTokens.ViewerToken(user.Id, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 3600, secret); Use the secret exactly as shown in the dashboard, as a text string. Do not hex-decode it into bytes first.
Token lab#
Build a token one step at a time with a demo secret, see the classic mistake fail, or paste a token to decode it and check its expiry.
Runs in your browser — nothing is sent anywhere. Still, use the demo secret: never paste your production signing secret into a web page, this one included.
- 01Payload JSONexp = now + 3600s → 2026-09-21 14:13:20 UTC{"vid":"user-8813","exp":1790000000}
- 02base64url(01)no paddingeyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9
- 03HMAC-SHA256(secret, 02)signs the base64url string, then base64urlXBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM
- 04vt = 02 + "." + 03eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM
- 05Embed URLhttps://embed.wolvy.stream/a1b2c3d4e5f60718293a/NDI=?vt=eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM
Test vectors#
Pin your implementation with these values. Any correct signer — in any language — must produce exactly this token.
| secret | d80d15d09b7c0e56b4d787f6a212ed8bf8d53c761b6df521278f030cb12a7781 |
| viewer_id | user-8813 |
| exp | 1790000000 (2026-09-21 14:13:20 UTC) |
| payload JSON | {"vid":"user-8813","exp":1790000000} |
| payload | eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9 |
| sig | XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM |
| vt | eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM |
To reproduce the vector exactly, serialise compact JSON with vid before exp. Wolvy itself accepts any valid JSON; the spelling only matters for matching this string.
import assert from "node:assert/strict";
import { viewerToken } from "./wolvy.js";
const SECRET = "d80d15d09b7c0e56b4d787f6a212ed8bf8d53c761b6df521278f030cb12a7781";
assert.equal(
viewerToken("user-8813", 1790000000, SECRET),
"eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
); <?php
$secret = 'd80d15d09b7c0e56b4d787f6a212ed8bf8d53c761b6df521278f030cb12a7781';
assert(
wolvy_viewer_token('user-8813', 1790000000, $secret)
=== 'eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM'
); SECRET = "d80d15d09b7c0e56b4d787f6a212ed8bf8d53c761b6df521278f030cb12a7781"
assert viewer_token("user-8813", 1790000000, SECRET) == (
"eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM"
) Check that your stored secret is current
After a rotation, tokens keep minting and Wolvy keeps rejecting them. The exact test: mint one token through the API, re-sign its payload with your stored secret and compare.
import crypto from "node:crypto";
// Proves your stored secret still matches the account's: mint one token through the API,
// re-sign its payload locally, compare. A mismatch means the secret was rotated.
export async function signingSecretIsCurrent(videoId) {
const res = await fetch(`https://api.wolvy.net/v1/videos/${videoId}/playback-tokens`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.WOLVY_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ viewer_id: "secret-check", ttl_seconds: 60 }),
});
const { vt } = await res.json();
const [payload, sig] = vt.split(".");
const mine = crypto.createHmac("sha256", process.env.WOLVY_VIEWER_SECRET).update(payload).digest("base64url");
return mine === sig;
} <?php
// Mint one token through the API, re-sign its payload with the stored secret, compare.
function wolvy_signing_secret_is_current(string $videoId): bool
{
$ch = curl_init('https://api.wolvy.net/v1/videos/' . rawurlencode($videoId) . '/playback-tokens');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('WOLVY_API_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['viewer_id' => 'secret-check', 'ttl_seconds' => 60]),
]);
[$payload, $sig] = explode('.', json_decode(curl_exec($ch), true)['vt']);
$mine = rtrim(strtr(base64_encode(hash_hmac('sha256', $payload, getenv('WOLVY_VIEWER_SECRET'), true)), '+/', '-_'), '=');
return hash_equals($sig, $mine);
} Add it to the embed#
const accountId = 42; // GET /v1/account → id
const base = `https://embed.wolvy.stream/${videoId}/${Buffer.from(String(accountId)).toString("base64")}`;
const embedUrl = `${base}?vt=${encodeURIComponent(vt)}`; <?php
$accountId = 42; // GET /v1/account → id
$embedUrl = 'https://embed.wolvy.stream/' . rawurlencode($videoId) . '/' . base64_encode((string) $accountId)
. '?vt=' . rawurlencode($vt); import base64
from urllib.parse import quote
account_id = 42 # GET /v1/account → id
account = base64.b64encode(str(account_id).encode()).decode()
embed_url = f"https://embed.wolvy.stream/{quote(video_id)}/{account}?vt={quote(vt)}" The account id is the number from GET /v1/account, base64-encoded as text (42 → NDI=). Then use the URL in the recommended iframe.
Choosing a viewer_id#
The id is drawn on screen during playback, so it has two jobs that pull in opposite directions: identify a real person well enough to trace a leak, and not expose anything they would mind a colleague reading over their shoulder.
| Option | Example | Trade-off |
|---|---|---|
| Your internal user id | user-8813 | Traceable by you, meaningless to anyone else. Recommended. |
| A pseudonymous id | wv_9f2b6c1d3e4a5b60 | Same, and hides even your id format. Keep a lookup table. |
| Enrolment or licence number | STU-20431 | Useful when the course seat matters more than the login. |
| Email address | [email protected] | The strongest deterrent — and personal data shown on screen. |
- Stable per person. Rotating the signing secret must not renumber viewers, or the trail breaks at the moment someone investigates a leak.
- At most 64 bytes, plain text, no control characters.
- No secrets. It is readable by anyone who decodes the token.
Lifetime & caching#
- One token covers every video on the account. The payload is only
{vid, exp}, so a page with a dozen players needs one signature, and you can cache a token per viewer. - Pick the lifetime you can live with. A token is valid until
exp. A few hours suits a lesson or an event session; the floor is 60 seconds and the ceiling 24 hours. - Re-mint before it runs out. Refresh a cached token once less than about a tenth of its lifetime remains, so nobody starts a long video on a token about to expire.
- Never cache the page publicly. A page with a token belongs to one viewer — send
Cache-Control: private, no-storeand exclude it from CDN and page caches.
Enforcement#
By default an embed without a token still plays — just without a verified identity. Turn on enforcement to close that route:
- Sign every embed firstEvery page that shows a Wolvy player must add
?vt=. Deploy that before anything else. - Turn the watermark onEnforcement applies when the watermark is enabled and “Refuse playback without a verified viewer ID” is on.
- Enable itIn Settings → Watermark → Enforcement. From the next playback, an embed with a missing, expired or forged token shows a refusal page instead of the video.
- Watch for refusalsEach one is logged as a
viewer_token_requiredsecurity event — a quick way to find an embed you missed.
Check the state from code with GET /v1/settings/player: watermark.enabled and watermark.required.
Common mistakes#
| Mistake | What you see | Fix |
|---|---|---|
| Signing the JSON instead of the base64url payload | Every token rejected | HMAC the characters before the dot |
Standard base64 (+ / =) in an unencoded URL | Works for some viewers, fails for others — a + in the query string arrives as a space | base64url without padding, and URL-encode the token |
exp in milliseconds | Tokens that effectively never expire | Math.floor(Date.now() / 1000) |
| Hex-decoding the secret | Every token rejected | Use the secret string as-is |
| Signing in the browser or app | A leaked secret: anyone can pose as anyone | Sign on your server only |
| Page cached by a CDN or plugin | Viewers watermarked as someone else | Cache-Control: private, no-store |
| Secret rotated, backend not updated | Every embed refused (with enforcement on) | Rotate carefully; use the secret check above |
Passing ?viewer_id= instead of ?vt= | An id the viewer can edit | Always send a signed vt |
Rotating the secret#
Rotate only if the secret may have leaked. In Settings → Watermark → Signing secret, Rotate takes effect immediately: every token signed with the old secret stops verifying, and there is no overlap period.
- Prepare the deployHave the change that updates
WOLVY_VIEWER_SECRETready to ship. - Rotate, then deploy at onceUntil your servers use the new secret, signed embeds fail — and with enforcement on, nothing plays.
- Flush cached tokensTokens cached per viewer were signed with the old secret; key your cache on a fingerprint of the secret so they retire automatically.
- VerifyRun the stored-secret check. Viewer ids are unaffected, so your leak-tracing history stays intact.
Something wrong or unclear on this page? Email [email protected] — include the page name.