16 min read
Custom tabs and JS Bridge
A common problem when adding a custom tab—an embedded webpage with a single designated URL available in the Android or iOS app—with externally sourced content is that this content is hosted on a separate webpage and cannot communicate directly with the Android or iOS app, as the app would see the custom tab as a “black box” whose boundary cannot be crossed. To address this, you can use the JavaScript SDK called JS Bridge, which lets you programmatically communicate on the client-side with the native app and the embedded webpage via JavaScript calls.
What you get by implementing JS Bridge:
- Shared login — users log in once, whether they log in via the native app or through the embedded webpage;
- Built-in no-access alerts — show the app’s own alert when a user lacks access to premium (subscription-locked) content, instead of building your own;
- Shared consent — user consent choices are synchronised between the native app and your webpage;
- In-app audio — audio on your webpage can play through the native app’s built-in audio player;
- Start page integration — your page content can appear as items on the native app’s start page as content for a start page component;
- Push notification topic management — users can adjust which of your push notification topics they are subscribed to;
Where do I start?
If this is your first time setting up JS Bridge, here is how you can get started:
- Notify Prenly Helpdesk so they can activate the integration on the Prenly side.
- Install the JS Bridge SDK on the embedded webpage that you will or have already added as a custom tab to your e-paper application via Prenly Workspace.
- Initialise the SDK on the webpage and call the API function(s) you need.
- Check that JS Bridge is active.
- Test your implementation before going live.
The rest of this guide walks through each of these steps in detail. If you get stuck on a technical term, check the technical reference at the end of the article for additional information.
How is it implemented?
1. Notify Prenly Helpdesk to activate the feature
Once the JS Bridge is added to your page, notify Prenly Helpdesk at hello@prenly.com. JS Bridge is a free-to-use additional feature that may require activation for your application. Until the feature is activated, the API will return undefined when called (see How do I know JS Bridge is active?).
2. Enable in custom tab in PWS…
JS Bridge is on default not active for the embedded webpage. You can enable it within the Menu builder in the application manager in Prenly Workspace.
3. Install the JS Bridge library
You can install the JS Bridge SDK JavaScript library either as an npm package or let the client fetch it directly from GitHub.
npm
Visit npm to find installation instructions, or simply run:
npm i prenly-js-bridge
GitHub
You do not need to use npm. You can import the SDK directly from GitHub if you prefer.
Visit GitHub. Import the SDK to your webpage via the example
import PrenlyAppSDK from "https://raw.githubusercontent.com/Textalk/prenly-js-bridge/refs/heads/main/dist/prenly-app-sdk.es.js";
The manual will assume you install the SDK via npm.
4. Initialise the SDK on the webpage
Import the library and create an instance of it. This gives you the api object, which is what you will use for every JS Bridge functionality described throughout the manual. Keep the reference to api and pass it around to call each desired function.
import PrenlyAppSDK from "prenly-js-bridge";
const { api } = new PrenlyAppSDK();
5. Use the JS Bridge functions
Once installed and activated, you can use the JavaScript functions provided by JS Bridge to interact with the native app. Each feature is backed by one or more api optionally called functions:
| Prenly feature | What it does | Functions you’ll use |
|---|---|---|
| Shared login | Users log in once, either via the native app or your webpage | api.login(), api.logout(), api.getUserJwt() |
| No-access alert | Shows the native app’s built-in alert when a user lacks access to premium content | api.showNoAccessAlert() |
| Consent sync | User consent choices are shared between the native app and your webpage | api.getUserConsent(), api.showUserConsentDialog() |
| In-app audio player | Audio on your page plays through the native app’s native player | api.playPauseAudio(), api.queueDequeueAudio(), api.getAudioStatus() |
| Start page web content component | Your content appears as items on the app’s start page | api.setComponentData() |
| Push notification topics | Users adjust which of your push topics they’re subscribed to (JS Bridge does not send push notifications itself — see note below) | api.getPushTopics(), api.subscribePushTopics(), api.unsubscribePushTopics() |
For a full list of available functions, events, and schemas, please refer to the Prenly App SDK - JS Bridge GitHub page. Treat this manual as a “getting started” version and use the README (in GitHub) as the complete reference.
Please note that JS Bridge only lets users manage their subscription to push topics — it cannot send a push notification itself. Sending a push notification, or creating a new push notification topic, is done within Prenly Workspace.
Every API function returns a Promise, so use await or .then() to get the result. Each function can reject the request, so it is strongly recommended to always wrap function calls in try/catch and check error.code (see request error codes):
try {
const userJwt = await api.getUserJwt();
} catch (error) {
if (api.isRequestError(error) && error.code === "rejected") {
// The user or the app declined the request.
}
}
Alongside these functions, JS Bridge also triggers custom events that your code can listen for. You can, for example, listen to userLogin to know when a user logs in in the native app:
api.on("userLogin", (userData) => {
// Runs automatically whenever the user logs in.
});
Each feature also has an expected order to call the functions in. The next sections will guide you through the expected function call order.
Login and logout flow
Always check whether the user is already logged in before deciding what to show or do. Avoid calling api.login() on a user who is already logged in.
- Call
api.getUserJwt()and verify and decode the responsejwtfield to read theis_logged_inclaim. - If
is_logged_inisfalsethen the user is currently not authenticated (logged in), show a “Log in” option that callsapi.login(). - If
is_logged_inistruethen the user is authenticated, show a “Log out” option that callsapi.logout()instead to allow the user to log out.
If you do not want to control the log in or log out flow, you can instead of relying on api.login() and api.logout listen to the events userLogin and userLogout events so your UI stays correct if the user logs in or out from elsewhere in the app while the custom tab showing your webpage is open.
If the user is logged in, you can check the decoded JWT prenly_package_slugs claim for the user’s subscriptions known to Prenly. If the required subscription to read your content is missing, call api.showNoAccessAlert() instead of writing your own “no access” message. This would keep the notice alerts consistent with the rest of the app.
Consent flow
- Call
api.getUserConsent()first to read the user’s existing choice (nullif no CMP is used). The result has two parts:prenly_purpose_grants, a fixed set of purposes (functional,analytical,marketing), andcmp_purpose_grants, which uses whatever purpose names your specific CMP defines. - Before doing anything that depends on a consent purpose, check the relevant field. For example, only load an analytics script if
prenly_purpose_grants.analyticalistrue. - If a required purpose has not been granted by the user, call
api.showUserConsentDialog()to let the user grant it, then re-check withapi.getUserConsent()(or wait for the event below) before proceeding.
const consent = await api.getUserConsent();
if (consent?.prenly_purpose_grants?.analytical) {
// Load your analytics script.
} else {
// Don't load it — or call api.showUserConsentDialog() first to ask.
}
You can listen to the custom event userConsentChange to react to the user’s consent change within the native app.
Audio playback flow
-
Call
api.getAudioStatus(AudioId)first (or track state via theaudioStatusChangeevent) so your UI shows the correct “Play” or “Pause” state before the user does anything.Note: you cannot ask the native app for available audio IDs; as such, you can listen to the custom event
audioStatusChangeto get the currently played audio. - Call
api.playPauseAudio(AudioData)to start playback, or pause the playback if it is already playing. - Check the current audio status and then add to the queue if the audio is currently not enqueued, or remove the audio from the queue, by calling
api.queueDequeueAudio(AudioData).
Listen for audioStatusChange to stay in sync, especially as audio playback can change outside your webpage and the app may be playing audio not available from your website.
Push notification topics flow
-
Always call
api.getPushTopics()first to get the list of valid topic IDs and each one’s current subscription status.Do not hardcode or guess an available push notification topic ID
- Use the IDs from that response when calling
api.subscribePushTopics()orapi.unsubscribePushTopics().
None of the push topic functions has its own dedicated error code; if one fails, you’ll get one of the general request error codes (rejected, not_supported, or feature_disabled) rather than something like get_push_topics_failed.
const { topics } = await api.getPushTopics();
const newsTopic = topics.find((t) => t.name === "News");
if (newsTopic.subscribed) {
await api.unsubscribePushTopics({ ids: [newsTopic.id] });
} else {
await api.subscribePushTopics({ ids: [newsTopic.id] });
}
5. If the web content component should be used
If you would like to list an article, or different content, on your webpage to also be listed on the app’s start page as a start page component, then…:
-
Create a new Web Content start page component in Prenly Workspace.
Copy the Unique identifier generated by the start page component.
- Configure the Source in the component settings and select the custom tab displaying your webpage.
- Make any additional configuration for your component, save it, and then publish the component to be available to your app’s start page.
- In your JS Bridge implementation, call
api.setComponentData()with that identifier and the items you want shown, for example:
await api.setComponentData({
id: "<the component's unique identifier>",
items: [
{
id: "article-1",
header: "Headline goes here",
subheader: "Short teaser text",
deeplink_url: "https://example.com/article-1",
image_url: "https://example.com/images/article-1.jpg",
},
],
});
Each item you pass in becomes a clickable tile on the start page, linking back into your custom tab content.
Please note, do not confuse the Web Content start page component with a Web API Web Component.
How do I know JS Bridge is active?
Check whether api is defined after instantiating the SDK:
const { api } = new PrenlyAppSDK();
if (api) {
// JS Bridge is active — you can call api functions.
} else {
// Not active yet. Either the library isn't loaded correctly,
// or it has not been activated for the custom tab in Prenly Workspace
}
If api is undefined, double-check that you’ve completed step 1 (notifying Prenly Helpdesk) and that you are viewing the webpage embedded in your app, not in a regular web browser tab.
How can I test JS Bridge?
There is no dedicated JS Bridge test environment. What testing looks like depends on what is available to you:
-
If you have a sandbox app, use that to try out your implementation before it goes live.
If you are unsure, check your available applications within Prenly Workspace or reach out to Prenly Helpdesk to request one.
-
If you don’t have a sandbox app, testing happens live: once JS Bridge is set up and activated for your custom tab (see the steps above), you test directly in the production app.
Note. A production app cannot enter a debug mode enabling you to use Safari to spy on your iOS app’s custom tab or Google Chrome to spy on your Android app’s custom tab.
- Confirm activation using the
apicheck described in How do I know JS Bridge is active?. This is the fastest way to catch a setup mistake before testing individual features. - Observe the visible result of each function. For example, call
api.login()and confirm the app’s own login state changes.
api.on("userLogin", (userData) => {
// This callback firing (and the app's UI updating) confirms the login flow worked.
});
If a request keeps failing or nothing changes, the feature may not be activated for your account yet — contact Prenly Helpdesk at hello@prenly.com, so they can help you activate JS Bridge.
Debug mode (console access)
Reading console logs or inspecting error.code values directly requires debug mode to be enabled for your custom tab. This is not something you can turn on yourself — it requires a special app variant that only Prenly Helpdesk can enable for both Android and iOS. If you need this level of access while developing, request it from Prenly Helpdesk at hello@prenly.com; don’t assume it’s already available.
Technical reference
The concepts below come up throughout this guide. They’re here so you don’t need to leave the page to look them up.
What is an SDK?
SDK, or Software Development Kit, is a set of software development tools available as a single package that allows integration with the targeted system.
What is npm?
npm is the standard package manager for JavaScript. “Installing a package” means downloading a piece of code (in this case, the JS Bridge library) and adding it to your project, usually by running a command like npm i prenly-js-bridge in your project’s terminal. The package then becomes available to import in your code.
What is a Promise?
A Promise represents a value that isn’t available yet but will be at some point. For example, the result of asking the app to log in the user. You typically handle it with await inside an async function, or with .then()/.catch():
// Using await (inside an async function):
const result = await api.getUserJwt();
// Using .then()/.catch():
api
.getUserJwt()
.then((result) => {
/* ... */
})
.catch((error) => {
/* ... */
});
If the promise is rejected (something went wrong), the code in your catch block runs and receives the error you can inspect — see request error codes below.
What is fetch()?
fetch() is the standard way to make an HTTP(s) request from JavaScript — for example, sending data from your page to your own HTTP server. It returns a Promise, so it’s used with await/.then() the same way as the api functions:
await fetch("https://your-backend.example.com/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jwt: userJwt.jwt }),
});
This is unrelated to JS Bridge itself; it is the regular way a webpage communicates with a server, used here because JS Bridge is client-side only and does not send anything to your HTTP server on its own.
What is an event listener?
Functions like api.getUserJwt() are things you ask for. Events are the opposite: they are custom notifications that JS Bridge triggers automatically whenever something happens based on the user’s interaction with the app, and your webpage could “react” to it. For example, whenever the user logs in or out.
You subscribe with api.on(eventType, handler), where handler is a function that runs every time that event fires:
api.on("userLogin", (userData) => {
// Runs automatically whenever the user logs in.
});
Stop listening with api.off(eventType, handler) (or api.off(eventType) to remove every handler for that event). See the GitHub README for the full list of available events.
What is a JWT?
A JWT (JSON Web Token) is a compact, signed string used to represent a user’s identity and claims about them. api.getUserJwt() and the login/logout functions return one. You must verify and decode it using Prenly’s certificate before reading anything inside it — do not treat the raw string as readable on its own. Once decoded, in addition to standard claims like given_name and email, the payload also includes Prenly-specific claims:
{
"given_name": "Jane",
"family_name": "Doe",
"email": "jane@example.com",
"customer_number": "12345",
"prenly_package_slugs": ["premium"],
"is_logged_in": true
}
To learn more about JWTs in general (not specific to Prenly), see the introduction on jwt.io. While developing, you can also paste a JWT into the JWT debugger on jwt.io to inspect its header and payload (avoid pasting a real user’s JWT there, since the payload contains their personal information (name, email, etc.); use it with test data instead).
Note. The available claims depend on the login system the app is running. The only guaranteed claim is is_logged_in.
If your HTTP server also needs to know about the logged-in user (for example, to create a session), send it the relevant information yourself, e.g. with a fetch() request to your own endpoint:
await fetch("https://your-backend.example.com/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jwt: userJwt.jwt }),
});
How your HTTP server handles that request—verifying it again, creating a session, or anything else—is your own implementation and falls outside the scope of this manual.
This certificate is generated and provided by Prenly — you cannot create or supply your own. Prenly will deliver it to you if your setup needs one, or you can request it from Prenly Helpdesk at hello@prenly.com. A setup using any other certificate will not work.
What is a CMP, and what does “consent” mean here?
A CMP (Consent Management Platform) is the system that asks users for their privacy consent (e.g. for analytics or marketing cookies) and stores their choice. api.getUserConsent() lets your custom tab read the consent the user already gave inside the Prenly app, so you don’t have to ask them again separately.
Request error codes
When a Promise from an api function is rejected, the error object has a code you can check. Some codes are general and can come from any function; others are specific to one flow. A function without a dedicated error code (e.g. getPushTopics(), getUserConsent()) will only ever produce a general one:
| Code | Applies to | Meaning |
|---|---|---|
rejected |
Any function | The request was declined by the user or the app. |
not_supported |
Any function | The requested feature isn’t supported by the app (e.g. an old app version). |
feature_disabled |
Any function | The feature hasn’t been activated for your account — see step 3. |
login_failed |
api.login() |
The login flow failed. |
logout_failed |
api.logout() |
The logout flow failed. |
play_pause_audio_failed |
api.playPauseAudio() |
Playing or pausing audio failed. |
queue_dequeue_audio_failed |
api.queueDequeueAudio() |
Queuing or dequeuing audio failed. |
set_component_data_failed |
api.setComponentData() |
Sending data to the start page component failed. |
For the complete, always-up-to-date list of functions, events, and data models, see the GitHub README.