# Sign your viewers

> Mint a signed viewer token (vt) so Wolvy can watermark and trace every playback: the wire format, code in six languages, test vectors and a token lab.

Source: https://docs.wolvy.net/viewer-tokens/ · Wolvy Developer API v1 · updated 2026-11-12

_Guide · needs your code_

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**

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

```text
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](https://docs.wolvy.net/api/#list-sessions) and [security events](https://docs.wolvy.net/api/#list-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.

> **Note — Encoded, not encrypted**
> Anyone can base64-decode the payload and read the id and expiry — that is expected. What they cannot do is change either one, because they cannot produce a matching signature without the secret.

## 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](https://docs.wolvy.net/wordpress/) 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:

```bash
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
  }'
```

_Also on the page in Node.js, Python, PHP._

_201 · response_

```json
{
  "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](https://docs.wolvy.net/viewer-tokens/#viewer-id).
- `ttl_seconds` — 60 to 86400. Default 21600 (6 hours).
- The response `iframe_html` is minimal. Prefer the [recommended iframe](https://docs.wolvy.net/embedding/#iframe), which adds `picture-in-picture` and a `referrerpolicy`.

> **Careful — Don’t send an Idempotency-Key here**
> A replay with the same key returns the _original_ response for 24 hours — including a token that may already have expired. Mint fresh tokens without one.

## Option B · Sign it yourself

1. **Copy the signing secret**In the dashboard, open Settings → Watermark → Signing secret, reveal and copy it. It is 64 hex characters.
2. **Store it like a password**An 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 viewer**Use the function below whenever you render a page for a signed-in viewer.

```js
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);
```

_Also on the page in PHP, Python, Go, Ruby, C#._

> **Important — Sign the base64url string, not the JSON**
> The HMAC covers the **encoded payload** — the exact characters before the dot. Signing the raw JSON gives a token that looks perfectly well-formed and is rejected every single time. The [token lab](https://docs.wolvy.net/viewer-tokens/#token-lab) shows both side by side.

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.

_[Interactive: Token Lab — runs on https://docs.wolvy.net/viewer-tokens/]_

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

```js
import assert from "node:assert/strict";
import { viewerToken } from "./wolvy.js";

const SECRET = "d80d15d09b7c0e56b4d787f6a212ed8bf8d53c761b6df521278f030cb12a7781";
assert.equal(
  viewerToken("user-8813", 1790000000, SECRET),
  "eyJ2aWQiOiJ1c2VyLTg4MTMiLCJleHAiOjE3OTAwMDAwMDB9.XBNWyMiuLw0y7WlQkzO5s2N-eI8Ltxhs4N0uvDHM4ZM",
);
```

_Also on the page in PHP, Python._

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

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

_Also on the page in PHP._

## Add it to the embed

```js
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)}`;
```

_Also on the page in PHP, Python._

The account id is the number from [`GET /v1/account`](https://docs.wolvy.net/api/#get-account), base64-encoded as text (`42` → `NDI=`). Then use the URL in the [recommended iframe](https://docs.wolvy.net/embedding/#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 | `sara@example.com` | 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.

> **Note — What a token does not stop**
> It stops a viewer from _impersonating_ someone else. It does not stop them passing _their own_ token on: until it expires, whoever holds it watches as them — and the watermark then carries the sharer’s id, which is the deterrent working. Shorter lifetimes shrink that window.

## 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 first**Every page that shows a Wolvy player must add `?vt=`. Deploy that before anything else.
2. **Turn the watermark on**Enforcement applies when the watermark is enabled _and_ “Refuse playback without a verified viewer ID” is on.
3. **Enable it**In 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 refusals**Each one is logged as a `viewer_token_required` [security event](https://docs.wolvy.net/api/#list-security-events) — a quick way to find an embed you missed.

Check the state from code with [`GET /v1/settings/player`](https://docs.wolvy.net/api/#get-player-settings): `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](https://docs.wolvy.net/viewer-tokens/#rotation); 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.

1. **Prepare the deploy**Have the change that updates `WOLVY_VIEWER_SECRET` ready to ship.
2. **Rotate, then deploy at once**Until your servers use the new secret, signed embeds fail — and with enforcement on, nothing plays.
3. **Flush cached tokens**Tokens cached per viewer were signed with the old secret; key your cache on a fingerprint of the secret so they retire automatically.
4. **Verify**Run the [stored-secret check](https://docs.wolvy.net/viewer-tokens/#test-vectors). Viewer ids are unaffected, so your leak-tracing history stays intact.

## Common questions

### What is a viewer token?

A viewer token (`vt`) is a tiny signed statement — this viewer id, valid until this time — that your server hands to the player. Wolvy checks its HMAC-SHA256 signature before trusting the id, then draws it on the watermark and records it against the playback session.

### Where do I sign a token — in the browser or on the server?

On the server, always. The signing secret must never reach a browser or a mobile app: whoever holds it can mint a token for any viewer id, which is exactly what the token exists to prevent.

### How long should a viewer token last?

Between the 60-second floor and the 24-hour ceiling — a few hours suits a lesson or an event session. Re-mint a cached token once less than about a tenth of its lifetime remains, and never let a page carrying a token into a shared or CDN cache.

### What happens if the token is missing, expired or forged?

By default the video still plays, just without a verified identity. With [enforcement](https://docs.wolvy.net/viewer-tokens/#enforcement) on — the watermark enabled and “refuse playback without a verified viewer ID” switched on — the viewer gets a refusal page and Wolvy logs a `viewer_token_required` security event.

### Do I have to call the API to get a token?

No. [`POST /v1/videos/{id}/playback-tokens`](https://docs.wolvy.net/api/#create-playback-token) will mint one for you, but signing it yourself is three lines of HMAC with no API call and no round trip. Both produce exactly the same string — see [Option B](https://docs.wolvy.net/viewer-tokens/#sign-locally).

### Does one token work for several videos?

Yes. The payload holds only the viewer id and an expiry, so a single token covers every video on the account — a page with a dozen players needs one signature, and you can cache one token per viewer.

Something wrong or unclear on this page? Email [contact@wolvy.net](mailto:contact@wolvy.net?subject=Docs%3A%20Sign%20your%20viewers) — include the page name.
