@coumtech/sdk
v0.4.1
Published
Official Synxed web SDK — licensed in-game radio and music over a live /sdk session
Readme
Synxed Web SDK
Official web SDK for licensed in-game radio and music. Coum Technologies Ltd (Synxed) maintains this package. Studios ship it in web games.
One live session over WebSocket wss://api.synxed.com/sdk streams the catalog into the game. You draw the UI. AdTonos programmatic ads, voice, station wheel, and discovery chrome are opt-in — not required to play music. First-party Synxed audio ads are scheduled by the server for your API key (see Ads).
- Install:
@coumtech/sdkon npm (npm i @coumtech/sdk) - Source: github.com/coumtech/synxed-web-sdk
Synxed handles the music licensing required to make the catalog available through your integration.
How it works
- Get an API key from your Synxed developer account.
- Mount the player, or call
playPlaylist/playRadio(song, mix, and station work the same way). - The SDK opens the
/sdksession and plays licensed audio in the game. - You draw controls and layout. Built-in chrome (
SynxedWebPlayer), AdTonos ads, and voice stay off until you opt in. First-party Synxed ads follow your API key's ad frequency.
Default serverUrl is https://api.synxed.com. Playback uses WebSocket wss://api.synxed.com/sdk (protobuf session). That is the live contract — not REST /v1 and not Socket.IO. Every init carries protocolVersion 0.3.0; the backend treats older or missing versions as legacy clients (a content switch mid-ad ends the ad), and a 0.2.0-or-newer client instead waits for the ad to finish (see Ads). 0.3.0 is also what tells the server this client can take a break of two ads.
Install
npm i @coumtech/sdkPreviously published as unscoped synxed-sdk. @coumtech/sdk is the company package.
Versions and what each one needs
The package version and the wire protocol version are different numbers. The protocol version is what the SDK announces to the backend; the package version is what you install.
| SDK | Protocol | Needs backend | Notes |
| --- | --- | --- | --- |
| 0.4.1+ | 0.3.0 | main >= 3f77d93; two-ad breaks need synxed-backend development #102+ | An ad break can be two ads; breakStart/breakEnd bracket the whole break |
| 0.4.0 | 0.2.0 | main >= 3f77d93 (deployed 22 Sep 2026) | Ads recover from failure; an ad break holds play*()/switch*() until it ends |
| 0.3.x | 0.1.0 | any | An ad that fails to load stops playback for good |
If you run your own Synxed backend, deploy it before upgrading the SDK. On an older backend a 0.4.x client reports a failed ad in a way that backend does not understand, and it has no watchdog to end the break, so the listener hears silence instead of the next track. Against api.synxed.com this is already handled.
0.4.1 needs no newer backend than 0.4.0 did to play ads. Ad pods travel in
fields the wire protocol did not have before, and a backend that does not send
them is read as one ad per break, with the same terminal analytics payload
0.4.0 sent. What is new is that 0.4.1 announces protocolVersion 0.3.0
instead of 0.2.0, which is how the server decides a client can take a break
of two ads — it serves a pod only at 0.3.0 and above. Any backend at
3f77d93+ already accepts 0.3.0 as a current client, so nothing has to be
upgraded in step; pods simply start appearing once the backend serves them. See
Ad breaks come as pods.
Four things do change on an unchanged backend, so read them before you
upgrade. 0.4.1 is published as a patch, so ^0.4.0 and ~0.4.0 install it
without asking; pin 0.4.0 exactly if you need time to adapt:
AdInfoandAdTonosSlotInfogain requiredbreakId,podPositionandpodSizefields. Any fixture or mock you build by hand stops type-checking until you add them.breakStartandbreakEndnow fire around each ad, each ad being a break of one. Additive — duck against these instead ofadStart/adEnd.- The built-in player stays in ad mode from
adEnd/adTonosSlotEnduntil the break closes (frombreakStartfor an item it rejects on arrival or declines, which fires neither): a round trip normally, and if the server goes quiet, the break's own deadline —durationSeconds+ 30 s, where an ad that declared no duration counts as 15 s and an AdTonos slot that declared none 30 s; an ad rejected on arrival (no usablehttps:playbackUrl) counts as its declared duration, or 60 s if it declared none, and a malformed AdTonos slot (no validslotId) counts as 60 s. During it a track skip is refused and auto-minimize is held. - The same creative served again in a later break now plays, where 0.4.0 could answer it with the previous break's end event and skip it. That is a fix, but it means an ad that was silently dropped is now played and counted.
Changes worth reading before an upgrade ship in the package as CHANGELOG.md:
open node_modules/@coumtech/sdk/CHANGELOG.md, or run
npm view @coumtech/sdk for the published versions.
Quick start
Load the key from your bundler's environment (process.env, import.meta.env, or NEXT_PUBLIC_SYNXED_API_KEY). Never commit a live synx_live_… key.
Headless — you own the UI:
import { SynxedPlayer } from "@coumtech/sdk";
const player = new SynxedPlayer({
apiKey: process.env.SYNXED_API_KEY!,
autoConnect: true,
});
await player.playPlaylist({
playlistCode: "sxpl_YOUR_CODE",
});Or mount the optional built-in player:
import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";
SynxedWebPlayer.mount({
apiKey: process.env.SYNXED_API_KEY!,
source: { type: "radio" },
});SynxedWebPlayer needs @coumtech/sdk/player.css once in the app entry file. Headless SynxedPlayer does not. The SDK does not inject a <style> tag at runtime.
Requirements
You need:
- A Synxed API key from your developer account:
https://portal.synxed.com - One of these content sources:
- a song ID
- a playlist code
- a mix ID
- live radio
- a station ID
Choose an Integration
Use SynxedWebPlayer if you want the fastest setup with a ready-made player.
Use SynxedPlayer if you want to build your own controls, layouts, queue UI, station browser, or game-specific audio UI.
| Need | Use |
| ------------------------------------ | ----------------- |
| Floating player bar or mini player | SynxedWebPlayer |
| Custom buttons and layout | SynxedPlayer |
| Play one song | either |
| Play a playlist | either |
| Play live radio | either |
| Play a mix | SynxedPlayer |
| Play a scheduled station | SynxedPlayer |
| Fetch stations, schedules, and mixes | discovery helpers |
| Voice commands | either |
Built-In Web Player
The built-in player works in React, Vue, Angular, plain HTML, and most browser-based game/app shells.
import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";
const player = SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});Call destroy() when your page, route, or component unmounts:
player.destroy();Play One Song
Use the song ID copied from the Synxed developer portal.
SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "song", songId: "SYNX_EX_SONG_ID" },
});Play a Playlist
SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});Play Live Radio
SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "radio" },
});Live radio always stays live. It does not resume from an old timestamp after refresh.
Mount Inside Your Own Container
<div id="synxed-player"></div>SynxedWebPlayer.mount({
container: document.getElementById("synxed-player")!,
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});When container is omitted, the player creates its own floating root and appends it to document.body.
Use a Custom Backend URL
The SDK uses https://api.synxed.com by default and connects to WebSocket /sdk on that host. Override serverUrl only for local development, staging, or self-hosted backend environments.
A self-hosted backend must be at main >= 3f77d93 (deployed 22 Sep 2026). This SDK
reports a failed ad in a way earlier backends do not understand, and those backends have
no watchdog to end the break, so the listener hears silence instead of the next track.
api.synxed.com already runs a new enough build.
This SDK speaks wire protocol 0.3.0, which any backend at 3f77d93+ accepts as a
current client — so beyond that baseline there is no further upgrade order to observe.
Two-ad breaks additionally need synxed-backend development #102 or later; until then
every break is a single ad.
SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
serverUrl: "https://your-synxed-backend.example.com",
source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
});Web Player Appearance
Modes
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
mode: "wide",
});| Mode | Behavior |
| ------- | --------------------------------------------------- |
| wide | Compact horizontal bar. This is the default. |
| mini | Circular DJ avatar button with a title pill. |
| large | Larger card with visualizer and transport controls. |
Mount once. The widget changes chrome in place (setMode) so playback does not restart.
With enableChromeCycle: true and enableStationWheel: true:
- Click the mini avatar → wide bar
- Click the hamburger on the bar → large card
- Click the hamburger on the card → back to mini
- Double-click the avatar (any size) → station wheel
enableExpand is discovery only (toggleExpand / "Open in"). It does not cycle chrome.
onMiniClick still overrides the mini tap (for example to play/pause instead of expanding).
When the player collapsed itself (see below), a tap first restores it and then calls
onMiniClick, so a handler cannot leave the player stuck small.
Auto-minimize
New in 0.4.0, and on by default. A player upgraded from 0.3.x collapses on its own
where it previously did not; autoMinimizeDelayMs: 0 restores the old behaviour.
The player gets out of the way on its own. After autoMinimizeDelayMs (default 7000) with
no interaction, it collapses to a small idle icon (autoMinimizeMode: "idle", the default)
or to the mini avatar ("mini"). Hovering it, keeping discovery open, or holding voice
pauses the countdown; a tap restores the size it had.
SynxedWebPlayer.mount({
// ...
autoMinimizeDelayMs: 7000, // 0 turns auto-minimize off
autoMinimizeMode: "idle", // or "mini"
});Only a collapse the player made itself is undone. A player mounted at mode: "idle" stays
that size, because it is already the collapsed mode; one mounted at mode: "mini" still
collapses to the idle icon, since the default autoMinimizeMode is "idle". Set
autoMinimizeMode: "mini" to keep the mini avatar, or autoMinimizeDelayMs: 0 to stop the
player collapsing at all. While an ad plays the player never collapses, and an ad that
starts on a collapsed player restores it so the ad's title, skip countdown and CTA are
visible; it collapses again after the ad.
Recolouring at runtime
The theme is a set of CSS custom properties on the widget root, so setTheme repaints
without rebuilding chrome, touching the engine, or restarting playback. Partial themes are
resolved against the defaults — same rules as the theme mount option — and the colour
survives a later setMode().
const ui = SynxedWebPlayer.mount({ /* ... */ theme: { accent: "#CCFF00" } });
ui.setTheme({ accent: "#FF00A8" }); // repaints in place, music keeps playing
ui.getTheme().accent; // "#FF00A8" — resolved theme, every value filled inWidth
The wide player has a default responsive width. You can override it with any valid CSS width value.
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
width: "720px",
});Responsive example:
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
width: "min(92vw, 820px)",
});Position and Dragging
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "radio" },
position: {
placement: "bottom-center",
offsetX: 16,
offsetY: 24,
},
draggable: true,
});Available placements:
top-lefttop-rightbottom-leftbottom-rightbottom-center
Theme
The player is gold by default. To match your brand, set theme.accent.
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
theme: {
accent: "#22c55e",
background: "#050805",
text: "#ffffff",
},
});border, liveDot, and glow follow accent by default. Override them only when those colors need to be different.
theme: {
accent: "#22c55e",
border: "rgba(34, 197, 94, 0.55)",
liveDot: "#22c55e",
glow: "rgba(34, 197, 94, 0.35)",
}Custom Labels and Classes
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
powerByLabel: "Powered by Synxed",
className: "my-synxed-player",
style: {
zIndex: "9999",
},
});Resumable Playback
The built-in web player resumes songs and playlists by default after a refresh, using a stable anonymous listener ID stored locally.
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "song", songId: "SYNX_EX_SONG_ID" },
resumePlayback: true,
});Turn it off when you always want playback to start fresh:
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
resumePlayback: false,
});Resume behavior:
- songs, playlists, and mixes can resume
- live radio and scheduled stations stay live and do not resume old positions
- checkpoint writes are throttled to avoid storage pressure
- the SDK stores playback position metadata, not API keys or audio files
For a custom UI, enable resume on SynxedPlayer and pass the same listenerId whenever you start content:
import { SynxedPlayer } from "@coumtech/sdk";
const listenerId = "user_123";
const player = new SynxedPlayer({
apiKey: "YOUR_SYNXED_API_KEY",
autoConnect: true,
resumePlayback: true,
});
await player.playPlaylist({
playlistCode: "sxpl_YOUR_CODE",
listenerId,
});Voice Commands
Voice is off unless you opt in and host VAD/ONNX assets yourself (same-origin or https:). The SDK does not download WASM from jsDelivr.
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
enableVoice: true,
voice: {
vadBaseAssetPath: "/vendor/vad/",
onnxWasmBasePath: "/vendor/onnx/",
},
});Turn voice off:
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
enableVoice: false,
});Customize the hold delay:
SynxedWebPlayer.mount({
apiKey: "...",
serverUrl: "https://api.synxed.com",
source: { type: "playlist", playlistCode: "sxpl_..." },
voiceHoldMs: 500,
});For custom controls:
const djButton = document.getElementById("dj-button")!;
djButton.addEventListener("pointerdown", () => {
void player.beginVoiceHold({ listenerId: "user_123" });
});
djButton.addEventListener("pointerup", () => {
void player.endVoiceHold();
});You can also cancel an active voice hold:
player.cancelVoiceHold();Custom Player UI
Use SynxedPlayer when you need full UI control.
import { SynxedPlayer } from "@coumtech/sdk";
const player = new SynxedPlayer({
apiKey: "YOUR_SYNXED_API_KEY",
autoConnect: true,
resumePlayback: true,
});Voice and AdTonos ads stay off until you pass voice: { enabled: true, vadBaseAssetPath, onnxWasmBasePath } or ads: { enabled: true, vastUrl }. First-party Synxed ads are not controlled by these options — see Ads.
Play Content
await player.playSong({
songId: "SYNX_EX_SONG_ID",
listenerId: "user_123",
});await player.playPlaylist({
playlistCode: "sxpl_YOUR_CODE",
listenerId: "user_123",
});await player.playMix({
mixId: "mix_YOUR_ID",
listenerId: "user_123",
});await player.playRadio({
listenerId: "user_123",
});await player.playStation({
stationId: "station_YOUR_ID",
listenerId: "user_123",
});Playback Controls
player.pause();
player.resume();
player.stop();
player.skip();
player.previous();
player.skipTo(2);
player.seek(45000);
player.setVolume(0.8);Notes:
seek(ms)expects milliseconds.setVolume(volume)accepts0to1.skip()andprevious()apply to playlists and mixes.- live radio and stations are continuous streams, so skip/previous are not intended for them.
Foreground / Background Mix
Games usually have their own audio (SFX, ambience) playing under the Synxed stream. setMix balances the two:
foregroundscales the Synxed licensed track on the SDK's playback graph. It multiplies withsetVolume(), so the listener's volume always wins, and it persists across track changes and ads.backgroundnever touches SDK audio. It is a level your game reads viagetMix()or themixChangeevent and applies to its own audio bus.
player.setMix({ foreground: 1, background: 0.35 });
// Apply the background level to your own audio (example with Howler):
player.on("mixChange", ({ background }) => {
gameAmbience.volume(background);
});Both levels accept 0 to 1 and default to 1. SynxedWebPlayer exposes the same setMix / getMix methods, which delegate to the engine.
Ads
There are two kinds of audio ad, and they are switched on in different places.
| | First-party Synxed ads | AdTonos (programmatic VAST) |
| --- | --- | --- |
| Who decides | The server, from your API key's ad frequency. An API key with an ad frequency of 0 never gets ads. | You, with ads: { enabled: true, vastUrl }. |
| SDK option | None — ads.enabled does not turn them on or off. | ads.enabled, ads.vastUrl (https: only), ads.optOut. |
| Events | adStart, adSkipUpdate, adEnd | adTonosSlotStart, adTonosSlotEnd |
| Break events | breakStart, breakEnd — once per break, whichever kind of ad it holds | |
| Getters | currentAd, isAdPlaying, canSkipAd(), getAdSkipCountdownSeconds(), currentAdBreak | currentAdTonosSlot, isAdPlaying, currentAdBreak |
| Skip / click | skipAd(), clickAd() | Not skippable, no CTA |
Headless SynxedPlayer never injects the AdTonos script unless you opt in:
const player = new SynxedPlayer({
apiKey: "YOUR_SYNXED_API_KEY",
ads: {
enabled: true, // AdTonos only
vastUrl: "https://your-vast-tag.example/vast.xml",
optOut: false, // true sets window.adtonosOptOuts = ["*"]
},
});Ad breaks come as pods
An ad break is normally two ads played back to back — one first-party Synxed ad and one AdTonos slot — and the SDK treats the pair as a single break:
- At the start of a session the first-party ad plays first; between songs and at a mix's cue points the AdTonos slot plays first. The server decides the order and sends the two ads one after the other.
- If only one side has an ad, the break is just that one ad. If neither has one, there is no break.
- Every ad of a break shares a
breakId, and each one carriespodPosition(1-based) andpodSize. A single-ad break haspodSize1.podSizeis the number of ads the server planned: if one side has nothing to serve, a break of 2 delivers only the other ad, which can arrive as position 2 of 2. Drive your UI off what actually arrives, not off a count you expect to reach. - Cadence and frequency caps are server-side. Ads are at most 30 s each and a listener hears at most six an hour; the SDK does not schedule, count, or cap anything.
adStart / adEnd (and adTonosSlotStart / adTonosSlotEnd) fire per ad, as before. breakStart and breakEnd fire once for the whole break, so you can duck your own audio for the break instead of twice:
player.on("breakStart", ({ breakId, podSize }) => {
duckGameAudio(); // once, however many ads the break holds
console.log(`break ${breakId}: ${podSize} ad(s)`);
});
player.on("breakEnd", () => unduckGameAudio());
player.on("adStart", (ad) => {
// podSize > 1 means a pod — tell the listener where they are in it.
if (ad.podSize > 1) showAdPosition(`Ad ${ad.podPosition} of ${ad.podSize}`);
});The player treats the whole break as "an ad is on" — including the gap between two ads, and the wait after the last one — so a track skip is refused and auto-minimize is held until breakEnd, not until adEnd.
breakStart fires before the first ad's adStart, and breakEnd after the last ad's adEnd — including for a break whose ads cannot play at all. The two are paired: every breakStart gets exactly one breakEnd, so duck/unduck stays balanced however the break ends. Normally that is once per break. If the SDK has to end a break early — the server answers PROCESSING / SERVICE_UNAVAILABLE instead of the next ad, or the break's own deadline passes with no answer — breakEnd fires then, because the listener is in silence and your audio should come back; if the server then sends another ad of that same break after all, it is announced with a fresh breakStart. Treat the pair as "audio is interrupted / audio is back", not as a count of breaks.
currentAdBreak reports the break in progress (null when there is none), including the brief gap between two ads of a pod, when isAdPlaying is momentarily false.
A breakStart or breakEnd listener that throws cannot break playback: the SDK logs the error and carries on with the break. Calling destroy() from inside a breakStart handler ends the session immediately, so that break's breakEnd arrives before your handler returns.
Against a backend that does not send pod fields, every ad is its own single-ad break: breakId is "", podPosition and podSize are 1, and breakStart / breakEnd fire once per ad.
What happens during an ad
- The music fades out (about 400 ms) once when the break opens and fades back in once when it closes — never between two ads of a pod.
- The ad plays through the same audio engine as music, so
setVolume(),setMix(),pause()andresume()apply to it. - Each ad of a break keeps its own skip countdown, load timeout, stall watchdog and outcome. Skipping the first ad of a pod moves to the second — it does not bring the music back.
isAdPlayingistruefrom the moment the ad arrives until it ends.seek(),previous()andskipTo()are ignored during an ad.skip()skips the ad once it is skippable (on any source, including radio and stations). The server refuses new content while an ad break is open, so aplay*()orswitch*()call made during the break is held and sent the moment the whole break closes — not between two ads of a pod (the last one wins). The track the server was holding is replaced by it rather than played first.- After the last ad of the break the server sends the held track. For a mid-roll break (for example a mix cue) playback resumes where it was interrupted — once, at the end of the break.
- If the listener pauses during an ad, the pause holds for the rest of the break: the next ad of the pod loads but waits, and the next track arrives paused too.
- Voice holds don't start during an ad break. If a voice request already in flight completes during the break, the ad keeps playing. The DJ's spoken reply is held, not played over the ad. When the break ends, the reply plays first, then the voice playlist.
stop()during a break stops the ad's audio and emitsadEnd, but the server's break stays open until it closes on its side. Nothing more from that break is played: neither a re-send of the ad that was playing nor a later ad of the pod. Aplay*()/switch*()made afterstop()is held until the break closes, and the track the server hands back is not played.- If a
play*()/switch*()crosses an ad the server has just started, the server answers with aSynxedErrorwhosecodeisAD_PLAYING. The SDK holds that request and sends it when the break closes, and does not emit the error. You only seeAD_PLAYINGinerrorfor requests the SDK did not make itself (for example a voice handoff). - When an ad's media ends slightly before its declared duration (up to 3 s), the SDK waits silently for the remainder before reporting completion, because the server accepts
ad_completeonly after that duration.
Each ad of a break reaches exactly one outcome, reported to the server, which then sends the next ad of the break — or, after the last one, the held track:
| Outcome | First-party analytics event | AdTonos analytics event |
| --- | --- | --- |
| Played to the end | ad_complete | adtonos_complete |
| Skipped (after skipAfterSeconds) | ad_skip | — |
| Could not play | ad_error with reason | adtonos_skip with reason |
First-party end events carry the adId so the server can match them to the break; one produced while offline is sent after the session resumes. ad_error reasons: load_failed (media failed to load, or the ad's playbackUrl is not https:), load_timeout (media did not load within 8 s), autoplay_blocked (the browser refused to play without a user gesture), playback_error (a fatal media error mid-ad, or media far shorter than its declared duration), stalled (no progress for 8 s while not paused by the listener). When an ad cannot play, the SDK emits adEnd once (if adStart was emitted), sets the status to loading, and music resumes as soon as the server answers — the listener is never left in silence. When the server sends a breakId, first-party and AdTonos end events carry it back, and echo podPosition / podSize whenever those arrived as whole positive numbers — including for an explicit single-ad pod — so the server can tell which ad of the pod they close. Values the SDK had to normalize are not reported back, and a backend that sends no breakId gets exactly the payload it got before pods existed: no breakId key, and no pod numbers. AdTonos slots report adtonos_skip with a reason instead of ad_error: no_fill (the VAST tag returned no ad — distinct from ads_disabled, and the server continues the break with the other ad without counting this one), overrun (the slot ran past its duration + 15 s from receipt; the creative is cut and no VAST complete beacon fires), the media failures above, ads_disabled or invalid_slot.
Building your own ad UI
player.on("adStart", (ad) => {
showAdBadge(ad.brandName, ad.campaignName);
if (ad.ctaUrl) showCta(ad.ctaLabel); // e.g. "Visit website", "Call now"
if (ad.companionBannerUrl) showBanner(ad.companionBannerUrl); // always https
});
player.on("adSkipUpdate", ({ canSkip, countdownSeconds }) => {
skipButton.disabled = !canSkip;
skipButton.textContent = canSkip ? "Skip ad" : `Skip in ${countdownSeconds}s`;
});
player.on("adEnd", () => hideAdUi());
player.on("adTonosSlotStart", ({ podPosition, podSize }) => {
showAdBadge("Advertisement");
if (podSize > 1) showAdPosition(`Ad ${podPosition} of ${podSize}`);
});
player.on("adTonosSlotEnd", () => hideAdUi());
skipButton.onclick = () => player.skipAd(); // no-op until canSkipAd() is true
ctaButton.onclick = () => player.clickAd();clickAd() only opens a validated destination: https:/http: links (including https://wa.me/…) open in a new tab with noopener,noreferrer; tel: and mailto: go to the device's handler. Anything else (for example javascript:) is rejected and ad.ctaUrl is absent. The ad_cta_click event is recorded once per ad, however many times the listener clicks.
SynxedWebPlayer renders all of this for you in every mode (idle, mini, wide, compact, large) and for every source, including radio and stations: an "Ad" badge, the brand name in the title, a keyboard-accessible CTA button and a skip countdown that turns into a Skip button. When the break holds more than one ad it also shows which one is playing — "1 of 2" beside the badge, shortened to "1/2" in the narrow mini strip — and folds that into the strip's label for assistive tech. A single-ad break looks exactly as it did before. Pass AdTonos options with the same ads option.
Player Events
Use events to keep your UI synced with playback state.
player.on("stateChange", (state) => {
console.log(state.status, state.currentTrack);
});
player.on("trackChange", (track) => {
console.log(track.title, track.artist, track.albumArt);
});
player.on("timeUpdate", ({ currentTime, duration }) => {
console.log(currentTime, duration);
});
player.on("queueUpdated", (tracks) => {
console.log(tracks);
});
player.on("connected", () => {
console.log("Connected to Synxed");
});
player.on("disconnected", (reason) => {
console.log("Disconnected", reason);
});
player.on("error", (error) => {
console.error(error);
});Ad events (breakStart, breakEnd, adStart, adSkipUpdate, adEnd, adTonosSlotStart, adTonosSlotEnd) are covered in Ads.
Voice events:
player.on("voiceStateChange", (state) => {
console.log(state);
});
player.on("voiceResult", (result) => {
console.log(result.transcript, result.playlistCode);
});
player.on("voiceUserSpeakingChange", (speaking) => {
console.log("speaking", speaking);
});Discovery Helpers
The SDK includes lightweight fetch helpers for station and mix discovery.
import { fetchMixes, fetchStations, fetchStationSchedule } from "@coumtech/sdk";Fetch Mixes
const mixes = await fetchMixes("https://api.synxed.com", {
headers: {
Authorization: "Bearer YOUR_SYNXED_API_KEY",
},
});
const firstMix = mixes[0];Then play a mix with the lower-level player:
await player.playMix({
mixId: firstMix.id,
listenerId: "user_123",
});Fetch Stations
const stations = await fetchStations("https://api.synxed.com", {
headers: {
Authorization: "Bearer YOUR_SYNXED_API_KEY",
},
});
const firstStation = stations[0];Then play a station:
await player.playStation({
stationId: firstStation.id,
listenerId: "user_123",
});Fetch a Station Schedule
const schedule = await fetchStationSchedule(
"https://api.synxed.com",
firstStation.id,
{
headers: {
Authorization: "Bearer YOUR_SYNXED_API_KEY",
},
},
);Radio Now Playing
For live radio metadata:
import { fetchRadioNowPlaying } from "@coumtech/sdk";
const info = await fetchRadioNowPlaying("https://api.synxed.com");
if (info) {
console.log(info.title, info.station, info.isLive);
}Poll every 10 to 15 seconds if you show this in your own UI. Do not poll faster than that in production.
React Example
import { useEffect, useRef } from "react";
import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";
export function MusicPlayer() {
const playerRef = useRef<SynxedWebPlayer | null>(null);
useEffect(() => {
playerRef.current = SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "playlist", playlistCode: "sxpl_YOUR_CODE" },
mode: "wide",
resumePlayback: true,
});
return () => {
playerRef.current?.destroy();
playerRef.current = null;
};
}, []);
return null;
}Next.js Example
The player needs browser APIs, so mount it from a client component.
"use client";
import { useEffect, useRef } from "react";
import "@coumtech/sdk/player.css";
import { SynxedWebPlayer } from "@coumtech/sdk";
export default function SynxedClientPlayer() {
const playerRef = useRef<SynxedWebPlayer | null>(null);
useEffect(() => {
playerRef.current = SynxedWebPlayer.mount({
apiKey: process.env.NEXT_PUBLIC_SYNXED_API_KEY!,
source: { type: "radio" },
});
return () => {
playerRef.current?.destroy();
};
}, []);
return null;
}Keep browser-exposed keys prefixed with NEXT_PUBLIC_. Do not put private backend secrets in client code.
Plain HTML Example
Copy the browser files into your app's public assets folder:
mkdir -p public/vendor/synxed
cp node_modules/@coumtech/sdk/dist/synxed.js public/vendor/synxed/synxed.js
cp node_modules/@coumtech/sdk/dist/player.css public/vendor/synxed/player.cssPowerShell:
New-Item -ItemType Directory -Force public/vendor/synxed | Out-Null
Copy-Item node_modules/@coumtech/sdk/dist/synxed.js public/vendor/synxed/synxed.js
Copy-Item node_modules/@coumtech/sdk/dist/player.css public/vendor/synxed/player.cssThen use normal HTML tags:
<link rel="stylesheet" href="/vendor/synxed/player.css" />
<script src="/vendor/synxed/synxed.js"></script>
<script>
const player = window.Synxed.SynxedWebPlayer.mount({
apiKey: "YOUR_SYNXED_API_KEY",
source: { type: "radio" },
});
</script>Cleanup
Always destroy SDK instances when the owning page, route, component, scene, or game state is removed.
player.destroy();This closes the WebSocket, stops audio, clears timers, removes lifecycle listeners, and releases voice resources.
TypeScript Reference
SynxedWebPlayer.mount(options)
type SynxedWebPlayerSource =
| { type: "song"; songId: string }
| { type: "playlist"; playlistCode: string }
| { type: "radio" }
| { type: "mix"; mixCode: string }
| { type: "station"; stationCode: string };
type SynxedWebPlayerMode = "mini" | "idle" | "wide" | "large" | "compact";
interface SynxedWebPlayerOptions {
container?: HTMLElement;
apiKey: string;
serverUrl?: string;
source: SynxedWebPlayerSource;
mode?: SynxedWebPlayerMode;
width?: string;
theme?: SynxedWebPlayerTheme;
position?: SynxedWebPlayerPosition;
nowPlayingPollMs?: number;
powerByLabel?: string;
className?: string;
style?: Partial<CSSStyleDeclaration>;
onMiniClick?: () => void;
/** Idle time before collapsing, in ms. Default 7000. 0 disables auto-minimize. */
autoMinimizeDelayMs?: number;
/** What to collapse to. Default "idle". */
autoMinimizeMode?: "idle" | "mini";
/** Width of the collapsed player in px. Default 165 (mini) / 34 (idle). */
miniSize?: number;
draggable?: boolean;
enableVoice?: boolean;
voiceHoldMs?: number;
listenerId?: string;
resumePlayback?: boolean;
debug?: boolean;
/** AdTonos / VAST only. First-party Synxed ads follow the API key's ad frequency. */
ads?: SynxedAdsOptions;
}SynxedPlayer
interface SynxedConfig {
apiKey: string;
serverUrl?: string;
autoConnect?: boolean;
debug?: boolean;
gameContext?: string;
deviceType?: string;
voice?: VoiceHoldOptions;
ads?: SynxedAdsOptions;
resumePlayback?: boolean;
}
interface SynxedAdsOptions {
enabled?: boolean; // AdTonos / VAST opt-in, default false
vastUrl?: string; // https only
optOut?: boolean;
}Ad breaks
/** Carried by breakStart / breakEnd, and by currentAdBreak. */
interface AdBreakInfo {
/** Shared by every ad of the break. "" when the backend sends no breakId. */
breakId: string;
/** How many ads the break holds. 1 for a single-ad break. */
podSize: number;
}
// AdInfo (adStart / adEnd / currentAd) and AdTonosSlotInfo
// (adTonosSlotStart / adTonosSlotEnd / currentAdTonosSlot) both add:
// breakId: string; // "" on a backend without pods
// podPosition: number; // 1-based; 1 for a single-ad break
// podSize: number; // 1 for a single-ad breakTests
npm test
npx tsc --noEmitSee TESTING.md for what the suite covers. Do not commit API keys or hit production from tests.
Production Checklist
- Import
@coumtech/sdk/player.cssif usingSynxedWebPlayer. - Call
destroy()when unmounting. - Use a stable
listenerIdif you want resume to work in a custom UI. - Keep
resumePlayback: falsefor experiences that must always start fresh. - Do not poll radio metadata faster than every 10 to 15 seconds.
- Use HTTPS in production.
- Do not expose private backend secrets in browser code.
- Handle
error,disconnected, andstateChangeevents in custom UIs.
Troubleshooting
The player is invisible or animations are missing
Import the CSS once:
import "@coumtech/sdk/player.css";Playback does not start automatically
Browsers may block autoplay until the user interacts with the page. Let the user click play, tap the mini player, or trigger playback from a user gesture. When autoplay is blocked the player reports paused (not loading), so a Play button works. An ad that is blocked the same way is reported as ad_error (autoplay_blocked) and the music continues.
Resume does not work in a custom UI
Make sure all of these are true:
resumePlayback: trueis set onSynxedPlayer- the same
listenerIdis passed toplaySong,playPlaylist, orplayMix - the content is a song, playlist, or mix
- the previous checkpoint has not expired
Radio or stations do not resume after refresh
This is expected. Live radio and scheduled stations stay live.
Voice does not start
Voice capture requires a user gesture and microphone permission. Call beginVoiceHold() from a pointer, mouse, touch, or keyboard interaction.
Artwork URLs should show Synxed domains
Synxed API responses should return artwork through Synxed proxy URLs. If you see a third-party provider domain in a Synxed response, update the backend response mapper before shipping.
License
Copyright (c) 2026 Coum Technologies Ltd. All rights reserved.
This package is published as UNLICENSED: installing it from npm grants no
right to use it. Use is permitted only under a written agreement with Coum
Technologies Ltd. The full text ships in the package as LICENSE.
Licensing enquiries: [email protected] synxed.com
