Skip to main content

Build an app

This guide walks you through building your first Scompler app from scratch. By the end, you'll have a working app that installs, loads inside Scompler, and communicates with the Scompler API.

What you'll build: A Hello World app with a Node.js backend and a React frontend.

What you'll need:

  • Node.js 20.6+
  • npm 9+
  • cloudflared installed
  • Access to Scompler Developer Dashboard

Step 1: Register your app

Before writing any code, register your app in the Developer Dashboard to get the credentials you'll need throughout this guide.

Go to Settings → Developers to open the Scompler Developer Dashboard, then navigate to Apps → Create app.

Fill in the basic details:

  • Name — e.g. My First App
  • Handle — a unique identifier for your app, e.g. my-first-app
  • App URL — enter a placeholder for now, e.g. https://example.com. You'll update this in Step 3.
  • Callback URLhttps://example.com/callback
  • Webhook URLhttps://example.com/webhooks

Under Permissions, select Articles.

Click Create.

Copy the App ID and App Secret from the app detail page — you'll need them in the next step.

Step 2: Create a minimal server

Set up a backend and a simple React frontend that you can open in the browser.

Project structure

mkdir my-first-app && cd my-first-app
mkdir -p backend/src frontend/src
my-first-app/
├── backend/
│ ├── src/
│ │ └── index.js
│ ├── package.json
│ └── .env
└── frontend/
├── src/
│ ├── main.jsx
│ └── App.jsx
├── index.html
├── vite.config.js
├── package.json
└── .env
note

The full project structure will grow as you work through each step. db.js, store.js, and setup.js are added in Step 4.

Backend

Install dependencies:

cd backend
npm init -y
npm install express jsonwebtoken better-sqlite3

Add "type": "module" to backend/package.json to enable ES modules:

{
"type": "module",
"scripts": {
"start": "node --env-file=.env src/index.js"
}
}

Create backend/.env:

APP_SECRET=your_app_secret
APP_ID=your_app_id
# Use the URL that matches the account's plan:
# App plan: https://public-api.app.scompler.com/graphql
# Pro plan: https://public-api.pro.scompler.com/graphql
API_URL=https://public-api.app.scompler.com/graphql
PORT=3000

Create backend/src/index.js:

import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

app.use(express.json())

// Serve the built frontend
app.use(express.static(join(__dirname, '../../frontend/dist'), { index: false }))

app.get('/{*path}', (req, res) => {
res.sendFile(join(__dirname, '../../frontend/dist/index.html'))
})

app.listen(process.env.PORT, () => {
console.log(`Backend running at http://localhost:${process.env.PORT}`)
})

Frontend

Install dependencies:

cd ../frontend
npm init -y
npm install react react-dom
npm install --save-dev vite @vitejs/plugin-react

Create frontend/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="app-id" content="%VITE_APP_ID%" />
<title>My First App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

Create frontend/src/main.jsx:

import ReactDOM from 'react-dom/client'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')).render(<App />)

Create frontend/src/App.jsx:

export default function App() {
return <h1>Hello from my first app!</h1>
}

Create frontend/vite.config.js:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
plugins: [react()],
})

Add build scripts to frontend/package.json:

{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}

Run it

Build the frontend and start the backend:

cd frontend && npm run build
cd ../backend && npm start

Open http://localhost:3000 in your browser. You should see Hello from my first app!

Step 3: Start a HTTPS tunnel

Scompler requires a publicly accessible HTTPS URL to load your app in an iframe and deliver callback and webhook requests. Use cloudflared to expose your local server.

In a new terminal window:

cloudflared tunnel --url http://localhost:3000

cloudflared will output a URL like:

https://random-words-here.trycloudflare.com

Copy this URL — you'll use it in the next step.

note

cloudflared generates a new URL on every restart. You'll need to update your app URLs in the Developer Dashboard each time. For a stable URL, set up a named tunnel with a custom domain.

Update your app URLs

Go to your app in the Developer Dashboard and update the following fields:

