Starter template
embedded-node is a complete, runnable embedded app in plain node:http — no
framework, no build step, one runtime dependency
(@onebooks/node). It’s what
onebooks init scaffolds and what the
quickstart runs. It demonstrates every part
of an embedded app at once: pages inside OneBooks, a session-token-authenticated
backend, app data, a signed webhook
receiver and an example hosted function.
onebooks init my-onebooks-appcd my-onebooks-appcp .env.example .envnpm installnode server.mjsYou need the onebooks CLI (in preview — see there for
access). init points the app’s @onebooks/node dependency at the SDK
version matching your CLI.
What it does
Section titled “What it does”- Home page (
/) — the page merchants open from your app’s row in the Apps sidebar. Shows the business name, language and theme fromapp.context(), and the ten most recently created invoices. - Invoice action (
/invoice-action, targetINVOICE_ACTION) — opened from an invoice’s Apps menu. It saves a demo tracking number for that invoice as app data, shows a toast and closes itself. - Invoice block (
/invoice-block, targetINVOICE_BLOCK) — a card on the invoice page that reads the tracking number back, or shows Not set yet. OneBooks reloads it when the action dialog closes, so the new value appears straight away. - Webhook receiver (
POST /webhooks) — verifies the signature, then drops the business’s stored tokens onapp.uninstalledandbusiness.redact. - Hosted function (
functions/notify-on-paid.js) — deployed separately with the CLI; logs a line when an invoice is paid.
- server.mjs the whole HTTP server — routes, API error handling, webhook receiver
Directorylib
- load-env.mjs tiny .env loader, imported first
- onebooks.mjs wires @onebooks/node to the token store — session-token verification, exchange and refresh
- session-tokens.mjs the exchange-once / refresh / re-exchange rules, one renewal at a time, as pure logic
- csp.mjs the Content-Security-Policy frame-ancestors value
- pages.mjs the three HTML pages (home, invoice action, invoice block)
- token-store.mjs a JSON-file token store, keyed by business and user
Directoryfunctions
- notify-on-paid.js example hosted function (
invoice.paid)
- notify-on-paid.js example hosted function (
Directorystatic
- app-bridge.global.js the App Bridge SDK’s script-tag build (plus its
.map)
- app-bridge.global.js the App Bridge SDK’s script-tag build (plus its
Directorytest
- session-tokens.test.mjs
- csp.test.mjs
Directorydata/ created at runtime — holds tokens.json, ignored by git
- …
- .env.example
- .gitignore
- package.json
- README.md
- LICENSE
Routes
Section titled “Routes”| Route | Serves |
|---|---|
GET / | The home page |
GET /invoice-action | The INVOICE_ACTION extension page |
GET /invoice-block | The INVOICE_BLOCK extension page |
GET /static/* | app-bridge.global.js |
GET /api/invoices/recent | GET /invoices?limit=10, returned to the home page as { invoices, total } |
POST /api/invoice-action?resourceId=… | Reads the invoice, generates TRK-<invoice number>-…, saves it as the tracking_number app-data value, returns { trackingNumber } |
GET /api/invoice-block?resourceId=… | Reads the invoice’s app data and returns { trackingNumber } (or null) |
POST /webhooks | The signed webhook receiver |
Every /api/* route requires Authorization: Bearer <session token> — which
app.fetch() attaches for you. Its errors are mapped to statuses your page can
act on:
| Status | Means |
|---|---|
400 | A bad request, such as a missing resourceId |
401 | No valid session token — or OneBooks no longer accepts this session’s tokens and they couldn’t be renewed; reload the app |
502 | OneBooks failed or refused: the token exchange didn’t work, or an API call came back with an error (its status and code are in the body) |
500 | A bug in the app itself |
npm test runs the tests for the token rules and the CSP header — no network
needed.
Environment
Section titled “Environment”| Variable | Required | Meaning |
|---|---|---|
ONEBOOKS_CLIENT_ID | Yes | Your app’s client_id. The server refuses to start without it. |
ONEBOOKS_CLIENT_SECRET | Yes | Your app’s client_secret — the app must be CONFIDENTIAL, because token exchange requires a secret. |
ONEBOOKS_WEBHOOK_SECRET | For webhooks | The whsec_… secret shown once when you create the webhook. Without it every delivery gets a 400. |
ONEBOOKS_API_BASE | No | Default https://api.getonebooks.com |
PORT | No | Default 3100 |
APP_ORIGIN | No | The OneBooks origin allowed to frame the pages. Default https://app.getonebooks.com |
NODE_ENV | In production | Set it to production when you deploy: only APP_ORIGIN may frame the app then. Anything else also allows http://localhost:5173, a OneBooks frontend running locally. |
Console setup it expects
Section titled “Console setup it expects”| Console tab | Setting |
|---|---|
| OAuth | Scopes invoices:read, app-data:read, app-data:write |
| App data | A field on INVOICE with key tracking_number, field type TEXT, Merchant visible on |
| Embedding | App URL = your https origin; extensions INVOICE_ACTION → /invoice-action and INVOICE_BLOCK → /invoice-block |
| Webhooks | Optional: an endpoint at <your origin>/webhooks subscribed to app.uninstalled and business.redact |
The quickstart walks through each one.
How the backend authenticates
Section titled “How the backend authenticates”Every page loads /static/app-bridge.global.js, calls app.ready(), and then
reaches its backend with app.fetch('/api/…'). On the server:
verifyRequestSessionToken()checks the session token’s ES256 signature against/.well-known/jwks.json, its audience (yourclient_id), issuer and expiry — on every request. See Session tokens.withSessionClient()looks up stored tokens for that token’s business and user (bidandsub). The first time a pair shows up, it exchanges the session token for an access and refresh token and stores them; afterwards it reuses them, refreshing when the access token is within a minute of expiry. A session token can be exchanged only once, and App Bridge sends the same one with every request for up to about 50 seconds — which is why the exchange happens once per pair, not once per request.- If a refresh fails, or the API answers
401(the stored tokens were revoked), it drops the pair and exchanges the request’s session token instead of failing from then on. - Only one renewal — refresh or exchange — runs at a time per business
and user. A page’s parallel
app.fetch()calls carry the same session token, and only one exchange of it can succeed, so concurrent first requests share a single exchange, and parallel requests that hit the same401share one re-exchange. After a401, the stored pair is dropped only if it’s still the one the API rejected, and a failed renewal isn’t remembered — the next request simply tries again. - It runs the route with a
OneBooksClientfor that access token.
The rules live in lib/session-tokens.mjs as plain functions, so you can
lift them into your own app. The one-at-a-time rule is kept in memory, so it
coordinates requests within one process only — see
Before you ship it. Every response also carries a
Content-Security-Policy: frame-ancestors … header (lib/csp.mjs), without
which browsers refuse to show the pages inside OneBooks.
Webhooks
Section titled “Webhooks”POST /webhooks reads the raw body, verifies X-Webhook-Signature with
verifyWebhook() and ONEBOOKS_WEBHOOK_SECRET, and answers 400 if that
fails. On app.uninstalled and business.redact it deletes every stored token
for the business. It acknowledges with 200 even when its own handling throws
— retrying a delivery can’t fix an application bug.
The hosted function
Section titled “The hosted function”functions/notify-on-paid.js exports onEvent(event, ctx): on invoice.paid
it reads the invoice through ctx.api and logs its number and total. It also
shows how to reach an outside service — with the global fetch(), never
ctx.api — but ships with that call switched off. Deploy it with:
onebooks functions deploy --app <appId> --name notify-on-paid \ --file functions/notify-on-paid.js --events invoice.paid --activate(Add --egress <host> when you turn the outside call on — see
Hosted functions.)
Before you ship it
Section titled “Before you ship it”The template is deliberately small. Before real merchants use code built on it:
- Store tokens properly.
data/tokens.json(file mode0600) is a stand-in. Use a real database and encrypt tokens at rest — see Authentication. - Coordinate renewals across instances. Running more than one instance of the backend, two of them can still refresh or exchange the same pair at once. Serialize renewals per business and user through your database — a row lock, for example — instead of the in-memory rule.
- Delete everything on
business.redact. The example only holds tokens. Your app must delete all of its own data for the business — see Privacy & data handling. - Let the merchant enter the value. The action writes a generated number immediately; a real action would show a small form first.
- Set
NODE_ENV=productionwhere you deploy, so onlyhttps://app.getonebooks.commay frame your pages. - Report errors. The webhook receiver acknowledges even when its own code fails, so ship your own error reporting rather than relying on retries.
Where next
Section titled “Where next”- Guide: add an action to invoices — build an action from nothing, with each step explained.
- Node SDK and App Bridge SDK — the two packages this template is built on.