Client guide
Connect your website, server or app securely
Follow these steps so your videos play only on your own domains, only for the viewers your server lets in, and saved or shared links stop working.
- About 20 minutes
- Needs: your panel login, your DNS, your server code
- Panel address: https://streamingdog.net
How it fits together
Two secrets do the work: your embed key, which only your server knows, and the list of verified domains, which you prove with DNS.
1Secure your account and the embed key
Your panel login controls everything. The embed key lets anyone who has it sign links to your videos.
Panel account
- Use a long, unique password for the panel. Don't reuse it anywhere else.
- Give the login only to people who manage videos and settings. Your developers need the embed key, not the password.
- Sign out on shared or public computers.
Embed key
Find it under Settings → Embed key. Treat it like a database password:
| Keep it here | Never put it here |
|---|---|
An environment variable on your server (for example STREAMINGDOG_EMBED_KEY),
or your host's secret manager |
Browser JavaScript, HTML, a mobile or TV app, a URL, a Git repository, logs, screenshots, support tickets |
2Lock the player to your domains
With signed embed links off and no verified domain, the player can be embedded on any site. When one domain is verified, it plays only on verified domains.
Add and verify each domain
-
Open Settings → Websites and add the exact host your pages are on.
You enter Player allowed on example.comexample.comonly. Notwww.example.com.www.example.comwww.example.comonly*.example.comexample.comand every subdomain (www.,app.,learn., …)localhostYour own computer while developing. Counts as verified with no DNS record. Enter just the host: no
https://, no path, no port. -
At your DNS provider, add the TXT record the panel shows:
Domain you added Type Name Value example.comTXT _streamingdog.example.comstreamingdog-verify=…www.example.comTXT _streamingdog.www.example.comstreamingdog-verify=…*.example.comTXT _streamingdog.example.comstreamingdog-verify=…Copy the value from the panel; it is different for every domain. Many DNS panels (GoDaddy, Namecheap, cPanel, Cloudflare) add your domain to the name for you. There, type only
_streamingdog(or_streamingdog.www). -
Check that the record is visible, then press Verify:
nslookup -type=TXT _streamingdog.example.com # or dig +short TXT _streamingdog.example.comNew records can take a few minutes to show up. If the panel says the record isn't there yet, wait and press Verify again. A verification stays once it is made, so you can delete the TXT record afterwards.
What the lock does
- Browsers refuse to show the player inside any page that isn't on a verified domain. This covers every frame up the chain, so a verified page framed by another site doesn't play either.
- The panel refuses playback when the page's address isn't on a verified domain: “This video can only be played on approved websites.”
- From a browser, the Stream API (step 4) answers only pages on your verified domains.
- With Only browser players (step 5), the video servers serve files only to players on your verified domains.
strict-origin-when-cross-origin) works.
Don't send Referrer-Policy: no-referrer from your site, and don't put referrerpolicy="no-referrer"
on the iframe. Without the address, a locked player refuses to play.
3Sign links on your server
This is how you decide who may watch. Your server checks the viewer first, then signs a link that expires. A copied link stops working at that time.
- Put the embed key in an environment variable on your server (step 1).
- Keep Settings → Playback security → Require signed embed links on (the default for new
accounts). The player loads only with a valid
tokenandexpires. - In your page code, after your own login or payment check, build the player link with the code below.
The token
| Part | Rule |
|---|---|
expires | Unix time in seconds (10 digits at most) when the link stops loading. One hour is a good default. Keep your server's clock synced (NTP). |
| v1 | token = sha256_hex(embedKey + videoId + expires) |
| v2 | Use v2 when you send a viewer id uid and/or watermark text
wm (up to 128 characters each):token = sha256_hex(embedKey + videoId + expires + "|" + len(uid) + ":" + uid + "|" + len(wm) + ":" + wm)len is the UTF-8 byte length. A value you don't send is the empty string, written 0:. |
| Link | https://streamingdog.net/embed/{videoId}?token={token}&expires={expires}[&uid={uid}&wm={wm}],
with uid and wm URL-encoded. |
uid is your own id for the viewer. Use a user id, never a password or session token. It counts as the
viewer for stream limits and fills {uid} in the watermark. wm replaces the watermark text for
that link, for example the buyer's name or email. Both are trusted only inside a signed link: if someone edits them,
the token no longer matches.
import { createHash } from "node:crypto";
const APP_URL = "https://streamingdog.net";
const EMBED_KEY = process.env.STREAMINGDOG_EMBED_KEY; // Settings → Embed key. Never in browser code.
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
const field = (v) => `${Buffer.byteLength(v, "utf8")}:${v}`; // UTF-8 byte length + ":" + value
export function signedEmbedUrl(videoId, { ttl = 3600, uid = "", wm = "" } = {}) {
const expires = Math.floor(Date.now() / 1000) + ttl; // seconds, not milliseconds
let data = EMBED_KEY + videoId + expires;
if (uid || wm) data += "|" + field(uid) + "|" + field(wm); // v2
let url = `${APP_URL}/embed/${videoId}?token=${sha256(data)}&expires=${expires}`;
if (uid) url += `&uid=${encodeURIComponent(uid)}`;
if (wm) url += `&wm=${encodeURIComponent(wm)}`;
return url;
}
// Example (Express): sign only after YOUR check that this viewer may watch.
app.get("/lessons/:id", requireLogin, async (req, res) => {
const lesson = await db.lessons.find(req.params.id);
if (!(await canWatch(req.user, lesson))) return res.status(403).send("Not included in your plan");
const src = signedEmbedUrl(lesson.videoId, { uid: String(req.user.id), wm: req.user.email });
res.render("lesson", { src }); // <iframe src="{{src}}" …></iframe>
});<?php const APP_URL = 'https://streamingdog.net'; define('EMBED_KEY', getenv('STREAMINGDOG_EMBED_KEY')); // Settings → Embed key. Never in browser code. function sd_field(string $v): string { return strlen($v) . ':' . $v; // strlen counts UTF-8 bytes } function sd_embed_url(string $videoId, int $ttl = 3600, string $uid = '', string $wm = ''): string { $expires = time() + $ttl; $data = EMBED_KEY . $videoId . $expires; if ($uid !== '' || $wm !== '') { $data .= '|' . sd_field($uid) . '|' . sd_field($wm); // v2 } $url = APP_URL . '/embed/' . $videoId . '?token=' . hash('sha256', $data) . '&expires=' . $expires; if ($uid !== '') $url .= '&uid=' . rawurlencode($uid); if ($wm !== '') $url .= '&wm=' . rawurlencode($wm); return $url; } // After your own login / purchase check: if (!user_can_watch($user, $lesson)) { http_response_code(403); exit; } $src = sd_embed_url($lesson['video_id'], 3600, (string) $user['id'], $user['email']); echo '<iframe src="' . htmlspecialchars($src) . '" allow="autoplay; fullscreen; picture-in-picture; encrypted-media" allowfullscreen></iframe>';
import hashlib, os, time from urllib.parse import quote APP_URL = "https://streamingdog.net" EMBED_KEY = os.environ["STREAMINGDOG_EMBED_KEY"] # Settings → Embed key. Never in browser code. def field(v: str) -> str: return f"{len(v.encode('utf-8'))}:{v}" # UTF-8 byte length def embed_url(video_id: str, ttl: int = 3600, uid: str = "", wm: str = "") -> str: expires = int(time.time()) + ttl data = f"{EMBED_KEY}{video_id}{expires}" if uid or wm: data += f"|{field(uid)}|{field(wm)}" # v2 token = hashlib.sha256(data.encode("utf-8")).hexdigest() url = f"{APP_URL}/embed/{video_id}?token={token}&expires={expires}" if uid: url += "&uid=" + quote(uid, safe="") if wm: url += "&wm=" + quote(wm, safe="") return url # After your own login / purchase check: # src = embed_url(lesson.video_id, uid=str(user.id), wm=user.email)
Put the signed link in your page
<div style="position:relative;padding-top:56.25%">
<iframe src="SIGNED_LINK_FROM_YOUR_SERVER"
style="position:absolute;inset:0;width:100%;height:100%;border:0"
allow="autoplay; fullscreen; picture-in-picture; encrypted-media"
allowfullscreen loading="lazy"></iframe>
</div>- Make the link when the page is requested, not once at build time. A page cached for everyone (CDN, page cache) gives every visitor the same link: exclude those pages from caching, or keep the cache shorter than the link lifetime.
- A viewer who has started watching can keep watching for the Playback link lifetime
(step 5), even after
expireshas passed.expiresonly limits when the player can be opened. - Never sign links in browser JavaScript. If the key is in the browser, anyone can sign links to all your videos.
4Your own player or app (Stream API)
For hls.js, Video.js, Shaka, or a mobile or TV app. You get fresh signed video links after the same checks as our player.
GET https://streamingdog.net/api/v1/stream/{videoId}?token={token}&expires={expires}[&uid=…&wm=…&ip=…]
tokenandexpiresare always required. Sign them exactly like an embed link (step 3). The Stream API works even when signed embeds are off.- From a browser on one of your verified domains: the links are bound to the viewer's own address. Other sites get a 403.
- From your server: add
ip=with the viewer's IP address, so IP locks and stream limits apply to the viewer and not to your server. - The same rules as the embed apply: domain lock, stream limit, IP lock, watermark and account status. The answer is
never cached, so ask again when
expiresAtis near. - With Only our embed player selected (step 5), the Stream API answers 403.
// sha256() and EMBED_KEY as in step 3. const expires = Math.floor(Date.now() / 1000) + 3600; const token = sha256(EMBED_KEY + videoId + expires); // The viewer's real IP. Behind a CDN or load balancer, read it from the header YOUR proxy sets // (e.g. CF-Connecting-IP, or Express with "trust proxy"), never from a header the client could send. const viewerIp = req.ip; const r = await fetch( `${APP_URL}/api/v1/stream/${videoId}?token=${token}&expires=${expires}&ip=${encodeURIComponent(viewerIp)}`, ); if (!r.ok) throw new Error(`Stream API ${r.status}: ${await r.text()}`); const stream = await r.json(); // hand stream.hls (or the whole object) to your app or page
<!-- TOKEN and EXPIRES come from your server (step 3). Never the embed key. --> <video id="player" controls playsinline crossorigin="anonymous"></video> <script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script> <script type="module"> const API = "https://streamingdog.net"; const res = await fetch(`${API}/api/v1/stream/VIDEO_ID?token=${TOKEN}&expires=${EXPIRES}`); if (!res.ok) throw new Error(`Stream API: ${res.status}`); const s = await res.json(); const video = document.getElementById("player"); if (Hls.isSupported()) { const hls = new Hls(); hls.loadSource(s.hls); hls.attachMedia(video); } else if (!s.playerOnly) { video.src = s.hls; // Safari's built-in HLS } // Keeps the viewer's session open (stream limits, live viewers). if (s.sessionId) { setInterval(() => fetch(`${API}/api/playback-sessions/${s.sessionId}/ping`, { method: "POST", mode: "no-cors" }), 30_000); } </script>
{
"videoId": "abcde1234567",
"title": "My video",
"durationSec": 754,
"expiresAt": "2026-10-24T18:00:00.000Z", // the links stop working then
"hls": "https://…/master.m3u8", // every quality, audio track and subtitle
"poster": "https://…/poster.jpg",
"qualities": [ … ], // one playlist per quality
"audio": [ … ], // audio-only playlists, one per track
"subtitles": [{ "id": "…", "label": "English", "language": "en", "default": true, "vtt": "https://…" }],
"sessionId": "…", // ping it every 30 s while the page is open
"watermark": null, // text to draw over the video when the watermark is on
"playerOnly": false // true: only browser players on your domains get the files
}ip=, never from the app with the key inside it. Then turn on the IP lock and a
short link lifetime.
5Protect the video files
Settings → Download protection and Settings → Playback security. These make saved or shared file links useless.
Who can play your video links
| Option | Plays in | Refused |
|---|---|---|
| Anyone with a valid link | Any player until the link expires: browsers, VLC, TV and mobile apps | Expired or tampered links |
| Only browser players | Our player, and your own browser player (Stream API) on your verified domains | Links opened in a tab, download managers, media players, apps, other websites |
| Only our embed player (default) | The Streaming Dog embed only | All of the above, and the Stream API. Its links last minutes and renew while the player is open, and the encryption key goes only to that open player, itself encrypted. |
A stricter choice applies at once, also to links you handed out before: their master and quality
playlists are refused outside the allowed players. Audio-only links and video files a player has already loaded keep
working until they expire. With Only browser players, your own player must use hls.js or
Media Source with crossorigin="anonymous". Safari's built-in HLS is refused (hls.js plays on current
Safari).
The other switches
| Setting | What it does | Watch out for |
|---|---|---|
| Encrypt new videos (AES-128) | On by default for new accounts. Each piece of the video is encrypted. The key goes only to valid players. | Applies only to uploads made while it is on. H.264 only, no MP4 fallback. Very old iPhones (before iOS 17.1) can't play them inside our embed player. |
| Lock playback links to the viewer's IP address | A copied video link stops working on another network. | Viewers who switch from Wi-Fi to mobile data reload the player. Leave it off if many viewers have both IPv4 and IPv6. |
| Simultaneous streams per viewer | How many players one viewer may have open at once (0 = unlimited). A viewer is the signed uid,
otherwise the IP address. |
Without uid, people on the same office network count as one viewer. |
| Show a moving watermark | Semi-transparent text moves over the video. {uid} is the signed viewer id and {ip} the
viewer's IP. A signed wm replaces the text. |
Picture-in-picture is off while the watermark is on. |
| Playback link lifetime | How long the file links inside a player work: 1 hour, 6 hours (default), 24 hours or 7 days. | Shorter is safer. Viewers who pause for longer reload the page. With only our embed player, links last 5 minutes and renew by themselves instead. |
| MP4 fallback (Encoding) | Plain MP4 copies for very old browsers. | A single MP4 file is much easier to save than a stream. Leave it off unless you need it. |
Which level you need
Start from the row that matches your videos. Each level includes the one before it.
Public videos
Marketing, blog, product pages
- Verified domains (step 2)
- Nothing else needed
Members only
Behind your login
- Signed embed links with
uid - Only browser players
- Lifetime 1–6 hours
Paid content
Courses, premium videos
- Only our embed player
- Encrypt new videos
- 1–2 streams per viewer
- Watermark with the buyer (
wm) - Lifetime 1 hour
| You have | Turn on |
|---|---|
| Your own web player | Stream API from your verified domain, Only browser players |
| A mobile or TV app | Stream API from your server with ip=, Anyone with a valid link, IP lock,
short lifetime |
6Test your setup
Each test should be refused. If one plays, the setting it checks isn't on yet.
| Try this | Expected | Checks |
|---|---|---|
Open https://streamingdog.net/embed/VIDEO_ID
in a new tab, without a token | “This link has expired or is invalid.” | Signed links |
Sign a link with ttl = 60, wait two minutes, then open it |
“This link has expired or is invalid.” | Expiry, server clock |
Change one letter of uid or wm in a signed link |
“This link has expired or is invalid.” | v2 signature |
| Paste your iframe into an online HTML playground (any domain you haven't verified) | The browser refuses the frame, or “…only be played on approved websites.” | Domain lock |
While a video plays, copy a .m3u8 address from DevTools → Network and open
it in a new tab or in VLC | 403 | Only browser players |
Set streams per viewer to 1, then open the same signed uid in two tabs |
The second one: “Too many streams for this viewer.” | Stream limit |
| Search your built front-end and app for the first characters of the embed key | Nothing found | Key stays on your server |
When something is refused
The player shows the message. The Stream API returns it with this status.
| Status | Message | What to do |
|---|---|---|
| 403 | This link has expired or is invalid. | Sign a new link. Check the embed key, the video id, that expires is in seconds,
and that uid/wm are the same as when you signed. |
| 403 | This video can only be played on approved websites. | Verify the domain the page is on (mind www.), and make sure the page sends a
Referer. |
| 403 | …play only in the Streaming Dog player… | Stream API with Only our embed player selected: use the embed, or allow browser players. |
| 404 | This video doesn't exist. | Wrong id, or the video was deleted. |
| 409 | This video is still processing. | Wait until it is Ready. |
| 402 | This video is temporarily unavailable. | Your account has an unpaid balance (Billing). |
| 429 | Too many streams for this viewer. | The viewer closes another player, or you raise the limit. |
| 503 | No server is available to play this video right now. | Temporary. Try again in a minute. |
With Only browser players on, a video that starts but stays black on Safari means your own player uses Safari's built-in HLS: switch to hls.js. Still stuck? Open a support ticket. Never paste your embed key into a ticket.
Checklist
Tick items as you go. Your ticks are saved only in this browser.