SettingValue
App URLhttps://<your-tunnel-url>/
Callback URLhttps://<your-tunnel-url>/callback
Webhook URLhttps://<your-tunnel-url>/webhooks

Click Save.

Step 4: Handle installation

When a user installs your app, Scompler sends a POST request to your callback URL containing an access_token. Your app must verify the request signature and store the token for later use.

In this guide, tokens are stored in a SQLite database — a simple file-based database that requires no separate server to run. The database file is created automatically in your project directory on first run.

Database

Create backend/src/db.js:

import Database from 'better-sqlite3'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'

const __dirname = dirname(fileURLToPath(import.meta.url))
const db = new Database(join(__dirname, '../../database.db'))

export default db

Setup

Create backend/src/setup.js:

import db from './db.js'

db.exec(`
CREATE TABLE IF NOT EXISTS tokens (
account_id INTEGER PRIMARY KEY,
access_token TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
`)

console.log('✓ Database ready')

Add a setup script to backend/package.json:

{
"type": "module",
"scripts": {
"setup": "node --env-file=.env src/setup.js",
"start": "node --env-file=.env src/index.js"
}
}

Run it once before starting the server for the first time:

npm run setup

You should see:

✓ Database ready

Token store

Create backend/src/store.js:

import db from './db.js'

export function saveToken({ account_id, access_token, expires_at }) {
db.prepare(
`
INSERT INTO tokens (account_id, access_token, expires_at)
VALUES (@account_id, @access_token, @expires_at)
ON CONFLICT (account_id) DO UPDATE SET
access_token = excluded.access_token,
expires_at = excluded.expires_at
`,
).run({ account_id, access_token, expires_at })
}

export function getToken(account_id) {
return db.prepare('SELECT * FROM tokens WHERE account_id = ?').get(account_id) ?? null
}

export function deleteToken(account_id) {
db.prepare('DELETE FROM tokens WHERE account_id = ?').run(account_id)
}

Verify the callback signature

Scompler signs every callback request with your app secret. Compute HMAC SHA256 of the raw request body and compare it to the X-Signature header.

Update backend/src/index.js:

import express from 'express'
import crypto from 'crypto'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { saveToken } from './store.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

// The callback route must use express.raw() to access the raw request body.
// Registering it before express.json() ensures the body isn't parsed first.
app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}

const { account_id, access_token, expires_at } = JSON.parse(req.body)
saveToken({ account_id, access_token, expires_at })
console.log(`✓ App installed for account ${account_id}`)
res.sendStatus(200)
})

app.use(express.json())
app.use(express.static(join(__dirname, '../../frontend/dist'), { index: false }))

app.get('/{*path}', (req, res) => {
res.sendFile(join(__dirname, '../../frontend/dist/index.html'))
})

app.listen(process.env.PORT, () => {
console.log(`Backend running at http://localhost:${process.env.PORT}`)
})

function verifySignature(rawBody, signature) {
const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature))
}
warning

Always use crypto.timingSafeEqual for signature comparisons to prevent timing attacks.

Test it

Restart the backend, then go to the Developer Dashboard and install your app on a test account. Check your terminal — you should see:

✓ App installed for account 123

Step 5: Verify the launch request

When a user opens your app, Scompler loads it in an iframe and passes signed query parameters in the URL. Verify the hmac parameter before serving the frontend.

HMAC calculation: All query parameters except hmac are sorted alphabetically, serialized as key=value&key=value, then signed with your app secret using HMAC SHA256.

Update backend/src/index.js — replace the GET / route:

import express from 'express'
import crypto from 'crypto'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { saveToken } from './store.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}

const { account_id, access_token, expires_at } = JSON.parse(req.body)
saveToken({ account_id, access_token, expires_at })
console.log(`✓ App installed for account ${account_id}`)
res.sendStatus(200)
})

app.use(express.json())
app.use(express.static(join(__dirname, '../../frontend/dist'), { index: false }))

