Add scripture to your website
Fellowverse embeds bring listening, reading and search to your own pages. A church can put this Sunday's readings on its home page, a study site can play a passage while its words light up, and a ministry can let visitors find a passage by typing or speaking.
Each embed runs in its own frame served by Fellowverse. It plays the same reviewed recordings and text as the app, takes your colours and theme, and never reads your page.
Add your site
In the developer console, open Sites and add your website with its exact origins, such as https://www.example.org. Start with a test site, which also accepts your local development address while you build.
Paste an embed
Copy the snippet for your site from the console and paste it into your page.
Add a token URL for search and voice
Search, voice and private playlists draw on your account, so your server issues a short-lived token for them. Public playlists and readings need none.
The embeds
<script type="module" src="https://embed.fellowverse.app/embed/v1/sdk.js"></script>
<fv-playlist site="site_YOUR_ID" playlist="sunday-readings"></fv-playlist>
<fv-read-along site="site_YOUR_ID" reference="Psalm 23"></fv-read-along>
<fv-search site="site_YOUR_ID" get-token-url="/fellowverse/token"></fv-search>
| Element | Shows | Main attributes |
|---|---|---|
fv-playlist |
A public playlist, played in order with its pacing and background sound. | playlist: the playlist's public link name. |
fv-read-along |
A reading, with each verse highlighted as it is read. | reference: a chapter or verses, such as Psalm 23 or John 3:16-17. mode: listen, or read-aloud to practise reading a verse aloud. |
fv-search |
A search box for references, words or a feeling, typed or spoken. | get-token-url: your token URL. |
Every embed also takes:
| Attribute | Values |
|---|---|
theme |
paper, night, or auto to follow the visitor's light or dark setting. |
layout |
expanded, or compact for a narrow column. |
accent |
A six-digit colour such as #b5620f, used for buttons and the current verse. |
font |
system, or serif for the scripture text. |
When several embeds share a page, starting one pauses the others. A search result plays in a read-along embed on the same page, or in a compact one the search adds below itself.
Your token URL
Embeds that search, listen to a voice or open a private playlist ask your page for a token. The page posts the request to your get-token-url, on your own origin, with your visitor's cookies. Your server decides whether to allow it, then asks Fellowverse for a session with its API key.
The request your token URL receives:
{
"site": "site_YOUR_ID",
"surface": "hosted",
"instance": "cmp_6f1e…",
"origin": "https://www.example.org",
"scopes": ["search", "voice"],
"playlists": []
}
A token URL in Node 18 or later, with no dependencies. This example allows search and voice only. Add your visitor authentication or anonymous rate limits before exposing it, and serve it behind your HTTPS proxy:
import { createServer } from 'node:http';
const KEY = process.env.FELLOWVERSE_KEY; // fv_live_... or fv_test_...
const SITE = 'site_YOUR_ID';
const ORIGIN = 'https://www.example.org';
const ALLOWED = new Set(['search', 'voice']);
createServer(async (req, res) => {
const send = (status, body) => {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
if (req.method !== 'POST' || req.url !== '/fellowverse/token') {
send(404, { error: 'not_found' });
return;
}
// Install your own visitor authentication or anonymous rate limit here.
// For signed-in visitors, also check your CSRF token.
if (req.headers.origin !== ORIGIN) {
send(403, { error: 'origin_not_allowed' });
return;
}
try {
const chunks = [];
let bytes = 0;
for await (const chunk of req) {
bytes += chunk.length;
if (bytes > 4096) {
send(413, { error: 'request_too_large' });
return;
}
chunks.push(chunk);
}
let asked;
try {
asked = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
send(400, { error: 'invalid_json' });
return;
}
if (!asked || !Array.isArray(asked.scopes) || !asked.scopes.length ||
!asked.scopes.every((s) => ALLOWED.has(s)) ||
typeof asked.instance !== 'string' ||
!/^[A-Za-z0-9_-]{6,100}$/.test(asked.instance) ||
(asked.renew !== undefined &&
(typeof asked.renew !== 'string' || !/^[A-Za-z0-9_-]{6,100}$/.test(asked.renew)))) {
send(400, { error: 'invalid_request' });
return;
}
const upstream = await fetch('https://play.fellowverse.app/v1/embed-sessions', {
method: 'POST',
signal: AbortSignal.timeout(20000),
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
site: SITE,
origin: ORIGIN,
surface: 'hosted',
instance: asked.instance,
scopes: asked.scopes,
...(asked.renew ? { renew: asked.renew } : {}),
}),
});
const session = await upstream.json();
if (!upstream.ok) {
// Preserve Fellowverse's error response and retry guidance.
const retry = upstream.headers.get('retry-after');
if (retry) res.setHeader('retry-after', retry);
send(upstream.status, session);
return;
}
send(201, { id: session.id, token: session.token, expiresAt: session.expiresAt });
} catch {
send(502, { error: 'session_unavailable' });
}
}).listen(8080);
Take the site and origin from your own configuration, and pass on only the scopes and playlists you mean to allow. Before a token runs out, the embed asks again with renew, and the new session continues the same allowance. See Start a session for an embed for every field, including lower per-visitor limits.
Your API key stays on your server. The page only ever holds a short-lived token for one embed, kept in memory.
Private playlists
A private playlist can appear on your site when its owner allows it. Send the owner your site's ID from the console. In the app, they open Share, choose Show on a website, look up your site and allow it. They see your account name and your website before they do, and they can stop it at any time.
<fv-playlist site="site_YOUR_ID" private-playlist="pl_THEIR_ID"
get-token-url="/fellowverse/token"></fv-playlist>
For private playlists, adapt the token endpoint to allow the playlist scope
and send a server-configured allowlist of playlist IDs. Do not forward arbitrary
IDs from the browser. The search-only example above does not grant this access.
For Read aloud, use the read-aloud scope and no playlist IDs unless you also
need private playlist access.
Control embeds from your own code
Mount an embed from JavaScript to control it and hear what it is doing:
import { mount } from 'https://embed.fellowverse.app/embed/v1/sdk.js';
const reading = mount(document.querySelector('#reading'), {
embedOrigin: 'https://embed.fellowverse.app',
site: 'site_YOUR_ID',
component: 'read-along',
reference: 'Psalm 23',
theme: 'paper',
onEvent(name, payload) {
// ready, playbackchange, itemchange, ended and error.
},
});
await reading.ready;
await reading.command('play');
// Also pause, next, previous, and seek with { positionMs }.
reading.destroy();
Pass getToken(request) instead of a token URL when your page already talks to your server another way. It receives the same request and returns the same { id, token, expiresAt }.
If your page already plays the audio in a player of its own, give mount a playerAdapter with your player's state, play, pause and seek. The read-along embed then follows your player's clock and highlights each verse in time, without playing anything itself.
Usage
Public playlists and readings are free to show. Search is metered per request, and voice search and read aloud per second of audio, on the account that owns the site. A test site draws on your free test allowance. Each site's daily ceilings, and its loads, plays and searches, are in the console under Sites.
Need the contract? Download OpenAPI. Manage keys and account access in the developer console.