Integration guide
Add a player to your site
Give visitors a way to listen without leaving your website. Create an audio player in Obbo, paste its code into your page, and you're ready. If someone else manages your website, send them this guide.
The first three steps are all most websites need. Styling and developer reference are optional.
01Get a player ID
A player is the thing you embed. It points at a whole podcast or at specific episodes, and it has a public ID you paste into your HTML.
- Open Account, then Audio players.
- Create a player, name it for your own reference, and add what it should play: a whole podcast, one episode, or several episodes in the order you choose.
- Copy the player ID.
- Publish changes and keep the player enabled. Draft edits are not visible to your website visitors.
The ID is meant to be public.It sits in your page source where anyone can read it, and that is fine. It grants one thing: permission to play the episodes you put in that player. It is not an API key, it reaches nothing else in your account, and deactivating the player revokes it everywhere at once.
New finished episodes in a whole-podcast player appear automatically. If you change the selected episodes or player settings, publish those changes. The embed code does not need replacing.
02Paste two lines
Replace YOUR_WIDGET_ID with your player ID. Put the script on your page and the div where the player should appear. In a website builder, use a custom HTML or embed block that allows scripts.
<script src="https://www.obbo.me/player/obboplayer.js" async></script>
<div data-obbo-player="YOUR_WIDGET_ID"></div>That is a complete integration. The script finds every element with a data-obbo-player attribute and fills it in, whether the page has one player or ten. Loading the script twice is harmless.
Audio only downloads after someone presses play.The player loads its episode list and artwork with the page, but requests audio only after a visitor starts playback. A page view does not count as a play.
03Check your published page
Save and publish your website, then open the page as a visitor would. Some website editors do not run scripts inside their editing preview.
- Your podcast title and finished episodes should appear without signing in to Obbo.
- Press play, try the seek bar, and pause. Audio should only start after you ask for it.
- Check the page on a phone as well as a wider screen. If the player is missing, make sure your website editor allows custom HTML and external scripts.
You're ready.You can stop here. Explore the sections below only if you want another layout, your own branding, or a custom integration.
04Pick a layout
There are six visible layouts, plus auto. Choose one with data-obbo-ui. Omit it or use auto to follow your player's published compact or playlist setting.
| Value | What it is |
|---|---|
list | The controls with your episode list underneath. For a whole podcast, or when someone might want an episode other than the newest. |
bar | Just the controls. For an article or product page built around one episode. |
card | Cover art at full size with the button on its edge. For sidebars and grids, where a horizontal bar looks stapled on. |
inline | A play button and a clock, sized to sit inside a sentence rather than interrupt it. |
dock | Pins to the bottom of the window once someone presses play, so the controls do not scroll away. Dismissible. |
transcript | The episode as readable text that follows the audio. The spoken line is highlighted; click any line to jump there. |
auto | The default. Follows the layout saved with the player. |
<script src="https://www.obbo.me/player/obboplayer.js" async></script>
<div
data-obbo-player="YOUR_WIDGET_ID"
data-obbo-ui="list"
data-obbo-theme="dark"
></div>Every attribute
| Setting or identifier | What it does |
|---|---|
data-obbo-player | Required. Your player ID. |
data-obbo-ui | list, bar, card, inline, dock, transcript, or auto. |
data-obbo-theme | light or dark. |
data-obbo-episode | Start on a particular episode. It is selected, not played. |
data-obbo-isolate | Render inside a shadow root, sealed off from your CSS. |
data-obbo-locale | Language for the player's own buttons: en, es, da or ja. Episode titles come from your podcast either way. |
data-obbo-language | Audio language: en, es, da, ja, de, fr, it, nl, sv, no or pl. Defaults to the page's lang attribute. An episode without that language plays its original recording. |
data-obbo-autoadvance | Play the next episode when one ends. |
Several players on one page is fine.They share a single request for your episode list, and starting one pauses the others, so a visitor never ends up with two voices at once.
05Make it look like your site
The player renders into your page, not an iframe. Use the variables below to match your colors, corner radius and font. The example includes the class that connects your HTML to your CSS.
The quick way: nine variables
| CSS variable | Default value |
|---|---|
--obbo-player-bg | #ffffff |
--obbo-player-fg | #1c2733 |
--obbo-player-muted | #66768a |
--obbo-player-accent | #2f6fed |
--obbo-player-accent-fg | #ffffff |
--obbo-player-border | #e3e8ef |
--obbo-player-row-hover | #f4f6f9 |
--obbo-player-radius | 12px |
--obbo-player-font | System font stack |
<div class="my-player" data-obbo-player="YOUR_WIDGET_ID"></div>
<style>
.my-player {
--obbo-player-accent: #a1482e;
--obbo-player-radius: 8px;
--obbo-player-font: system-ui, sans-serif;
}
</style>The thorough way: class names
Stable obbo- classes let your stylesheet target the controls. Prefer variables for branding; use classes for more detailed changes. The root's data-obbo-state attribute lets you style loading and error states. Keep error messages visible so visitors know when playback is unavailable.
| Setting or identifier | What it does |
|---|---|
.obbo-p | The player root. Carries data-obbo-state. |
.obbo-play | Play and pause button. |
.obbo-seek | The scrubber, an input of type range. |
.obbo-title / .obbo-sub | Episode title and podcast name. |
.obbo-art | Cover image. Hidden when the podcast has none. |
.obbo-list / .obbo-row | Episode list and its rows. The playing row carries aria-current. |
.obbo-note | The loading, empty and error message. |
.obbo-p[data-obbo-state="loading"] { opacity: 0.6; }
.obbo-p[data-obbo-state="error"] .obbo-note { font-weight: 700; }If your CSS is aggressive, seal it off.Add data-obbo-isolate to an in-page layout to protect it from your site's CSS. The nine variables still pass through, but class-level styling no longer reaches inside. The dock layout attaches to the document and does not support shadow isolation.
06Build your own player
Skip the layouts entirely. createPlayer gives you episode data, playback and position; you write the markup. Listening still gets counted and expired audio links still recover, without either being your problem. The controls below are running the code underneath them.
Live example from Momento. These custom controls use the same playback engine as the ready-made layouts.
Loading the sample…
<script src="https://www.obbo.me/player/obboplayer.js" async></script>
<button id="play" type="button" disabled>Play</button>
<span id="time">0:00</span>
<script>
addEventListener('load', function () {
var player = Obbo.createPlayer({ widgetId: 'YOUR_WIDGET_ID' });
var button = document.getElementById('play');
var time = document.getElementById('time');
player.subscribe(function (state) {
button.disabled = state.status !== 'ready' || !state.playable;
button.textContent = state.playing ? 'Pause' : 'Play';
time.textContent = Obbo.formatTime(state.position);
});
button.onclick = function () { player.toggle(); };
});
</script>Keep the button disabled until status is ready.On iOS, permission to play audio is tied to the tap that asked for it. A tap the player cannot act on yet is spent for nothing, and your visitor has to tap again.
07The follow-along transcript
The transcript layout puts the whole episode on your page as text, and keeps it in step with the audio. Every Obbo episode is transcribed with sentence-level timings as part of production, so there is nothing for you to prepare.
- The line being spoken is highlighted and scrolls itself into view. Scroll it yourself and it stops following, with a button to jump back — it will not fight you for the scrollbar.
- Clicking any line plays from there, which makes the episode quotable: a reader can find the sentence they want and hear it said.
- The transcript is selectable text that assistive technology can read once loaded. Keep essential written content in your page's own HTML when search visibility matters; JavaScript-rendered text is not guaranteed to be indexed.
<script src="https://www.obbo.me/player/obboplayer.js" async></script>
<div data-obbo-player="YOUR_WIDGET_ID" data-obbo-ui="transcript"></div>Uses the transcript available for each episode.Finished transcripts are served by the embed API. If an episode has no transcript, playback still works and the player explains that no transcript is available. The playground includes a working read-along example.
08API reference
Obbo.createPlayer(options)
| Setting or identifier | What it does |
|---|---|
widgetId | Required. Your player ID. |
locale | Language for built-in labels. Defaults to en. |
language | Audio language to request. Set this to your page's language; episodes without that language use the original recording. |
apiBase | Public API origin. Defaults to https://app.obbo.me. Only change it when using a different Obbo server. |
autoAdvance | Continue to the next episode. Off by default. |
exclusive | Pause other players on the page when this one starts. On by default. |
The player
| Setting or identifier | What it does |
|---|---|
subscribe(fn) | Calls your function with the full state on every change, and once immediately. Returns a function that unsubscribes. |
getState() | The current state, if you would rather pull than subscribe. |
play(episodeId?) | Start playing, optionally switching episode first. |
pause() / toggle() | The obvious things. |
select(episodeId) | Switch episode without playing. Makes no request. |
seek(seconds) | Jump to a position, in seconds. |
setRate(rate) | Playback speed, from 0.75 to 2. |
next() / previous() | Move through the episode list. |
reload() | Fetch the episode list again. |
destroy() | Stop and detach. Call it when your component unmounts. |
media | The underlying audio element, for anything we have not thought of. |
The state
| Setting or identifier | What it does |
|---|---|
status | loading, ready, empty or error. |
error | Empty, or blocked when the browser refused without a tap, load_failed, or unavailable for a wrong or revoked ID. |
episodes | Every episode in the player: title, description, number, date and duration. |
episode | The selected one. Selected does not mean playing. |
podcast | Title, company name and cover image. |
playing | Whether audio is running right now. |
position / duration | Both in seconds. |
playable | Whether the selected episode can be played at all. |
Also on Obbo
| Setting or identifier | What it does |
|---|---|
Obbo.formatTime(s) | Turns 65 into 1:05, the same way the built-in layouts do. |
Obbo.ui.list(el, player, opts) | Mount a built-in layout yourself. Returns an object with destroy. |
Obbo.mount(root?) | Scan for player elements and mount them. Call it after adding markup dynamically. |
Obbo.unmountElement(el) | Tear one down. |
Obbo.version | Worth quoting in a bug report. |
09React, Vue and friends
Do not rely on the automatic scan. A framework that re-renders the container will wipe out the player's markup and leave its event listeners behind. Mount it yourself and tear it down on unmount, into an element you never render into.
import { useEffect, useRef, useState } from 'react';
let sdkPromise;
function loadPlayer() {
if (window.Obbo) return Promise.resolve(window.Obbo);
if (sdkPromise) return sdkPromise;
sdkPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://www.obbo.me/player/obboplayer.js';
script.async = true;
script.onload = () => {
if (window.Obbo) resolve(window.Obbo);
else {
sdkPromise = null;
script.remove();
reject(new Error('Player SDK unavailable'));
}
};
script.onerror = () => {
sdkPromise = null;
script.remove();
reject(new Error('Player SDK could not load'));
};
document.head.appendChild(script);
});
return sdkPromise;
}
export function Player({ widgetId, locale = 'en' }) {
const host = useRef(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
let cleanup;
loadPlayer().then((Obbo) => {
if (cancelled) return;
const player = Obbo.createPlayer({ widgetId, locale, language: locale });
const ui = Obbo.ui.list(host.current, player, { locale });
cleanup = () => { ui.destroy(); player.destroy(); };
}).catch((error) => {
if (!cancelled) {
console.error('Player failed to load', error);
setFailed(true);
}
});
return () => { cancelled = true; cleanup?.(); };
}, [widgetId, locale, attempt]);
return <>
<div ref={host} />
{failed && <p role="alert">
The player could not load.
<button type="button" onClick={() => {
setFailed(false);
setAttempt((value) => value + 1);
}}>Try again</button>
</p>}
</>;
}This example waits for the SDK, cancels late mounts, and destroys both the layout and player on cleanup. Pass your website's language as locale. Apply the same lifecycle to Vue's onMounted/onUnmounted or Svelte's onMount. Inline examples may need a CSP nonce or an external JavaScript file under your site's policy.
10Content-Security-Policy
If your site sends a Content-Security-Policy header, review these directives with your developer. Add the required hosts to your existing policy; these rows are not a complete policy to paste.
| Setting or identifier | What it does |
|---|---|
script-src https://www.obbo.me | To load the player. |
connect-src https://app.obbo.me | To fetch your episode list. |
media-src https://app.obbo.me https://*.amazonaws.com | To stream the audio itself. |
style-src | The SDK injects an inline stylesheet. Your policy must allow it; the current bundle does not provide a stylesheet nonce. Review this requirement with your developer rather than disabling CSP. |
img-src | Allow your podcast artwork host, commonly S3. Use your actual artwork URL to determine the narrowest host allowlist. |
media-src is the one people miss.Audio is served from storage on a different host than the API. Leave it out and the player looks completely healthy, right up until someone presses play.
11What gets counted
Listening on your site shows up in your Obbo analytics: how many times the player loaded, how many plays started, and how much was actually heard.
- Your visitors are never identified. No account, no login, and the player sets no cookie. Reports are attributed to you as the owner, not to whoever is listening.
- A page view is not a play. Nothing counts as a play until audio actually starts.
- Listening time is measured rather than assumed. Skipping ahead does not count as having heard what you skipped.
12When it does not work
Nothing appears at all
Check the element has data-obbo-player with your ID, and that the script tag is on the page. If you added the markup after the page loaded, call Obbo.mount() once afterwards.
It says the player is unavailable
The ID is wrong, or the player has been deactivated in your account. Both give the same message on purpose.
It says there are no episodes
The player is empty, or its episodes are still being produced. Only finished episodes are served.
It looks right but will not play
Almost always media-src missing from your Content-Security-Policy. Otherwise check state.error: blocked means the browser wanted a real tap first, which happens when playback is triggered by something other than a click.
It looks wrong
Your page's CSS is reaching into it, which is usually the point but occasionally is not. Add data-obbo-isolate to seal it off.
Still stuck?Send us the page address, your player ID and the value of Obbo.version at hello@obbo.me