// Catch-all — serves the frontend for all non-API routes.
// Must be registered after all API routes.
app.get('/{*path}', (req, res) => {
if (!verifyLaunchHmac(req.query)) {
return res.status(403).send('Forbidden')
}
res.sendFile(join(__dirname, '../../frontend/dist/index.html'))
})

app.listen(process.env.PORT, () => {
console.log(`Backend running at http://localhost:${process.env.PORT}`)
})

function verifySignature(rawBody, signature) {
const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature))
}

function verifyLaunchHmac(query) {
const { hmac, ...params } = query
if (!hmac) return false

const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')

const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(message).digest('hex')

try {
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac))
} catch {
return false
}
}

Restart the backend and open your app from inside Scompler.

Step 6: Use App Bridge

App Bridge is a JavaScript library that handles communication between your app and the Scompler platform. Install it in the frontend, initialize it on load, and use it to get a session token for authenticating requests to your backend.

Install App Bridge

cd frontend
npm install @scompler/app-bridge

Configure the frontend environment

Create frontend/.env:

VITE_APP_ID=your_app_id

Initialize App Bridge

Update frontend/src/App.jsx:

import { createAppBridge } from '@scompler/app-bridge'

const app = createAppBridge({ appId: import.meta.env.VITE_APP_ID })

export default function App() {
return <h1>Hello from my first app!</h1>
}

host and language are automatically read from the iframe URL query params — no manual parsing needed.

Rebuild the frontend and reopen your app inside Scompler. App Bridge will initialize and establish a connection with the platform.

Step 7: App Frontend → App Backend request

Use App Bridge to get a session token and send it to your backend. The backend verifies the token and returns account data. Session tokens are short-lived JWTs — always fetch a fresh one before each request.

Add the session token endpoint to the backend

Session tokens are signed with your app secret and include the account ID and user ID in the payload. Verify the token on every request using the jsonwebtoken library.

Update backend/src/index.js — add the session token middleware and the /api/me route:

import express from 'express'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { saveToken } from './store.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}

const { account_id, access_token, expires_at } = JSON.parse(req.body)
saveToken({ account_id, access_token, expires_at })
console.log(`✓ App installed for account ${account_id}`)
res.sendStatus(200)
})

app.use(express.json())
app.use(express.static(join(__dirname, '../../frontend/dist'), { index: false }))

app.get('/api/me', requireSession, (req, res) => {
res.json({
account_id: req.session.account_id,
user_id: req.session.sub,
domain: req.session.iss,
})
})

// Catch-all — serves the frontend for all non-API routes.
// Must be registered after all API routes.
app.get('/{*path}', (req, res) => {
if (!verifyLaunchHmac(req.query)) {
return res.status(403).send('Forbidden')
}
res.sendFile(join(__dirname, '../../frontend/dist/index.html'))
})

app.listen(process.env.PORT, () => {
console.log(`Backend running at http://localhost:${process.env.PORT}`)
})

function requireSession(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' })
}
try {
const token = authHeader.slice(7)
req.session = jwt.verify(token, process.env.APP_SECRET, {
algorithms: ['HS256'],
audience: process.env.APP_ID,
})
next()
} catch {
res.status(401).json({ error: 'Invalid or expired session token' })
}
}

function verifySignature(rawBody, signature) {
const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature))
}

function verifyLaunchHmac(query) {
const { hmac, ...params } = query
if (!hmac) return false

const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')

const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(message).digest('hex')

try {
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac))
} catch {
return false
}
}

Fetch account data from the frontend

Update frontend/src/App.jsx:

import { useEffect, useState } from 'react'
import { createAppBridge } from '@scompler/app-bridge'

const app = createAppBridge({ appId: import.meta.env.VITE_APP_ID })

export default function App() {
const [account, setAccount] = useState(null)
const [error, setError] = useState(null)

useEffect(() => {
async function loadAccount() {
try {
const response = await app.authFetch('/api/me')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
setAccount(await response.json())
} catch (err) {
setError(err.message)
}
}
loadAccount()
}, [])

if (error) return <p>Error: {error}</p>
if (!account) return <p>Loading...</p>

return (
<div>
<p>Account ID: {account.account_id}</p>
<p>User ID: {account.user_id}</p>
<p>Domain: {account.domain}</p>
</div>
)
}

