This document provides a comprehensive reference for the WINK Streaming Command API, covering WINK Forge, WINK Archive, and WINK Media Router appliances. The API enables programmatic control of video streaming operations, user management, and system administration.
The WINK API uses XML-based messaging over HTTPS, providing:
All API calls require authentication using the following XML structure:
<wink_api user='username' pass='password' key='shared_key'>
<req id='unique_id' command='command_name'>payload</req>
</wink_api>
| Parameter | Required | Description |
|---|---|---|
| user | Yes | API username (must have API access level) |
| pass | Yes | User password |
| key | Optional | Shared key for enhanced security |
All API endpoints require SSL/HTTPS connections. Configure SSL certificates through:
--insecure lets cURL skip validation of a
self-signed certificate. Understand the trade before using it: credentials travel in the request
body, so an unverified connection exposes them to anyone in the path. Prefer pointing cURL at the
appliance CA with --cacert /path/to/appliance-ca.pem, and never use --insecure
against a production system or across an untrusted link.curl -X POST https://10.130.90.188/api/ \
--cacert /path/to/appliance-ca.pem \
--data-urlencode "<wink_api user='apiuser' pass='apipass'>
<req id='1' command='version'></req>
</wink_api>" \
-H 'Content-Type: application/xml'
WINK Forge includes a built-in API testing interface:
Access via: Tools → API Tester
Built-in API testing interface
| Command | version |
|---|---|
| Description | Returns the API version number |
| Payload | None |
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='version'></req>
</wink_api>
|
| Response |
<wink_api>
<resp id="1" command="version" status="ok">1.4</resp>
</wink_api>
|
| Return Value | Numeric API version (e.g., "1.4") |
| Command | start | stop | restart |
|---|---|
| Description | Controls transcoding operations for input streams |
| Payload | GUID of the input stream or 'all' for all streams |
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='restart'>WF05-2657-1C04-7F1B-5EC0</req>
</wink_api>
|
| Response |
<wink_api>
<resp id="1" command="restart" status="ok">WF05-2657-1C04-7F1B-5EC0</resp>
</wink_api>
|
| Return Value | GUID of affected input or error message |
| Command | startip | stopip |
|---|---|
| Description | Controls transcoding based on IP:Port combination |
| Payload | IP address and port (format: IP:PORT) |
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='stopip'>10.130.90.68:554</req>
</wink_api>
|
| Response |
<wink_api>
<resp id="1" command="stopip" status="ok">WF05-2657-1C04-7F1B-5EC0</resp>
</wink_api>
|
| Return Value | GUID of the affected input |
| Command | getguid |
|---|---|
| Description | Returns GUID for a specific IP:Port combination |
| Payload | IP and Port (e.g., 10.130.90.199:554) |
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='getguid'>10.130.90.199:8382</req>
</wink_api>
|
| Response |
<wink_api>
<resp id="1" command="getguid" status="ok">WF05-2657-1C04-7F1B-5EC0</resp>
</wink_api>
|
| Command | guidstatus |
|---|---|
| Description | Returns the current status of a stream |
| Payload | GUID string |
| Response Values |
|
| Command | blockguid |
|---|---|
| Description | Blocks video output from viewers |
| Payload Options |
|
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='blockguid'>WF05-2657-1C04-7F1B-5EC0</req>
</wink_api>
|
| Command | unblockguid |
|---|---|
| Description | Restores video output to viewers |
| Payload | GUID string |
| Command | blockip |
|---|---|
| Description | Blocks video based on IP:Port without knowing GUID |
| Payload | IP:Port combination (e.g., 10.130.90.199:554) |
| Response | Returns the GUID of the blocked stream |
| Command | unblockip |
|---|---|
| Description | Restores video based on IP:Port |
| Payload | IP:Port combination |
| Command | input_source | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Description | Creates or updates video input configuration | ||||||||||||||||||||||||||||||
| Required (New) |
|
||||||||||||||||||||||||||||||
| Required (Update) | guid - Existing stream GUID | ||||||||||||||||||||||||||||||
| Optional Parameters |
|
||||||||||||||||||||||||||||||
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='input_source'>
<input title='Test Camera'
desc='Testing stream'
type='RTSP'
path='rtsp://192.168.1.100:554/stream1'
fps='30'
width='1920'
height='1080'
enabled='1' />
</req>
</wink_api>
|
| Command | input_delete |
|---|---|
| Description | Removes video input and associated outputs |
| Payload | GUID string |
| Command | createuser |
|---|---|
| Description | Creates a local user account |
| Parameters |
|
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='createuser'>
<name>Billy Armstrong</name>
<username>billy</username>
<password>SecurePass123!</password>
<access>2</access>
</req>
</wink_api>
|
| Command | deleteuser |
|---|---|
| Description | Removes a user account |
| Payload | XML with username |
| Command | changepassword |
|---|---|
| Description | Updates user password |
| Parameters |
|
| Command | log |
|---|---|
| Description | Adds entry to system event log |
| Payload | Log message string |
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='log'>API integration test successful</req>
</wink_api>
|
| Command | flushlogs |
|---|---|
| Description | Clears the event log |
| Payload | None |
| Command | reboot |
|---|---|
| Description | Initiates system reboot |
| Payload | None |
| Response | Error message or immediate reboot |
Firmware Requirements: Version 1.1 or greater
TITLE_[YYYY-MM-DD_HH.MM.SS]_GUID.mp4EastSideSony_[2015-04-03_13.06.18]_WMA5-6E0F-1C04-86F2-E5C4.mp4
Same as WINK Forge version command - returns API version number.
| Commands | archiver_start | archiver_stop |
|---|---|
| Description | Controls video archiving operations |
| Payload | GUID or blank (blank = all archivers) |
| Behavior | If already recording, creates overlapping instance to ensure no content loss |
| Example |
<wink_api user='apiuser' pass='apipass'>
<req id='1' command='archiver_start'>WMA5-6E0F-1C04-86F2-E5C4</req>
</wink_api>
|
| Command | archiver_status |
|---|---|
| Description | Returns archiver configuration and status |
| Payload | GUID or blank (blank = all archivers) |
| Response |
<wink_api>
<resp id="11" command="archiver_status" status="ok">
<archiver guid="WMA5-6E0F-1C04-86F2-E5C4"
title="TV2"
desc="Conference Room Feed"
type="RTMP"
path="rtmp://10.130.90.10/public/stream_high"
segmenttime="5"
meta1="Building A"
meta2="Floor 2"
meta3="Room 201"
meta4="PTZ Camera"
enabled="1" />
</resp>
</wink_api>
|
| Command | archiver_source |
|---|---|
| Description | Create or modify archiver configuration |
| Parameters |
|
| Command | archiver_delete |
|---|---|
| Description | Stop and remove archiver |
| Payload | GUID |
OTP tokens provide short-lived, scoped access to a video stream without ever putting a permanent credential in front of a viewer. A token is minted by your server using your API credentials, handed to the browser as part of a finished stream URL, and it expires on its own. Used correctly, a leaked stream URL is worth little: it is narrow in scope and short-lived.
Live video is not a normal API resource. It is fetched by a player rather than by your application code, over a long-lived connection, across many segment requests, often through a CDN, and the URL is trivially copied out of any page. That combination defeats nearly every access control integrators reach for first:
A short-lived, per-session, scoped token carried in the stream URL is the one mechanism that survives all of it: every player supports it because it is just a URL, it expires on its own, it can be revoked for one viewer without touching the others, and it leaves an audit trail per session. That is why the OTP flow is the supported way to secure live video on the WINK Media Router — not one option among several.
The rest of this section is about using it correctly. The token API is deliberately simple, and the simplest thing an integrator can build with it — minting tokens straight from the browser — is also the one thing that must never ship. Read the callout below before you write any code.
Your API credentials (apiuser / apipass) authenticate token creation.
Anyone who can reach them can mint valid tokens at will. Therefore:
Referer or Origin header is not access control.
Both are trivially set by any server-side caller. Require a real session, not a header.extend.This is not a style preference. An integration that skips it can be replayed by any third party to pull your feeds directly from the WINK origin, in bulk, until your token limit is exhausted and legitimate viewers are denied service. See Anti-patterns for real cases.
duration and renew with
extend (see below). All tokens can be cleared via the Web Administration OTP section.
The rule above has one mechanism behind it. The credential that mints tokens is the asset being protected; everything else follows from keeping it on your server:
apiuser / apipass that never leave the server,
calls the WINK Media Router and creates a token scoped to that viewer (and, where possible, that
stream), with a short duration.otp and expires — to the browser.extend or mints a fresh token.At no point does anything the browser holds let it — or a copycat replaying the same request — mint a new token. A stolen stream URL is scoped and expires; a stolen credential would not exist to steal.
The difference between a secure and an exploitable integration is entirely in where the credential lives and what the browser is allowed to ask for. The diagrams below contrast the two.
Insecure: anything the browser holds can be replayed by anyone.
Secure: the credential never leaves the server; the browser only ever receives a finished, expiring URL.
Each item below is a real failure mode, with the consequence. The insecure build "works" perfectly in a demo, which is exactly why it ships — the damage only shows up once someone replays it at scale.
apiuser/apipass (or any equivalent bearer/credential) to the browser. Anyone can read it and mint tokens at will.Referer, Origin, or User-Agent as access control. All are attacker-controlled.extend.None of the following are hypothetical, and none of them were clever attacks. They are the ordinary ways OTP integrations get built when the pattern is treated as a suggestion. Each one passed its own demo, which is precisely why it reached production.
A production deployment went live still using the factory username and password. The documentation that told the integrator how to call the API was the same documentation that told everyone else what the defaults were.
Outcome: the system was open to anyone who had read the manual. No replay, no interception, no spoofing — just the published default, still in place.
An integrator wrote the token logic in client-side JavaScript and pasted
apiuser and apipass straight into it. Anyone who pressed
View Source had the account.
Outcome: the credential was public from the moment the page went live. Not leaked — published.
Another built the whole mint-and-play flow in the browser. Even where the credential was not literally printed in the source, the browser still had to hold something that could mint, and whatever the browser holds, the viewer holds.
Outcome: every visitor became a token factory. Per-session revocation, audit and quotas all became meaningless, because every token looked the same.
Referer header was the only lock on the doorA public endpoint minted a token for any caller that arrived with the right
Referer. The header is set by the client, so it is not a check — it is a
formality. Two lines of server-side code reproduce it exactly.
Outcome: the endpoint was open to anyone who read the page once and noticed which header it wanted.
A customer minted a single token at the longest TTL available and shared it with the entire user base, reasoning that one token was simpler than many.
Outcome: a master key with a long life and no way to revoke one user without cutting off all of them. The first time it escaped the intended audience, there was no way to tell who had it or to take it back.
Over-correcting in the other direction, one integrator minted two-second tokens and re-minted on a two-second timer for continuous live video. Short TTLs are good; a TTL shorter than the thing it protects is not. The renewal raced the expiry on every cycle.
Outcome: visible stream stalls whenever a refresh lost the race, and a
self-inflicted flood against the token endpoint that looked indistinguishable from abuse.
Use a sensible TTL and extend; do not rebuild the session on a stopwatch.
An integrator embedded a shared minting credential in its public web page and exposed a
token endpoint gated only by a Referer header. A third-party site replayed that
request from its own backend, minted tokens in bulk, and streamed the feeds directly from the
WINK origin. A single token in that deployment also unlocked every camera, so one
replayed request was enough to pull the whole set.
Outcome: the integrator's token limit was exhausted and legitimate viewers were intermittently denied service. Every "Do NOT" above was in play simultaneously.
Nothing here required breaking anything. The credential was public, the header was spoofable, the token was broad and long-lived, and there was no independent rate limiting. In each case the secure pattern would have made the abuse impossible rather than merely harder — which is the entire point of implementing it exactly as written, rather than approximately.
Every case above is a deployment where most of the flow was right and one piece was taken as optional. The OTP design only holds if all of it holds; each rule below closes a door that the others leave open, so a near-miss implementation is an insecure one.
extend before expiry, and do not re-mint on a tight timer. Read the
expiry back with query rather than computing it.This comes up on nearly every integration, so it is worth stating plainly: CORS is not an access control, and it never was. It is a browser convention. Treating it as a security boundary is one of the most common reasons an integration that "felt safe" turns out not to be.
Cross-Origin Resource Sharing exists to protect the user from a page they did not trust, by stopping that page from silently reading responses from another origin where the user is already logged in. It runs inside the browser, at the browser's discretion, and it decides what a page may read. It says nothing whatsoever about who may call your endpoint.
Your server receives the request and does the work regardless. CORS only governs whether the browser hands the response back to the calling script afterwards. By then the request has already been served.
curl, a server-side script, a mobile app, a scraper, or any HTTP library will
not read your Access-Control-Allow-Origin header, let alone honour it. There is no
enforcement outside the browser, and an attacker is not obliged to use one.
Chrome and the other major browsers can be launched with the same-origin policy disabled by a command-line flag, and extensions exist whose whole purpose is stripping CORS headers. A user who wants past the check is a browser restart away from it.
Any request can be relayed through a trivial server-side proxy that forwards it, receives the response, and returns it with whatever headers the attacker likes. This is the standard workaround and takes minutes to build.
Integrators have worked around their own CORS restrictions with in-page techniques rather than fixing the design. If a developer can defeat the check to make their own product work, so can someone with less friendly intentions.
Put together: a permissive CORS policy does not create a vulnerability, and a restrictive one does not remove one. The header is simply not part of the security model.
Configure CORS correctly anyway — it is good hygiene and it prevents other people's pages from misusing a logged-in session. Just do not count it as protection.
Origin, a Referer, a User-Agent,
or a header your own JavaScript adds — there is no control in place. Every one of those values
is chosen by whoever makes the request.
This is the whole supported pattern. If you follow it you can ignore most of the endpoint reference below — it is there for people maintaining an existing integration, not for assembling a new one freehand.
apiuser and apipass. They come
from configuration or a secret store. They never reach a browser, a mobile bundle, or a
repository.action=create,
scoped as narrowly as the deployment allows. Post to /otp/api/ — trailing
slash included.extend on a timer comfortably inside that lifetime,
and call remove when the session ends. Treat a playback failure as a signal to
re-mint rather than something to debug.Working implementations of exactly this are in OTP Code Examples — PHP for the backend, JavaScript for the player, C# for ASP.NET.
duration is in seconds, not minutesEvery earlier edition of this manual, and the Media Router manual alongside it, described
duration as minutes. That one word was wrong. The numbers around it
were right:
| Field | Value | Meaning |
|---|---|---|
| Unit | Seconds | Not minutes |
| Range | 1 – 900 | Maximum lifetime is 15 minutes |
| Default | 600 | Ten minutes if you send no duration |
This matches what was observed on build 1.6.3: a
duration=1 token dies in about a second, and a duration=2 token was gone
well before the first thirty-second check. Read as minutes, those same calls would have lasted one
and two minutes.
Any integration written against the old wording is passing a number
sixty times smaller than intended. A duration=15 meant as fifteen
minutes is a fifteen-second token, and playback dies almost immediately. If viewers report streams
dropping seconds after they start, this is why. The same arithmetic runs the other way on the
maximum: duration=900 is fifteen minutes, not fifteen hours.
There is also no query action, so nothing in the API reports a token's expiry back to
you. Track it yourself from the moment you mint:
300 — five minutes.extend at roughly a third of the
lifetime, so a slow request or a sleeping laptop cannot land you past expiry. The examples refresh
a 300-second token every 100 seconds.The behaviour in this section was confirmed by direct request against production and test
routers on 16 September 2026. Earlier editions of this manual documented a second endpoint path,
a query action and a destroy verb that do not exist in the
deployed build. If you wrote an integration against an older edition, check it against
the table below.
Ask the API its own version before you trust any document:
action=version returns WINK Streaming OTP-API Version: 1.6.3.
Every action — create, extend, remove, version — posts to the same path. There is no separate management endpoint.
| Path | Status |
|---|---|
http://router.example.com/otp/api/ | Canonical. Use this. |
http://router.example.com/api/otp/ | Also works. Same handler. |
http://router.example.com/api/v1/otp/ | Does not exist — returns 404. Documented in error by earlier editions. |
http://router.example.com/otp/api | Fails. The trailing slash is required. |
All requests are form-encoded HTTP POST and carry apiuser and apipass
on every call.
| Action | Status | Returns |
|---|---|---|
create | Works | The token on its own, e.g. 836352163502 |
extend | Works | <token>:Token Extended |
remove | Works — this is the verb that revokes a token | success, or Token Not Removed on failure |
version | Works | WINK Streaming OTP-API Version: 1.6.3 |
destroy |
Not a valid verb. Earlier editions documented it | Token Not Removed - SQL Error. Use remove |
query |
Not implemented in 1.6.3. Earlier editions claimed it returned a token's expiry | 0:No Valid Command Received |
Because query does not exist, the router will not tell you
a token's expiry. Your application has to track it. That makes the token lifetime something you
must establish for your own deployment and then hold to — see
Token lifetime below, and keep your refresh interval comfortably
inside it.
These come from reading the deployed otp_api.php, not from testing. They are easy
to trip over and produce silent failures rather than errors.
Every posted value passes through a cleaner that deletes these characters
outright: ; ' / $ @
? &
Outcome: an API password containing any of them is silently altered before
comparison, and the call returns 0:Invalid Login with nothing to indicate why.
Choose OTP API credentials from letters, digits and other punctuation only.
The handler reads expiry_messsage — three s's. A
correctly spelled expiry_message is simply never seen.
Outcome: your custom expiry text is ignored and the player falls back to a bare failure. Send the misspelling if you need the feature to work today, and expect it to change when the typo is fixed.
A non-POST request answers 0:No POST data Received - Commands must be sent using
POST. — still with a 200 status.
Outcome: a client that checks the status code and not the body will read a
failure as a success. Always parse the body; a leading 0: means failure.
The handler also accepts user and pwd as
aliases for apiuser and apipass, and prefers them when both are present.
Use apiuser / apipass in new work.
Errors come back as plain text, not HTTP status codes — a rejected call still returns 200. Check the body.
The handler sets Content-Type: text/xml on every response, but the body is
never XML — it is a bare string terminated with CRLF. A client that picks a parser from
the content type will choke on a perfectly good answer. Read the body as text.
| Response | Meaning |
|---|---|
0:Invalid Login | apiuser or apipass wrong |
0:No Valid Command Received | Credentials accepted, action not recognised |
0:No POST data Received - Commands must be sent using POST. | Request was not a POST |
Those two make a useful probe: a valid login on any command returns a result, an invalid login
returns 0:Invalid Login, and an unknown command on a valid login returns
0:No Valid Command Received. That is how the table above was verified.
The token is appended to the playlist request as an otp query parameter. That is all that is required:
http://router.example.com/200/public/hls/<stream>.m3u8?otp=836352163502
On expires: older examples in this manual appended an
expires timestamp as well. Only otp was verified against build 1.6.3, and with
no query action there is no authoritative expiry to put in such a parameter. Send
otp alone unless your deployment has told you otherwise.
Only the .m3u8 playlist request carries the token; the segment (.ts) requests
that follow are authorized off the same playlist request, so you do not rewrite individual segment URLs.
Tokens can be cluster-wide across a deployment: a token created on one Media Router is valid on every Media Router where the API credentials are defined identically, so a five-router deployment can operate on one token per viewer session rather than one per router.
| Command | create | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Method | HTTP POST | ||||||||||||||||||||||||
| Endpoint | /api/otp/ |
||||||||||||||||||||||||
| Parameters |
This is the complete list the handler reads. Anything else you post is discarded without comment.
|
||||||||||||||||||||||||
| Example |
curl -d "apiuser=apiuser&apipass=apipass&action=create&duration=300" \
-X POST http://router.example.com/otp/api/
|
||||||||||||||||||||||||
| Response | 24814928371014572819 | ||||||||||||||||||||||||
| Notes | The create call returns the raw token string on its own, with no wrapper or formatting. Errors are returned as 0: followed by a description (for example 0:Invalid Login). |
||||||||||||||||||||||||
| Commands | extend | remove |
|---|---|
| Description | Renews a token's life, or revokes it. Both post to the same path as
create. |
| URL | http://router.example.com/otp/api/ |
| Parameters |
|
| Example |
curl -d "apiuser=apiuser&apipass=apipass&action=extend&token=836352163502&duration=300" \
-X POST http://router.example.com/otp/api/
curl -d "apiuser=apiuser&apipass=apipass&action=remove&token=836352163502" \
-X POST http://router.example.com/otp/api/
|
| Response |
|
| Note | destroy is not the verb. It returns
Token Not Removed - SQL Error and leaves the token live. Use
remove. |
| Command | version |
|---|---|
| Description | Returns the OTP API build. Worth calling before you trust any documentation, including this one. |
| Example | curl -d "apiuser=apiuser&apipass=apipass&action=version" \
-X POST http://router.example.com/otp/api/ |
| Response | WINK Streaming OTP-API Version: 1.6.3 |
query actionEarlier editions documented action=query as returning
a token's expiry as YYYY-MM-DD HH:MM:SS. It is not implemented in 1.6.3 — it
returns 0:No Valid Command Received. Any code written against that description
will fail. Track expiry in your own application instead.
The questions below come up regularly during web portal and partner integrations.
As an otp query parameter on the playlist request, alongside an expires timestamp:
http://router.example.com/200/public/hls/<stream>.m3u8?otp=836352163502
Only the .m3u8 request needs it. Segment requests inherit the authenticated session, so individual .ts URLs do not need to be rewritten.
No. Issue a token per viewer session. Sharing a single token across every visitor removes the ability to expire, extend, or revoke access for one viewer, and it makes the access log useless for audit purposes.
One per viewer session is enough: tokens can be cluster-wide, so a session's token spans every Media Router where the API credentials are defined identically. Note the trade-off — a cluster-wide token that leaks unlocks the whole fleet, so keep durations short and scope more narrowly where your threat model calls for it.
One path handles everything. create, extend,
remove and version all post to the same endpoint:
http://router.example.com/otp/api/ — canonical, use thishttp://router.example.com/api/otp/ — also works, same handlerThe trailing slash is required. /api/v1/otp/ appeared in earlier editions of this
manual and does not exist — it returns 404. Create parameters are
action=create and duration, plus apiuser and
apipass. The call returns the token on its own — a 12-digit value such as
836352163502 on the builds tested. There is no query action in 1.6.3, so
nothing reports a token's expiry back to you.
duration is in seconds, 1 to 900, default 600 — ten minutes if you send nothing, fifteen minutes at most. Earlier editions of this manual said minutes, which is why some integrations are quietly passing a value sixty times too small. There is also no query action, so nothing reports a token expiry back to you: record the mint time and track it yourself. A sensible starting point is a 300-second token renewed with extend every 100 seconds. See Token lifetime.
On expiry_url versus expiry_message: for a public website, neither should be the primary mechanism. Have the page request a fresh token when one lapses; that is a cleaner experience than a redirect mid-stream. Setting expiry_message is worth doing as a fallback, so anything that does slip through shows a sensible message in the player instead of a bare failure.
There are per-account and per-session quotas on token creation, and they exist to protect the platform — not to protect your integration. Do not treat WINK's limits as your abuse defense. Unbounded minting is both an abuse vector and an availability risk: a caller hammering an open token endpoint can exhaust your account's token cap and deny service to your legitimate viewers.
Put your own rate limiting and bot protection in front of your token endpoint (per-session and per-IP quotas, plus a challenge such as Cloudflare Turnstile or a WAF rule), and alert on abnormal issuance volume from a single session or source as an early abuse signal. Contact support if a legitimate deployment is expected to need a higher quota.
Yes. Stream URLs are unchanged by the upgrade; the paths documented for a deployment stay the same.
Confirm every item below before an OTP integration goes live in front of the public. If any answer is "no," the integration is exploitable in the way described in Anti-patterns.
apiuser/apipass) live on the server only, and are never delivered to or reachable from the browser.Referer/Origin header.extend rather than issued long up front.The cURL calls above show the raw requests. The examples below show the same calls in context, issuing and maintaining one token per viewer session. In every example the credentials stay on the server and only a finished, short-lived URL reaches the browser — copy that shape, not just the API calls.
apiuser and apipass appear only in the
server-side code below (the PHP and C# examples). They must never be exposed to a browser. The
JavaScript example deliberately holds no credential — it asks its own backend for a token and
receives only the finished URL. If you find yourself putting apiuser/apipass
into client-side JavaScript, stop: that is the exploited anti-pattern.
Creates a token on the visitor's first request, reuses it for the rest of the session, and extends it when it is close to lapsing:
<?php
session_start();
// Mint only for a caller you have already authenticated. Without this check the page
// is a public minting endpoint - the anti-pattern in "Real cases" above.
if (empty($_SESSION['user_id'])) {
http_response_code(401);
exit('Not authenticated');
}
// One endpoint for every action. The trailing slash is required.
$otp_api = 'https://router.example.com/otp/api/';
$api_user = 'apiuser';
$api_pass = 'apipass';
$playlist = 'https://router.example.com/200/public/hls/WF05-333F-4D5C-E636-2937.m3u8';
// Token lifetime in SECONDS - 1 to 900, default 600 if omitted.
$duration = 300;
// Renew at a third of the lifetime, so a slow request cannot land past expiry.
// There is no query action, so the clock is entirely ours to keep.
$refresh_at = (int) ($duration / 3);
function otp_post($url, $fields) {
$options = array(
'http' => array(
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($fields),
'timeout' => 10,
),
);
$result = @file_get_contents($url, false, stream_context_create($options));
return ($result === false) ? '' : trim($result);
}
// Still watching and the token is getting on - extend rather than issue a second one.
if (!empty($_SESSION['otp_token']) && (time() - $_SESSION['otp_minted']) > $refresh_at) {
$resp = otp_post($otp_api, array(
'apiuser' => $api_user,
'apipass' => $api_pass,
'action' => 'extend',
'token' => $_SESSION['otp_token'],
'duration' => $duration
));
// extend answers "<token>:Token Extended"
if (strpos($resp, ':Token Extended') !== false) {
$_SESSION['otp_minted'] = time();
} else {
unset($_SESSION['otp_token']); // extend failed - fall through and create
}
}
// No token yet (or the extend failed) - create one for this session
if (empty($_SESSION['otp_token'])) {
$token = otp_post($otp_api, array(
'apiuser' => $api_user,
'apipass' => $api_pass,
'action' => 'create',
'duration' => $duration
));
// Errors are plain text on a 200: "0:Invalid Login", "0:No Valid Command Received"
if ($token === '' || substr($token, 0, 2) === '0:') {
die('OTP API error: ' . htmlspecialchars($token));
}
$_SESSION['otp_token'] = $token;
$_SESSION['otp_minted'] = time();
}
// Only the .m3u8 carries the token - segment requests inherit the session
$video_url = $playlist . '?otp=' . urlencode($_SESSION['otp_token']);
// Call this when the user logs out or closes the session:
// otp_post($otp_api, array('apiuser' => $api_user, 'apipass' => $api_pass,
// 'action' => 'remove', 'token' => $_SESSION['otp_token']));
//
// Serving what the JavaScript example needs:
// header('Content-Type: application/json');
// echo json_encode(array('token' => $_SESSION['otp_token'],
// 'refresh' => $_SESSION['otp_minted'] + $refresh_at));
?>
The page asks its own backend for the session's token, builds the playlist URL, and requests a fresh token shortly before the current one lapses:
// hls.js instance, created once by your player setup. Declared here so the example
// is complete; swap in whatever player you use.
let hls = null;
let session = null; // { token: "a1b2c3d4e5f6", expires: 1691234567 }
let refreshTimer = null;
async function getSessionToken() {
const res = await fetch('/otp-token.php', { credentials: 'same-origin' });
if (!res.ok) {
throw new Error('Token request failed: ' + res.status);
}
return res.json();
}
function playlistUrl(playlist) {
return playlist
+ '?otp=' + encodeURIComponent(session.token)
+ '&expires=' + session.expires;
}
function loadIntoPlayer(playlist) {
const video = document.getElementById('player');
const url = playlistUrl(playlist);
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url; // Safari and other native HLS players
} else {
hls.loadSource(url); // hls.js or the player of your choice
}
}
async function startStream(playlist) {
session = await getSessionToken();
loadIntoPlayer(playlist);
scheduleRefresh(playlist);
}
// Refresh before expiry rather than letting playback fail. This is cleaner than
// redirecting mid-stream with expiry_url; keep expiry_message set as a fallback.
// Renew with headroom - do not re-mint on a tight timer (see "Real cases").
function scheduleRefresh(playlist) {
const msRemaining = (session.expires * 1000) - Date.now() - 60000; // 60s headroom
clearTimeout(refreshTimer);
refreshTimer = setTimeout(async function () {
try {
session = await getSessionToken(); // backend extends, or creates a new token
loadIntoPlayer(playlist);
scheduleRefresh(playlist);
} catch (err) {
// Do not let the chain die silently - back off and try again.
console.error('Token refresh failed, retrying:', err);
refreshTimer = setTimeout(function () { scheduleRefresh(playlist); }, 10000);
}
}, Math.max(msRemaining, 5000));
}
startStream('https://router.example.com/200/public/hls/WF05-333F-4D5C-E636-2937.m3u8')
.catch(function (err) { console.error('Could not start stream:', err); });
A small client wrapping the three actions, suitable for an ASP.NET application holding one token per user session:
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Threading.Tasks;
public class WinkOtpClient
{
private static readonly HttpClient Http = new HttpClient();
private readonly string _apiUrl; // https://router.example.com/otp/api/ (trailing slash required)
private readonly string _user;
private readonly string _pass;
public WinkOtpClient(string apiUrl, string user, string pass)
{
_apiUrl = apiUrl;
_user = user;
_pass = pass;
}
private async Task<string> PostAsync(string url, Dictionary<string, string> fields)
{
fields["apiuser"] = _user;
fields["apipass"] = _pass;
using (var content = new FormUrlEncodedContent(fields))
{
var response = await Http.PostAsync(url, content);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadAsStringAsync()).Trim();
}
}
// Returns the raw token string. Errors arrive as "0:Invalid Login".
// There is no query action, so record the mint time and track the lifetime yourself.
public async Task<string> CreateAsync(int durationSeconds = 300)
{
var token = await PostAsync(_apiUrl, new Dictionary<string, string>
{
{ "action", "create" },
{ "duration", durationSeconds.ToString() } // SECONDS, 1-900 (default 600)
});
if (token.StartsWith("0:"))
{
throw new InvalidOperationException("OTP API error: " + token);
}
return token;
}
public async Task<bool> ExtendAsync(string token, int durationSeconds = 300)
{
var result = await PostAsync(_apiUrl, new Dictionary<string, string>
{
{ "action", "extend" },
{ "token", token },
{ "duration", durationSeconds.ToString() }
});
return result == "Success";
}
public async Task<bool> RemoveAsync(string token)
{
var result = await PostAsync(_apiUrl, new Dictionary<string, string>
{
{ "action", "remove" }, // NOT "destroy" - that returns an SQL error
{ "token", token }
});
return result.Trim() == "success";
}
// Only the .m3u8 carries the token - segments inherit the session.
// There is no expires parameter: the API has no query action, so nothing
// authoritative exists to put in one.
public static string BuildPlaylistUrl(string playlist, string token)
{
return string.Format("{0}?otp={1}", playlist, Uri.EscapeDataString(token));
}
}
Usage, holding one token per session:
// Credentials come from configuration or a secret store - never from source control.
// One endpoint for every action; the trailing slash is required.
var otp = new WinkOtpClient("https://router.example.com/otp/api/",
Configuration["Wink:ApiUser"],
Configuration["Wink:ApiPass"]);
var token = await otp.CreateAsync(300); // 300 seconds = 5 minutes
var mintedAt = DateTimeOffset.UtcNow; // the API will not tell you when this dies
var videoUrl = WinkOtpClient.BuildPlaylistUrl(
"https://router.example.com/200/public/hls/WF05-333F-4D5C-E636-2937.m3u8",
token);
// Later, well inside the lifetime you measured for your deployment:
await otp.ExtendAsync(token, 300);
mintedAt = DateTimeOffset.UtcNow;
// When the session ends - "remove", not "destroy":
await otp.RemoveAsync(token);
The Command API authenticates with apiuser and
apipass in the request body. A browser that can send that request is a browser that
holds the credential, and anyone can read it. The pattern below is therefore the same
anti-pattern documented for OTP in
Anti-patterns: What Not To Build — and it is worse here,
because the Command API can start, stop, block, reconfigure and reboot, not merely view. Put the
credential on your server and let the browser talk to your backend.
The correct shape: the page calls your own endpoint under an authenticated session, and your server holds the credential and speaks to the appliance.
// Browser - holds no credential, talks only to your own backend
async function guidStatus(guid) {
const res = await fetch('/api/forge-proxy.php', {
method: 'POST',
credentials: 'same-origin', // your session cookie, not an API credential
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: 'guidstatus', payload: guid })
});
if (!res.ok) {
throw new Error('Proxy request failed: ' + res.status);
}
return res.json();
}
guidStatus('WF05-1EDA-1C04-6E33-ABF9')
.then(function (r) { console.log('Status:', r.data); })
.catch(function (e) { console.error(e); });
The matching backend. It authenticates the caller first, allows only the commands this application is meant to expose, and adds the credentials itself:
<?php
// /api/forge-proxy.php - credentials live here, never in the browser
session_start();
// 1. Require a real, authenticated session. A header is not a session.
if (empty($_SESSION['user_id'])) {
http_response_code(401);
exit(json_encode(array('error' => 'Not authenticated')));
}
// 2. Allow-list the commands this application may issue. Never proxy arbitrary commands.
$allowed = array('version', 'guidstatus', 'inputstatus');
$body = json_decode(file_get_contents('php://input'), true);
$command = isset($body['command']) ? $body['command'] : '';
$payload = isset($body['payload']) ? $body['payload'] : '';
if (!in_array($command, $allowed, true)) {
http_response_code(403);
exit(json_encode(array('error' => 'Command not permitted')));
}
// 3. Build the request server-side, escaping anything that came from the client.
$xml = sprintf(
"<wink_api user=%s pass=%s><req id='1' command=%s>%s</req></wink_api>",
quoteattr(FORGE_API_USER), // from config/environment, not source control
quoteattr(FORGE_API_PASS),
quoteattr($command),
htmlspecialchars($payload, ENT_XML1 | ENT_QUOTES, 'UTF-8')
);
$ch = curl_init('https://forge.example.com/api/');
curl_setopt_array($ch, array(
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $xml,
CURLOPT_HTTPHEADER => array('Content-Type: application/xml'),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => true, // leave on in production
));
$response = curl_exec($ch);
curl_close($ch);
$node = @simplexml_load_string($response);
header('Content-Type: application/json');
echo json_encode(array(
'status' => $node ? (string) $node->resp['status'] : 'error',
'data' => $node ? trim((string) $node->resp) : 'Malformed response',
));
function quoteattr($s) {
return "'" . htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8') . "'";
}
?>
A server-side client. Values are escaped before they reach the XML, every call has a timeout, and certificate verification stays on:
import requests
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape, quoteattr
class WinkAPIError(Exception):
pass
class WinkAPI:
def __init__(self, host, username, password, verify_ssl=True, timeout=10):
self.host = host
self.username = username
self.password = password
self.verify_ssl = verify_ssl
self.timeout = timeout
self.api_url = f"https://{host}/api/"
def execute_command(self, command, payload="", req_id="1"):
# quoteattr/escape matter: a password containing ' or & would otherwise
# break the document, and an unescaped payload could inject elements.
xml_request = (
f"<wink_api user={quoteattr(self.username)} pass={quoteattr(self.password)}>"
f"<req id={quoteattr(str(req_id))} command={quoteattr(command)}>"
f"{escape(str(payload))}"
f"</req></wink_api>"
)
try:
response = requests.post(
self.api_url,
data=xml_request.encode("utf-8"),
headers={"Content-Type": "application/xml"},
verify=self.verify_ssl,
timeout=self.timeout,
)
response.raise_for_status()
except requests.RequestException as exc:
raise WinkAPIError(f"Request to {self.api_url} failed: {exc}") from exc
try:
root = ET.fromstring(response.text)
except ET.ParseError as exc:
raise WinkAPIError(f"Malformed response: {response.text[:200]}") from exc
resp = root.find("resp")
if resp is None:
raise WinkAPIError(f"No <resp> element in response: {response.text[:200]}")
return {
"status": resp.get("status"),
"command": resp.get("command"),
"data": (resp.text or "").strip(),
}
# Example usage - credentials come from the environment, not from source control
import os
api = WinkAPI(
"forge.example.com",
os.environ["WINK_API_USER"],
os.environ["WINK_API_PASS"],
)
try:
version = api.execute_command("version")
print(f"API Version: {version['data']}")
status = api.execute_command("guidstatus", "WF05-1EDA-1C04-6E33-ABF9")
print(f"Stream Status: {status['data']}")
except WinkAPIError as exc:
print(f"API call failed: {exc}")
An appliance running its factory self-signed certificate will fail
verification. Reach for verify_ssl=False only on a trusted network during bring-up, and
understand the trade: the credentials travel in the request body, so an unverified connection means
anyone in the path can read them. The fix is to install a real certificate, or to point
verify at the appliance's CA bundle
(verify="/path/to/appliance-ca.pem") rather than disabling the check.
# Get API version
curl -X POST https://forge.example.com/api/ \
--cacert /path/to/appliance-ca.pem \
--data-urlencode "<wink_api user='apiuser' pass='apipass'><req id='1' command='version'></req></wink_api>" \
-H 'Content-Type: application/xml'
# Start all streams
curl -X POST https://forge.example.com/api/ \
--cacert /path/to/appliance-ca.pem \
--data-urlencode "<wink_api user='apiuser' pass='apipass'><req id='1' command='start'>all</req></wink_api>" \
-H 'Content-Type: application/xml'
# Create OTP token (server-side only - apiuser/apipass never reach the browser)
# Keep the duration short for browser-facing use and renew with extend.
curl -d "apiuser=apiuser&apipass=apipass&action=create&duration=300" \
-X POST https://router.example.com/otp/api/
Anything passed with -d is visible to other users via
ps while the command runs, and is written to your shell history. That is acceptable at
a console you control; it is not a way to drive the API from a script or a shared host. Use
--data , or an environment variable your shell does not log, and keep the real
integration in server-side code as shown above.
# Create new input with full configuration
curl -X POST https://forge.example.com/api/ \
--cacert /path/to/appliance-ca.pem \
--data-urlencode "<wink_api user='apiuser' pass='apipass'>
<req id='1' command='input_source'>
<input title='Conference Room'
desc='Main conference room HD camera'
type='RTSP'
path='rtsp://admin:password@192.168.1.100:554/stream1'
fps='30'
width='1920'
height='1080'
ptz_avail='1'
usrlat='41.8781'
usrlong='-87.6298'
enabled='1' />
</req>
</wink_api>" \
-H 'Content-Type: application/xml'
apiuser/apipass to the client or expose a public endpoint that mints tokens for any caller. See OTP Must Be Minted Server-to-Server and the Integrator Security Checklist.Referer/Origin header is not access control.extend for sessions still watching, rather than issuing long-lived tokens up front| Email Support | support@wink.co |
|---|---|
| Phone Support | +1-312-281-5433 |
| Corporate Website | https://www.wink.co |
When contacting support, please provide:
The complete reference — every command, all code examples, and the OTP security guidance — formatted for printing and offline use.
Download PDF© 2025 WINK Streaming, Inc. All rights reserved.
Document Version: 1.6.3 - Updated September 2026
Matches OTP-API build 1.6.3, verified 16 September 2026
Original Issue: August 2022