Wolvy DOCS

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.

Endpoint POST /v1/videos/{id}/playback-tokens Scope playback:sign Or sign locally in 6 languages
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.

payload = base64url( {"vid": "<viewer_id>", "exp": <unix seconds>} )
sig = base64url( HMAC-SHA256( secret, payload ) ) // over the base64url string
vt = payload + "." + sig

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_id in 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#

Viewer token sequence 1. A signed-in viewer requests your page. 2. Your server signs a token for that viewer. 3. Your server returns HTML with an iframe whose src carries the token. 4. The browser loads the embed URL from Wolvy. 5. Wolvy verifies the signature and expiry. 6. The player starts and draws the verified viewer id on the watermark, and the session is logged with it. Viewer’s browser Your server Wolvy player 1 GET /lesson — signed in as user 8813 2 SIGN vt = sign({vid, exp}, secret) 3 HTML with <iframe src="…?vt=…"> 4 Load embed.wolvy.stream/…?vt=… (Referer sent) 5 VERIFY HMAC ok · not expired → user-8813 6 Player starts · watermark “user-8813” · session logged

Your code lives in steps 2 and 3. Everything else is the browser and Wolvy.

Pick how to sign#

Option A · Ask the APIOption B · Sign it yourself
What you storeOnly your API keyYour API key and the signing secret
Per tokenOne HTTP call to api.wolvy.netNothing — a local HMAC in microseconds
Rate limitsEach token counts toward 120/min and 10,000/dayUnaffected
Returnsvt, embed_url, deep_link, iframe_htmlYou build the URL (one line)
Best forGetting started, low traffic, no secret handlingBusy 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
  }'
201 · response
{
  "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_html is minimal. Prefer the recommended iframe, which adds picture-in-picture and a referrerpolicy.

Option B · Sign it yourself#

  1. Copy the signing secretIn the dashboard, open Settings → Watermark → Signing secret, reveal and copy it. It is 64 hex characters.
  2. Store it like a passwordAn environment variable or secret manager on the server — WOLVY_VIEWER_SECRET in these examples. It must never reach a browser or a mobile app.
  3. 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);

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.

TOKEN_LAB.EXE

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.

Who is watching. Shown on the watermark.
60 – 86400
Your real one lives in the dashboard under Settings → Watermark.
  1. 01
    Payload JSONexp = now + 3600s → 2026-09-21 14:13:20 UTC
    {"vid":"user-8813","exp":1790000000}
  2. 02
    base64url(01)no padding
    eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9
  3. 03
    HMAC-SHA256(secret, 02)signs the base64url string, then base64url
    XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM
  4. 04
    vt = 02 + "." + 03
    eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM
  5. 05
    Embed URL
    https://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.

secretd80d15d09b7c0e56b4d787f6a212ed8bf8d53c761b6df521278f030cb12a7781
viewer_iduser-8813
exp1790000000 (2026-09-21 14:13:20 UTC)
payload JSON{"vid":"user-8813","exp":1790000000}
payloadeyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9
sigXBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM
vteyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.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",
);

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;
}

Add it to the embed#

https://embed.wolvy.stream/{video_id}/{base64(account_id)}?vt={vt}
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)}`;

The account id is the number from GET /v1/account, base64-encoded as text (42NDI=). 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.

OptionExampleTrade-off
Your internal user iduser-8813Traceable by you, meaningless to anyone else. Recommended.
A pseudonymous idwv_9f2b6c1d3e4a5b60Same, and hides even your id format. Keep a lookup table.
Enrolment or licence numberSTU-20431Useful 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-store and 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:

  1. Sign every embed firstEvery page that shows a Wolvy player must add ?vt=. Deploy that before anything else.
  2. Turn the watermark onEnforcement applies when the watermark is enabled and “Refuse playback without a verified viewer ID” is on.
  3. 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.
  4. Watch for refusalsEach one is logged as a viewer_token_required security 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#

MistakeWhat you seeFix
Signing the JSON instead of the base64url payloadEvery token rejectedHMAC the characters before the dot
Standard base64 (+ / =) in an unencoded URLWorks for some viewers, fails for others — a + in the query string arrives as a spacebase64url without padding, and URL-encode the token
exp in millisecondsTokens that effectively never expireMath.floor(Date.now() / 1000)
Hex-decoding the secretEvery token rejectedUse the secret string as-is
Signing in the browser or appA leaked secret: anyone can pose as anyoneSign on your server only
Page cached by a CDN or pluginViewers watermarked as someone elseCache-Control: private, no-store
Secret rotated, backend not updatedEvery embed refused (with enforcement on)Rotate carefully; use the secret check above
Passing ?viewer_id= instead of ?vt=An id the viewer can editAlways 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.

  1. Prepare the deployHave the change that updates WOLVY_VIEWER_SECRET ready to ship.
  2. Rotate, then deploy at onceUntil your servers use the new secret, signed embeds fail — and with enforcement on, nothing plays.
  3. 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.
  4. VerifyRun the stored-secret check. Viewer ids are unaffected, so your leak-tracing history stays intact.