How it works
The library connects your web page to users' haptic devices (via the FeelConnect mobile app) over a real-time Socket.IO connection to the Feel Exchange Center (FEC). Once connected, it synchronizes video playback with haptic subtitle events so that devices react in real time as a video plays.
The typical flow:
- User opens your page
- Your page shows a QR code
- User scans it with the FeelConnect app. Their device is now paired
- User presses play on the video
- The library sends haptic cues to the device in sync with the video
Prerequisites
You need two secret keys from FeelTechnology. Keep these on your server. Never expose them in client-side code.
| Key | Used for |
|---|---|
| Partner Key | Identifying your application to the Feel Apps API |
| Subtitles App Key | Accessing the Feel Subtitles database for a specific video catalog |
Contact info@feeltechnology.com to obtain your keys.
Integration modes
| Mode | Function | Use case |
|---|---|---|
| Full | init() |
Web app with device control + haptic subtitle sync |
| Slider | initSlider() |
Direct intensity control only, no subtitle support |
Installation
npm install @feelrobotics/feel-apps-api
Step 1: Fetch tokens on your server
Your backend must fetch four values before the client can initialize the library. All calls use your secret keys, so they must happen server-side.
1a. Feel Subtitles token
// Node.js example
const res = await fetch(`https://api.pibds.com/api/v2/app/${SUBS_APP_KEY}/token`)
const { apptoken: feelSubsToken } = await res.json()
1b. Partner token
const res = await fetch(`https://api.feel-app.com/api/v1/partner/${PARTNER_KEY}/token?user=${userId}`)
const { partner_token: partnerToken } = await res.json()
Always include user when requesting this token to
scope it to the current user session.
1c. FEC token and room name (parallel)
Using the partnerToken from 1b:
const [fecRes, roomRes] = await Promise.all([
fetch(`https://api.feel-app.com/jslib-api/v1/user/${userId}/fec-token?partner_token=${partnerToken}`),
fetch(`https://api.feel-app.com/jslib-api/v1/user/room?partner_token=${partnerToken}`),
])
const { fec_token: fecToken } = await fecRes.json()
const { drs_room: { drs_id: roomName } } = await roomRes.json()
1d. QR code auth token
const res = await fetch(`https://api.feel-app.com/api/v1/user/${userId}/auth?partner_token=${partnerToken}`)
const { auth_token: authToken } = await res.json()
Summary of values to pass to the client
| Value | Description |
|---|---|
feelSubsToken |
Authorizes subtitle requests |
fecToken |
JWT for the Socket.IO connection to FEC |
roomName |
DRS room ID that routes haptic messages to the user's device |
userId |
The same user ID you used when fetching the tokens |
authToken |
Used to generate the QR code shown to the user |
partnerToken stays on the server. Do not send it to
the client.
Step 2: Provide a user ID
userId is a stable string that identifies the current
user in your system. It can be up to 1000 characters and must use
URL-safe characters.
How you produce this value depends entirely on your platform. Use whatever identifier you already have for the user (e.g. your database user ID, a hashed session ID, or any unique stable key). The library treats it as an opaque string.
Step 3: Initialize the library
Call init once, after the user chooses to play
interactively. Do not call it on page load for all visitors; only
call it when the user opts in.
import { init, destroy, apps, subs } from '@feelrobotics/feel-apps-api'
await init(feelSubsToken, fecToken, userId, roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
onTokenError: (err) => {
console.error('Token refresh failed:', err)
},
})
The returned promise resolves when the FEC socket connects. Device
pairing happens later, when the user scans the QR code. The FEC
token has a 24 h TTL. fetchToken is called
automatically every 12 h to keep the connection alive.
Slider mode (device control only, no subtitles)
Use initSlider when you want to send haptic intensity
directly without subtitle sync.
import { initSlider } from '@feelrobotics/feel-apps-api'
await initSlider(fecToken, userId, roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
})
// Send intensity directly (0–100)
apps.playSubtitle(percent, 0, [])
Step 4: Connect the FeelConnect app
There are two approaches depending on where the user is viewing your page:
Desktop browsers: QR code
On desktop, display a QR code that the user scans with the
FeelConnect app on their phone. The QR code value is the
authToken returned in Step 1d.
You can generate the QR code using any method that fits your
platform: a client-side JavaScript library, a server-side image
generator, or a QR API. The only requirement is that the QR code
encodes the authToken string directly.
Mobile browsers: deep link button
On a mobile browser the user cannot scan a QR code with the same device. Instead, show a Connect button that opens the FeelConnect app directly via a deep link.
const connectUrl = apps.getMobileAppLaunchUrl(authToken)
// Returns: "feelapp://authorize?token=..."
// Render a button that navigates to the deep link
document.getElementById('connect-btn').href = connectUrl
<a id="connect-btn" href="#">Connect with FeelConnect</a>
This requires the FeelConnect app to be installed on the device. Consider showing a link to the App Store / Google Play as a fallback.
React to device connection (both approaches)
apps.status.subscribe(({ online, devices }) => {
if (online) {
document.getElementById('connect-section').hidden = true
document.getElementById('play-button').disabled = false
}
})
Step 5: Load subtitles and wire video events
// Load subtitle data for the video (waits for device connection if none yet)
await subs.load(videoId, subtitlesId, userId)
// Wire up the HTML5 video element
video.addEventListener('play', () => subs.play(video.currentTime))
video.addEventListener('timeupdate', () => subs.timeupdate(video.currentTime))
video.addEventListener('pause', () => subs.stop())
To cancel a load in progress (e.g. user navigates away):
const controller = new AbortController()
await subs.load(videoId, subtitlesId, userId, '', { signal: controller.signal })
// Cancel:
controller.abort()
Step 6: Clean up
Call destroy when the user leaves the page or the
player is torn down.
destroy()
Full example
import { init, destroy, apps, subs } from '@feelrobotics/feel-apps-api'
// 1. User ID (persisted in localStorage)
const userId = getUserId()
// 2. Fetch tokens from your backend (which fetches from Feel API server-side)
const tokens = await fetch(`/api/feel/tokens?userId=${userId}&videoId=${videoId}`)
.then(r => r.json())
// tokens: { feelSubsToken, fecToken, roomName, authToken }
// 3. Show QR code
QRCode.toCanvas(document.getElementById('qr'), tokens.authToken)
// 4. Initialize library
await init(tokens.feelSubsToken, tokens.fecToken, userId, tokens.roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
})
// 5. React to device connection
apps.status.subscribe(({ online }) => {
document.getElementById('qr-container').hidden = online
})
// 6. Load subtitles
await subs.load(videoId, subtitlesId, userId)
// 7. Wire video events
video.addEventListener('play', () => subs.play(video.currentTime))
video.addEventListener('timeupdate', () => subs.timeupdate(video.currentTime))
video.addEventListener('pause', () => subs.stop())
// 8. Clean up on unload
window.addEventListener('beforeunload', destroy)
Security guidelines
- Never expose your Partner Key or Subtitles App Key in client-side code. All calls using these keys must go through your backend.
-
Always include
userwhen requesting a partner token . A token without a user ID grants access to any user's connection data. -
Generate QR codes client-side using a JavaScript library. Sending
authTokento a third-party QR rendering service exposes it to that service.
Examples
Subtitles player
import { init, destroy, apps, subs } from '@feelrobotics/feel-apps-api'
function getUserId() {
let id = localStorage.getItem('feel_user_id')
if (!id) {
id = String(Math.floor(Math.random() * 9e9) + 1e9)
localStorage.setItem('feel_user_id', id)
}
return id
}
const userId = getUserId()
// Fetch tokens from your backend
const tokens = await fetch(`/api/feel/tokens?userId=${userId}`).then(r => r.json())
// Show QR code while SDK initialises
showQRCode(tokens.authToken)
await init(tokens.feelSubsToken, tokens.fecToken, userId, tokens.roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
onTokenError: (err) => console.error('Token refresh failed:', err),
})
apps.status.subscribe(({ online }) => {
if (online) { hideQRCode(); showPlayer() }
})
await subs.load(videoId, subtitleId, userId)
video.addEventListener('play', () => subs.play(video.currentTime))
video.addEventListener('timeupdate', () => subs.timeupdate(video.currentTime))
video.addEventListener('pause', () => subs.stop())
window.addEventListener('beforeunload', destroy)
Slider (direct intensity control)
import { initSlider, apps } from '@feelrobotics/feel-apps-api'
const userId = getUserId()
const tokens = await fetch(`/api/feel/tokens?userId=${userId}`).then(r => r.json())
showQRCode(tokens.authToken)
await initSlider(tokens.fecToken, userId, tokens.roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
})
apps.status.subscribe(({ online }) => {
sliderEl.disabled = !online
})
// slider value 0–4 → percent 0–100
sliderEl.addEventListener('input', () => {
const percent = (sliderEl.value / 4) * 100
apps.playSubtitle(percent, 0, [])
})
Migrating from v1 (feel-apps-api)
What changed at a glance
| Area | v1 | v2 |
|---|---|---|
| Package name | feel-apps-api |
@feelrobotics/feel-apps-api |
| Language | JavaScript (CommonJS) | TypeScript (ESM + CJS bundles) |
| Real-time transport | PubNub SDK | Socket.IO (no extra SDK needed) |
init signature |
init(subsToken, appsToken, userId) |
init(subsToken, fecToken, userId, roomName) |
initSlider signature |
initSlider(appsToken, userId) |
initSlider(fecToken, userId, roomName) |
initMobile |
Available | Removed. Use initSlider |
| Token refresh | Manual reconnect | Built-in (every 12 h) |
| Billing setup | subs.setPubNub(pubnub) |
Automatic |
| Subtitle events | subs.events.on('subtitle', cb) |
subs.onSubtitleEvent(cb) |
apps.data.connect |
Required | Removed. Automatic after init |
| Mobile app URL | getMobileAppLauchUrl() (typo) |
getMobileAppLaunchUrl() |
subs.load cancellation |
Not supported | AbortSignal via options |
| TypeScript types | None | Exported interfaces |
Installation
Before: loaded via script tag from the Feel CDN
<script src="https://api.feel-app.com/static/feeljs/1.2.1/feel.min.js"></script>
Exposed a global $feel object.
Now: npm package with ES module imports
npm install @feelrobotics/feel-apps-api
import { init, destroy, apps, subs } from '@feelrobotics/feel-apps-api'
New tokens required
v2 replaces feelAppsToken (the partner token) with two
new values that your backend must fetch:
| New value | How to get it |
|---|---|
fecToken |
GET https://api.feel-app.com/jslib-api/v1/user/{userId}/fec-token?partner_token={partnerToken}
|
roomName |
GET https://api.feel-app.com/jslib-api/v1/user/room?partner_token={partnerToken}
→ drs_room.drs_id
|
Fetch partnerToken the same way as before, then use it
to fetch these two new values. Do not pass
partnerToken to the library.
Initialization
Before
$feel.init(feelSubsToken, feelAppsToken, userId)
Now
await init(feelSubsToken, fecToken, userId, roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
})
initMobile removed
Before
$feel.initMobile(feelSubsToken)
Now: use initSlider
await initSlider(fecToken, userId, roomName, {
fetchToken: () =>
fetch(`/api/feel/fec-token?userId=${userId}`)
.then(r => r.json())
.then(d => d.fec_token),
})
PubNub: no longer needed
Remove the pubnub package and any PubNub initialization
code. All real-time messaging now goes through Socket.IO internally.
Before
import PubNub from 'pubnub'
const pubnub = new PubNub({ publishKey: PUB_KEY, subscribeKey: SUB_KEY })
$feel.subs.setPubNub(pubnub)
$feel.apps.data.connect(pubnub, drsRoomName)
Now
Delete all of this. Billing and room connection are managed automatically.
Subtitle events
Before
$feel.subs.events.on('subtitle', (percent) => setIntensity(percent))
Now
subs.onSubtitleEvent((percent) => setIntensity(percent))
// To remove the listener:
subs.offSubtitleEvent(onHaptic)
Mobile app launch URL: typo fixed
Before
$feel.apps.getMobileAppLauchUrl(authToken)
// ^ missing 'n'
Now
apps.getMobileAppLaunchUrl(authToken)
Removed APIs
| v1 API | Replacement |
|---|---|
initMobile(feelSubsToken) |
initSlider(fecToken, userId, roomName) |
apps.data.connect(pubnub, roomName) |
Automatic after init() |
apps.data.disconnect() |
destroy() |
subs.setPubNub(pubnub) |
Removed. Billing is automatic |
subs.events (EventEmitter) |
subs.onSubtitleEvent(cb) /
subs.offSubtitleEvent(cb)
|
apps.getMobileAppLauchUrl() |
apps.getMobileAppLaunchUrl() (typo fixed)
|
Room API
The Room API is a server-side REST API for managing user sessions in
haptic rooms. All calls must be made from your backend using your
partner_token.
Add user to a room
POST /api/v1/room/<room-id>/users?partner_token=<partner-token>
Note: A user cannot be added to more than one room at the same time. If the user has already joined another room, this call will return an error.
URL parameters
| Parameter | Description |
|---|---|
partner_token |
Partner token returned by /api/v1/partner/<partner-key>/token |
room-id |
Room ID to add the user to. If the room does not exist, it will be created. |
POST body parameters
| Parameter | Description |
|---|---|
user |
User ID on the partner website |
read |
0 or 1 — whether the user can read data from the room |
write |
0 or 1 — whether the user can write data to the room |
Response
Returns {} on success.
Errors
| Status | Meaning |
|---|---|
404 NOT FOUND | User is not known to the FeelApp system. |
401 NOT AUTHORIZED | Partner token is invalid or not authorized for this user. |
409 CONFLICT | User is already in another room. Remove the user from that room first. |
Remove user from a room
DELETE /api/v1/room/<room-id>/users/<user-id>?partner_token=<partner-token>
URL parameters
| Parameter | Description |
|---|---|
partner_token |
Partner token returned by /api/v1/partner/<partner-key>/token |
user-id |
User ID on the partner website |
Response
Returns {} on success.
Errors
| Status | Meaning |
|---|---|
404 NOT FOUND | User is not known to the FeelApp system or is not in the room. |
401 NOT AUTHORIZED | Partner token is invalid or not authorized for this user. |
Supported device models
GET /api/v1/device_models
Returns the list of all supported Bluetooth device models. No authorization is required to access this endpoint.
Additional Information
If your developers need any help, please contact us at info@feeltechnology.com.