Restart the backend, rebuild the frontend, and reopen your app inside Scompler. After a moment, you should see your account data displayed.

Step 8: App Backend → Scompler API request

Now that you have an access_token stored in the database, use it to make a GraphQL request to the Scompler API on behalf of the account. In this step, you'll fetch the account's articles and display them in the frontend.

Add the API request to the backend

The Scompler API is a GraphQL server. There are two endpoints depending on the account's plan:

PlanURL
Apphttps://public-api.app.scompler.com/graphql
Prohttps://public-api.pro.scompler.com/graphql

Configure the correct URL via the API_URL environment variable and pass the access_token in the Authorization header as a Bearer token.

Update backend/src/index.js — add the /api/articles route:

import express from 'express'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { saveToken, getToken } from './store.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}

const { account_id, access_token, expires_at } = JSON.parse(req.body)
saveToken({ account_id, access_token, expires_at })
console.log(`✓ App installed for account ${account_id}`)
res.sendStatus(200)
})

app.use(express.json())
app.use(express.static(join(__dirname, '../../frontend/dist'), { index: false }))

app.get('/api/me', requireSession, (req, res) => {
res.json({
account_id: req.session.account_id,
user_id: req.session.sub,
domain: req.session.iss,
})
})

app.get('/api/articles', requireSession, async (req, res) => {
const { account_id } = req.session
const record = getToken(account_id)

if (!record) {
return res.status(403).json({ error: 'No access token found for this account' })
}

const query = `
query GetArticles {
articles(first: 10, sort: {direction: desc, field: "id"}) {
nodes {
id
name
publishedAt
thumbnailUrl
}
}
}
`

try {
const response = await fetch(process.env.API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${record.access_token}`,
},
body: JSON.stringify({ query }),
})

if (!response.ok) {
return res.status(502).json({ error: 'Scompler API request failed' })
}

const { data, errors } = await response.json()

if (errors?.length) {
return res.status(502).json({ error: errors[0].message })
}

res.json(data.articles.nodes)
} catch {
res.status(502).json({ error: 'Scompler API request failed' })
}
})

// Catch-all — serves the frontend for all non-API routes.
// Must be registered after all API routes.
app.get('/{*path}', (req, res) => {
if (!verifyLaunchHmac(req.query)) {
return res.status(403).send('Forbidden')
}
res.sendFile(join(__dirname, '../../frontend/dist/index.html'))
})

app.listen(process.env.PORT, () => {
console.log(`Backend running at http://localhost:${process.env.PORT}`)
})

function requireSession(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' })
}
try {
const token = authHeader.slice(7)
req.session = jwt.verify(token, process.env.APP_SECRET, {
algorithms: ['HS256'],
audience: process.env.APP_ID,
})
next()
} catch {
res.status(401).json({ error: 'Invalid or expired session token' })
}
}

function verifySignature(rawBody, signature) {
const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature))
}

function verifyLaunchHmac(query) {
const { hmac, ...params } = query
if (!hmac) return false

const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')

const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(message).digest('hex')

try {
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac))
} catch {
return false
}
}

Display the result in the frontend

Update frontend/src/App.jsx to also fetch articles from the Scompler API:

import { useEffect, useState } from 'react'
import { createAppBridge } from '@scompler/app-bridge'

const app = createAppBridge({ appId: import.meta.env.VITE_APP_ID })

