FeelTechnology

Feel Apps API: Developer Guide

@feelrobotics/feel-apps-api

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:

  1. User opens your page
  2. Your page shows a QR code
  3. User scans it with the FeelConnect app. Their device is now paired
  4. User presses play on the video
  5. 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

View on npm →


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 user when 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 authToken to 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

View on npm →

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
ParameterDescription
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
ParameterDescription
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
StatusMeaning
404 NOT FOUNDUser is not known to the FeelApp system.
401 NOT AUTHORIZEDPartner token is invalid or not authorized for this user.
409 CONFLICTUser 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
ParameterDescription
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
StatusMeaning
404 NOT FOUNDUser is not known to the FeelApp system or is not in the room.
401 NOT AUTHORIZEDPartner 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.