Viewer watermark and your logo, step by step

For courses, paid content, internal briefings and anything else that should not leak: a viewer watermark draws each viewer's own details over the video (their name, email, phone, your ID for them, the date and time), so a screen recording or a phone filming the screen shows who it came from. Your logo sits in a corner of every video. Both are drawn by plycdn's players over the picture: they are never part of the video file, and the logo is never a link. Both are included in every plan. This page takes you from switching them on to checking that a recording shows who watched. Every rule and field is in the reference: Viewer watermark and player logo, Video libraries, Playback sessions and progress and Watch page brand.

You need: a video library. To show who is watching, your backend running one of plycdn's server packages (ASP.NET Core, Node.js or Python) or calling the API with your key. The logo, the fixed text and the date and time need no code at all.

1How it works

  1. You choose what the watermark shows and how it looks, once for your organization, in Account → Branding. Every video library uses it unless you give a library its own.
  2. Your server tells plycdn who is watching each time a player asks for a video: the viewer's name, email, phone or your own ID for them. plycdn never stores these details or writes them to any log.
  3. plycdn's players draw the watermark over the video in the viewer's own date and time, move it around, and put it back if someone removes or hides it.
  4. With Require, a player that does not draw the watermark cannot keep playing: links last about 10 minutes, and each renewal must prove the watermark is still there.

2Choose Show or Require

