---
title: Add scripture to your website
description: Put a playlist, a read-along passage or scripture search on any page with a few lines of HTML.
---

# 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.

:::steps
### Add your site
In the [developer console](https://play.fellowverse.app/developers), 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

```html
<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:

```json
{
  "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:

```js
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](/api/embed-sessions) for every field, including lower per-visitor limits.

:::tip
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.

```html
<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:

```js
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**.
