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 URL —
https://example.com/callback - Webhook URL —
https://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
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.
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:
| Setting | Value |
|---|---|
| App URL | https://<your-tunnel-url>/ |
| Callback URL | https://<your-tunnel-url>/callback |
| Webhook URL | https://<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))
}
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:
| Plan | URL |
|---|---|
| App | https://public-api.app.scompler.com/graphql |
| Pro | https://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