export default function App() {
const [account, setAccount] = useState(null)
const [articles, setArticles] = useState(null)
const [error, setError] = useState(null)

useEffect(() => {
async function load() {
try {
const [accountRes, articlesRes] = await Promise.all([
app.authFetch('/api/me'),
app.authFetch('/api/articles'),
])
if (!accountRes.ok) throw new Error(`/api/me: HTTP ${accountRes.status}`)
if (!articlesRes.ok) throw new Error(`/api/articles: HTTP ${articlesRes.status}`)
setAccount(await accountRes.json())
setArticles(await articlesRes.json())
} catch (err) {
setError(err.message)
}
}
load()
}, [])

if (error) return <p>Error: {error}</p>
if (!account || !articles) return <p>Loading...</p>

return (
<div>
<p>User ID: {account.user_id}</p>
<ul>
{articles.map((article) => (
<li key={article.id}>
{article.thumbnailUrl && <img src={article.thumbnailUrl} alt={article.name} />}
<p>{article.name}</p>
<p>{article.publishedAt}</p>
</li>
))}
</ul>
</div>
)
}

Restart the backend, rebuild the frontend, and reopen your app. You should now see the list of articles fetched directly from the Scompler API.

Step 9: Handle webhooks

Scompler sends webhook events as signed POST requests to your webhook URL. Verify the signature the same way as the auth callback, then handle events accordingly.

Update backend/src/index.js — add the /webhooks route:

import express from 'express'
import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { saveToken, getToken, deleteToken } from './store.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const app = express()

app.post('/callback', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}

const { account_id, access_token, expires_at } = JSON.parse(req.body)
saveToken({ account_id, access_token, expires_at })
console.log(`✓ App installed for account ${account_id}`)
res.sendStatus(200)
})

app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature']
if (!signature || !verifySignature(req.body, signature)) {
return res.status(401).json({ error: 'Invalid signature' })
}

const { event, account_id } = JSON.parse(req.body)

switch (event) {
case 'app.uninstalled':
deleteToken(account_id)
console.log(`App uninstalled for account ${account_id}`)
break
default:
console.log(`Received event: ${event}`)
}

res.sendStatus(200)
})

app.use(express.json())
app.use(express.static(join(__dirname, '../../frontend/dist'), { index: false }))

app.get('/api/me', requireSession, (req, res) => {
res.json({
account_id: req.session.account_id,
user_id: req.session.sub,
domain: req.session.iss,
})
})

app.get('/api/articles', requireSession, async (req, res) => {
const { account_id } = req.session
const record = getToken(account_id)

if (!record) {
return res.status(403).json({ error: 'No access token found for this account' })
}

const query = `
query GetArticles {
articles(first: 10, sort: {direction: desc, field: "id"}) {
nodes {
id
name
publishedAt
thumbnailUrl
}
}
}
`

try {
const response = await fetch(process.env.API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${record.access_token}`,
},
body: JSON.stringify({ query }),
})

if (!response.ok) {
return res.status(502).json({ error: 'Scompler API request failed' })
}

const { data, errors } = await response.json()

if (errors?.length) {
return res.status(502).json({ error: errors[0].message })
}

res.json(data.articles.nodes)
} catch {
res.status(502).json({ error: 'Scompler API request failed' })
}
})

// Catch-all — serves the frontend for all non-API routes.
// Must be registered after all API routes.
app.get('/{*path}', (req, res) => {
if (!verifyLaunchHmac(req.query)) {
return res.status(403).send('Forbidden')
}
res.sendFile(join(__dirname, '../../frontend/dist/index.html'))
})

app.listen(process.env.PORT, () => {
console.log(`Backend running at http://localhost:${process.env.PORT}`)
})

function requireSession(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' })
}
try {
const token = authHeader.slice(7)
req.session = jwt.verify(token, process.env.APP_SECRET, {
algorithms: ['HS256'],
audience: process.env.APP_ID,
})
next()
} catch {
res.status(401).json({ error: 'Invalid or expired session token' })
}
}

function verifySignature(rawBody, signature) {
const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(rawBody).digest('hex')
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(signature))
}

function verifyLaunchHmac(query) {
const { hmac, ...params } = query
if (!hmac) return false

const message = Object.keys(params)
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&')

const digest = crypto.createHmac('sha256', process.env.APP_SECRET).update(message).digest('hex')

try {
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(hmac))
} catch {
return false
}
}

To test, uninstall your app from the test account in Scompler. You should see the following in your terminal:

App uninstalled for account 123