Show Require
What viewers see The watermark, drawn by plycdn's players The same
Players that don't draw it (older versions, other players, a plain link) Still play the video, with no watermark Can't play the video
Links As today (a library's link lifetime, 4 hours by default) About 10 minutes, renewed by the player as the video plays
Downloads As today Off: a download has no watermark (see the honest limit)
The iPhone's own full-screen player, picture-in-picture, casting to a TV Not used while the watermark shows Not used while the watermark shows
Best for Deterring leaks without changing anything else Content that must never play without it

Start with Show, check your application plays your videos with it, then move to Require.

3Set it up in the dashboard

The watermark. Account → Branding → Watermark:

  1. Mode: Off, Show or Require.
  2. What it shows: choose from Add: Name, Email, Phone, Your viewer ID, IP address and Date and time. They appear top to bottom in the order of the list; use the arrows to move them. On Phone, Only the last 4 digits is on by default (•••• 2345).
  3. Fixed text (optional, up to 60 characters): shown last, on every video, such as your organization's name.
  4. Opacity (5 to 60 %), Size (small, medium or large, relative to the video, so it reads the same on a phone and a TV), Colour (Automatic, Light or Dark), Movement (Moves every 10 to 300 seconds, 30 by default, or Stays put) and Where it appears (Anywhere, or Only these areas: tick any of the nine areas of the picture).
  5. Save. The preview beside the settings draws a sample viewer exactly as the players will.

Your logo. Account → Branding → Player logo: upload a PNG, JPEG or WebP image up to 512 KB under Logo image (it is saved at once, and also shows on your watch page), then turn on Show your logo on the player and choose the Corner, Size (a share of the video's width), Opacity (20 to 100 %) and Distance from the edges (0 to 64 pixels). Save.

One library differently. Video streaming → Video libraries → open the library → Protection:

  • Viewer watermark → Which watermark: Your organization's (as set in Branding, and following it as it changes) or This library's own, which starts from your organization's settings and opens the same editor.
  • Player logo: Your organization's logo or No logo.

The Video libraries list shows a Watermark or Watermark required chip on each library that has one. A sub-organization uses its parent organization's branding, watermark and logo until it saves its own. Its first logo upload keeps the watermark and player logo it was using, and a first watermark or player logo change is made to the parent's settings, so a Require it inherited stays on.

4From your code

Changing the organization's branding or a library needs an admin key, so it belongs in your backend or your setup scripts.

PUT   /api/content/v1/watch-brand          {"watermark": {"mode": "show", "fields": ["name", "email", "time"], "text": "Example Academy"},
                                            "playerLogo": {"enabled": true, "position": "bottom-right"}}
PUT   /api/content/v1/watch-brand/logo     (the image itself, with its Content-Type)
PATCH /api/content/v1/libraries/{libraryId}   {"watermark": {"mode": "require"}}
PATCH /api/content/v1/libraries/{libraryId}   {"watermark": {"mode": "brand"}}      (back to the organization's)
PATCH /api/content/v1/libraries/{libraryId}   {"logo": {"mode": "off"}}

The fields you send are merged over what is saved. A library's effectivePlayer says what its viewers get now: {"watermark": {…} | null, "logo": {"url", "position", "size", "opacity", "inset"} | null}.

ASP.NET Core

await videos.UpdateWatchBrandAsync(orgId, new UpdateWatchBrandRequest
{
    Watermark = new UpdateWatermarkSettings { Mode = WatermarkModes.Show, Fields = new[] { "name", "email", "time" }, Text = "Example Academy" },
    PlayerLogo = new UpdatePlayerLogoSettings { Enabled = true, Position = "bottom-right" },
});
await videos.UploadWatchBrandLogoAsync(orgId, File.OpenRead("logo.png"), "image/png");

await videos.UpdateLibraryAsync(orgId, libraryId, new UpdateLibraryRequest
{
    Watermark = new UpdateWatermarkSettings { Mode = WatermarkModes.Require },
});
await videos.UpdateLibraryAsync(orgId, libraryId, new UpdateLibraryRequest { UseBrandWatermark = true });
await videos.UpdateLibraryAsync(orgId, libraryId, new UpdateLibraryRequest { Logo = new LibraryLogo(LibraryLogo.Off) });

Node.js

await video.updateWatchBrand(orgId, {
  watermark: { mode: 'show', fields: ['name', 'email', 'time'], text: 'Example Academy' },
  playerLogo: { enabled: true, position: 'bottom-right' },
});
await video.updateLibrary(orgId, libraryId, { watermark: { mode: 'require' } });
await video.updateLibrary(orgId, libraryId, { useBrandWatermark: true });
await video.updateLibrary(orgId, libraryId, { logo: { mode: 'off' } });

Python

video.update_watch_brand(org_id, {
    "watermark": {"mode": "show", "fields": ["name", "email", "time"], "text": "Example Academy"},
    "playerLogo": {"enabled": True, "position": "bottom-right"},
})
video.update_library(org_id, library_id, {"watermark": {"mode": "require"}})
video.update_library(org_id, library_id, {"useBrandWatermark": True})
video.update_library(org_id, library_id, {"logo": {"mode": "off"}})

Until your organization saves a watermark or a player logo, reading the brand leaves them out and the defaults apply: the watermark is off (with name, email and the date and time chosen, the phone masked, 25 % opacity, medium, automatic colour, moving every 30 seconds, anywhere) and the logo is off (top right, medium, 80 % opacity, 16 pixels from the edges). The .NET package fills them in for you (WatermarkSettings.Defaults, PlayerLogoSettings.Defaults); Node.js and Python export them as WATERMARK_DEFAULTS and PLAYER_LOGO_DEFAULTS.

5Name the viewer on your server

With plycdn's server packages this is the only code you add. The players already ask your backend's sign route for a link; the route asks your hook who the viewer is and passes the details to plycdn with the playback session.

ASP.NET Core

builder.Services.AddPlycdnVideo(builder.Configuration);
builder.Services.AddPlycdnStorage(o =>
    o.PlaybackWatermarkFor = (http, organization, library, video) =>
        ValueTask.FromResult<PlycdnWatermarkViewer?>(new PlycdnWatermarkViewer
        {
            Name = http.User.Identity?.Name,
            Email = http.User.FindFirst("email")?.Value,
            ViewerId = http.User.FindFirst("student_id")?.Value,
        }));

Node.js

app.use('/api/plycdn/v1', createPlycdnRouter({
  storage,
  signer: new PlycdnLinkSigner(storage),
  video: new PlycdnVideoApi(storage),
  resolveOrganization: (req) => req.user?.plycdnOrganizationId ?? null,
  authorize: (req, operation, bucketId, key) => true,   // your own rules
  playbackWatermarkFor: (req, organizationId, libraryId, videoId) =>
    req.user ? { name: req.user.name, email: req.user.email, viewerId: req.user.studentId } : null,
}));

Python

def playback_watermark_for(request, organization_id, library_id, video_id):
    user = getattr(request.state, "user", None)
    return {"name": user.name, "email": user.email, "viewerId": user.student_id} if user else None

app.include_router(plycdn_router(
    storage=storage, signer=AsyncPlycdnLinkSigner(storage), video=AsyncPlycdnVideoApi(storage),
    resolve_organization=resolve_organization, authorize=authorize,
    playback_watermark_for=playback_watermark_for,
), prefix="/api/plycdn/v1")

The hook may also return a per-viewer look (opacity, size, colour, movement, placement) that wins over the library's.

  • Return the same details for a viewer every time. On a library that requires the watermark, the player renews its session every few minutes and your hook is asked again each time; plycdn checks that the details match the ones the session started with. A hook that returns something different mid-video (a renamed user, a value worked out afresh, a time) stops that viewer's playback with watermark_proof_invalid.

  • A detail you don't pass is left out, never shown as empty. A visitor you can't name (return null or None) still gets the watermark with the date and time and your fixed text.

  • IP address appears only when your server passes the viewer's address with the session. The packages' sign route passes it for libraries that bind links to the viewer's address; server code that starts sessions itself can pass ip on any library that shows it.

  • Date and time is the viewer's own local time, drawn by the player. It is not checked against plycdn's clock.

  • Long or unusual details are drawn as plain text, never as markup: right-to-left names read correctly, and each line is cut at 80 characters with "…".

6Play the video

Nothing changes in your frontend. plycdn's players draw the watermark and the logo by themselves: they need no prop and no code.

React

import { PlycdnProvider, PlycdnPlayer } from '@plycdn/react/video';

<PlycdnProvider config={{ baseUrl: '/api/plycdn/v1' }}>
  <PlycdnPlayer libraryId={libraryId} videoId={videoId} />
</PlycdnProvider>

Angular

<plycdn-player [libraryId]="libraryId" [videoId]="videoId"></plycdn-player>

Live streams and share links show the logo and a watermark of your fixed text and the date and time, when the watermark shows them (live viewers are not named), and do not require it: a share link is a public link you chose to give out. See Live streaming and Your watch page.

With a player of your own

The server packages read what to draw from the playback session: PlycdnWatermark.Render(session) in ASP.NET Core, PlycdnWatermark.render(session) in Node.js and PlycdnWatermark.render(session) in Python. It is null/None when the library shows no watermark, and otherwise carries the lines (plain text: draw them as text, never as markup), timeAt (where the viewer's local date and time goes in the lines), the look, moveEverySeconds and placement. logo on the session is the logo to draw in its corner.

ASP.NET Core

var request = new PlaybackSessionRequest(libraryId, videoId)
{
    ResumeId = learner.Id,
    Watermark = new PlycdnWatermarkViewer { Name = learner.Name, Email = learner.Email },
};
PlaybackSession session = await videos.StartPlaybackAsync(orgId, request);
PlycdnWatermarkRender? mark = PlycdnWatermark.Render(session);       // what to draw; null: nothing
// …before session.RenewBy, with the same viewer details:
PlaybackSession next = await videos.StartPlaybackAsync(orgId, PlycdnWatermark.Renewal(request, session));

Node.js

const request = { bucketId: libraryId, videoId, resumeId: learner.id, watermark: { name: learner.name, email: learner.email } };
const session = await video.startPlayback(orgId, request);
const mark = PlycdnWatermark.render(session);                          // null: nothing to draw
const next = await video.startPlayback(orgId, PlycdnWatermark.renewal(request, session));

Python

request = {"bucketId": library_id, "videoId": video_id, "resumeId": learner.id,
           "watermark": {"name": learner.name, "email": learner.email}}
session = video.start_playback(org_id, request)
mark = PlycdnWatermark.render(session)                                 # None: nothing to draw
next_session = video.start_playback(org_id, PlycdnWatermark.renewal(request, session))

In the browser, PlayerMark (from @plycdn/react/video and @plycdn/angular) draws and defends the marks over any element: new PlayerMark({ container, onTamper }), then update(watermark, logo) with the session's watermark and logo, and destroy() when the video goes. PlycdnPlayerCore, the engine of plycdn's players, does everything below for you when you give it onMark and call its noteTamper() from onTamper.

On a library that requires the watermark, a player of your own must also:

  1. Say it draws the watermark. A browser asking your backend's sign route sends "watermark": "drawn" (React: client.sign({ bucketId, videoId, watermark: 'drawn' }); Angular: the fifth argument of PlycdnPlaybackService.link). Without it the route answers 409 watermark_required.
  2. Check in. Every watermark.proof.everySeconds (60) post to progressUrl, as text/plain: {"pass": "<pass>", "position": <seconds>, "played": <seconds>, "watermark": {"state": "present", "proof": "<watermark.proof.key>"}}, with "state": "tampered" once the watermark was removed or hidden.
  3. Renew before renewBy with continues (the session's sessionId) and watermarkProof (its watermark.proof.key), the same viewer details and the same resumeId (or none, if the session had none). Each renewal is a new session with a new proof; switch to its url, pass and proof.
  4. Stop after a refusal. After watermark_proof_invalid or watermark_tampered, stop playing and never start a fresh session instead. Only after playback_session_expired start a new session, without continues. One exception: when a renewal got no answer (a timeout or a network error) and its retry is refused with watermark_proof_invalid, the first renewal went through and used the proof; start one fresh session, without continues, as plycdn's players do.

7What Require changes, and its limits

  • Sessions only. Videos of the library play through playback sessions only, like serial playback. Links last at most 10 minutes (600 seconds) and are renewed by the player at 80 % of their life. nativeUrl and downloadUrl are null. Signing a plain link (POST /sign with a videoId) answers 409 watermark_required, and the server packages' own signers (PlycdnVideoSigner) refuse the same way: let the sign route start a session, or call StartPlaybackAsync, startPlayback or start_playback with watermark.
  • Your server packages. If your server signs video links itself, Require needs the Storage & video server packages 1.12.0 or later (the ones that know serial playback). Packages before 1.12.0 sign plain links and ignore Require, so its videos would play without the watermark there. The sign route and the players need the current versions.
  • Switching it on. Links already handed out keep playing until they expire; from then on, playback needs a session. Switching Require on or off (not Show) sends a signing_profile.changed webhook with reason watermark_changed, so servers that receive it stop or resume signing that library's videos themselves at once (Keep your signers current). Without the webhook, a server that kept the old settings refuses older players for up to an hour after Require is switched off; the first refusal makes it check.
  • Which devices. Our current players play Required videos in Chrome, Edge, Firefox and Safari on a computer, on Android, and on iPhone and iPad from iOS 17.1. An older iPhone or iPad can't play them. On an iPhone, full screen is not available for a video that shows the watermark: the iPhone's own full-screen player would hide it.
  • While the watermark is drawn picture-in-picture and casting to a TV are off, on every library that shows it.
  • Tampering. The players put the watermark back at once if it is removed, hidden, shrunk or moved off the video. Three times within a minute pauses playback ("Playback paused: the watermark was hidden or changed"), and the next check-ins tell plycdn. After two such check-ins, plycdn refuses to renew (watermark_tampered), so playback stops within the life of the current link. Something of your own page covering the watermark (a sticky header, a cookie notice) only moves it and is never counted.
  • The honest limit. The watermark stops ordinary screen recording, phone filming and casual tampering. A determined developer can still build a player of their own that plays without drawing it, and downloads being off does not stop a determined viewer from saving the stream itself while a session lasts. plycdn does not offer its own DRM today; for live streams you can use protection with a key service of your own (Content protection).
  • Viewer details ride only in the signed session given to the player and are checked by a fingerprint when the player renews. They are never stored, never written to a log, analytics or the audit log.

8Check it works

  1. In Account → Branding → Watermark, choose Show, keep Name, Email and Date and time, type your organization's name as the fixed text, and save.
  2. Sign in to your application as a test viewer and play a video: their name, email and the time move over the video, and your logo sits in its corner.
  3. In the browser's developer tools, delete the watermark's element: it comes back at once.
  4. Switch the library to Require (its Protection tab, or the organization's Watermark tab). Play again: it still plays. Ask for a plain link to the same video (POST /sign with its videoId): the answer is now 409 watermark_required.
  5. Watch for more than 10 minutes: the player renews by itself and playback carries on.

9Questions and problems

409 watermark_required. The library requires the watermark and the request didn't come from a player that draws it: an older package, a plain link, your own signing call without watermark, or a player that didn't send watermark: "drawn". Update the server and browser packages to Storage & video 1.26.0 or later, and start sessions with watermark (send an empty one when you have no details).

403 watermark_proof_invalid. A renewal carried a proof that doesn't belong to the session, another viewer's details or resumeId, or a session that was already renewed (a renewal can't be sent twice). The player stops; the viewer reloads the page. If your hook's details for a viewer change during a video, return the same ones every time (see Name the viewer on your server). A renewal whose answer was lost on the way is not a refusal for the viewer: plycdn's players start one fresh session and carry on.

403 watermark_tampered. The player reported the watermark removed or hidden. The count carries over renewals and a fresh session of the same resumeId for an hour. Fix whatever on the page interferes with the player, then reload.

410 playback_session_expired. A renewal came more than two minutes after the session expired (a laptop that slept, for example). The player starts a new session by itself.

The watermark shows only the time and the fixed text. Your hook returned nothing for that viewer, or isn't registered. Check the viewer is signed in when the player asks for its link.

There is no IP address line. The packages' sign route sends the viewer's address only for libraries that bind links to it. Pass ip yourself when you start sessions from your server.

422 validation_error when saving. A watermark that is shown must show something: choose at least one detail or a fixed text. Opacity is 5 to 60 %, movement 10 to 300 seconds, the fixed text up to 60 characters with no line breaks.

The logo doesn't show on my site, but it shows in the Branding preview. Your site sends a content security policy that doesn't allow images from https://watch.plycdn.com, where the logo comes from. Add it to the img-src line: img-src https://*.plycdn.net https://watch.plycdn.com. Also check Show your logo on the player is on and the library's Protection tab doesn't say No logo.

422 logo_invalid or 413 logo_too_large. The logo must be a PNG, JPEG or WebP image (not SVG or GIF) up to 512 KB.

403 organization_out_of_key_reach. Sub-organizations inherit your branding, so changing it needs a key that reaches the whole account, or the dashboard.

Does the watermark change the video files or cost anything? No. It is drawn by the player; nothing is encoded or stored again, and it is included in every plan.