Guide: add an action to invoices
This guide builds one complete INVOICE_ACTION extension from nothing,
explaining each move as you make it. It’s the shape every embedded action
follows, written out by hand.
It’s a different example from the starter template. The
starter template (what onebooks init scaffolds and
the quickstart runs) saves a generated
tracking number from /invoice-action; this guide builds a small form that
saves a merchant-entered reference code from /extensions/invoice-action, with
an Express backend. Read the two side by side if that helps — just don’t mix
their paths or field keys.
What you’ll build: an Add reference entry in every invoice’s Apps menu that opens a dialog showing the invoice number, lets the merchant type a reference code, and saves it as app data on the invoice.
You’ll need: a CONFIDENTIAL app with an https App URL
(Embedded apps), the scopes invoices:read,
app-data:read and app-data:write on its OAuth tab, and a
sandbox install to test in.
-
Register the extension
On your app’s Embedding tab, select Add extension:
- Placement: Invoice — Action (menu item) (
INVOICE_ACTION) - Label:
Add reference - Path:
/extensions/invoice-action
When a merchant opens the Apps menu on an invoice, OneBooks offers “Add reference” and opens
<your App URL's origin>/extensions/invoice-actionin a dialog. The console only accepts an invoice placement once your app hasinvoices:read, and it renders only for businesses whose installation holds that scope. See UI extensions. - Placement: Invoice — Action (menu item) (
-
Declare the app data field
On the App data tab, select Add field:
- Resource type:
INVOICE - Field type:
TEXT - Key:
reference_code - Field name:
Reference code - Merchant visible: on
- Merchant editable: off — only your app writes it here
See App data for the other types and rules.
- Resource type:
-
Serve the page, with the frame header
OneBooks loads your page with
?host=…&locale=…&embedded=1&target=INVOICE_ACTION&resourceType=INVOICE&resourceId=<invoice id>in a cross-origin iframe. Every response must allow that frame:server.js import express from 'express';const server = express();server.use(express.json());server.use((req, res, next) => {res.setHeader('Content-Security-Policy', 'frame-ancestors https://app.getonebooks.com');next();});// The extension page: an HTML file that loads your script (step 4).server.get('/extensions/invoice-action', (req, res) => {res.sendFile('invoice-action.html', { root: 'public' });});server.use(express.static('public')); // your bundled page script, CSS, … -
Initialize App Bridge on the page
import { createApp } from '@onebooks/app-bridge';const app = createApp(); // reads host, target and resourceId from the URLawait app.ready(); // required: the host waits up to 15 s for thisconst invoiceId = app.params.resourceId;app.paramsholds the query parameters above;app.context()gives you the merchant’s language, theme and business name too. -
Call your own backend — the session token travels automatically
const res = await app.fetch(`/api/invoice-panel?invoiceId=${encodeURIComponent(invoiceId)}`);const { invoiceNumber } = await res.json();render(invoiceNumber);app.fetch()attachesAuthorization: Bearer <session token>because the request is same-origin with your page — see App Bridge SDK. The session token proves who’s asking; your backend is what’s trusted to act on it. -
On your backend: verify every request, exchange once
A session token can be exchanged for API tokens exactly once, and App Bridge reuses the same token for every request it sends in the next ~50 seconds. So verify the session token on every request, exchange it only the first time you see that business and user, and keep the tokens it gives you:
import { verifySessionToken, oauth, OneBooksClient } from '@onebooks/node';const CLIENT_ID = process.env.ONEBOOKS_CLIENT_ID;const CLIENT_SECRET = process.env.ONEBOOKS_CLIENT_SECRET;const tokens = new Map(); // `${bid}:${sub}` -> { accessToken, refreshToken, expiresAt } — use a database in productionasync function clientFor(req) {const sessionToken = (req.get('Authorization') ?? '').replace(/^Bearer /, '');const claims = await verifySessionToken(sessionToken, { clientId: CLIENT_ID }); // throws if invalidconst key = `${claims.bid}:${claims.sub}`;let stored = tokens.get(key);if (!stored) {stored = toStored(await oauth.exchangeSessionToken({clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, sessionToken,}));} else if (stored.expiresAt - 60_000 < Date.now()) {stored = toStored(await oauth.refresh({clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, refreshToken: stored.refreshToken,}));}tokens.set(key, stored);return new OneBooksClient({ accessToken: stored.accessToken });}function toStored(t) {return { accessToken: t.access_token, refreshToken: t.refresh_token, expiresAt: Date.now() + t.expires_in * 1000 };}server.get('/api/invoice-panel', async (req, res) => {const client = await clientFor(req);const invoice = await client.get(`/invoices/${req.query.invoiceId}`);res.json({ invoiceNumber: invoice.invoiceNumber });});Refresh tokens rotate, so store the new pair every time (Authentication). The tokens belong to whichever user has the dialog open — a
viewer-role user gets a token that can read the invoice but not write, automatically. In production, also answer401when the session token doesn’t verify, and when a refresh fails or an API call answers401, drop the stored pair and exchange the current session token instead. This sketch also assumes one request at a time: a page’s parallel requests carry the same session token, and only one exchange of it can succeed, so let them share a single exchange or refresh per business and user (Token exchange). The starter template does all of this. -
Write app data back
When the merchant submits the form, send the value the same way:
// pageawait app.fetch(`/api/invoice-panel?invoiceId=${encodeURIComponent(invoiceId)}`, {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ referenceCode }),});// serverserver.post('/api/invoice-panel', async (req, res) => {const client = await clientFor(req);await client.appData.set([{ resourceType: 'INVOICE', resourceId: req.query.invoiceId, key: 'reference_code', value: req.body.referenceCode },]);res.status(204).end();});A value that breaks the field’s rules (a line break in a
TEXTfield, say) comes back as400 INVALID_APP_DATA_VALUEwith adetailsarray — see Errors. -
Toast and close
await app.toast('Reference saved', { tone: 'success' });await app.close();When the dialog closes, OneBooks refreshes the invoice’s App data card, where the merchant now sees Reference code under your app’s name.
What this covered
Section titled “What this covered”Extension registration → App Bridge context → session token → backend verification and a one-time token exchange → REST read → app data write → App Bridge finish. Every embedded action is a variation on this shape.
Where next
Section titled “Where next”- App data — types, limits and the merchant-editable flag this guide left off.
- App Bridge reference — every action beyond
ready(),fetch(),toast()andclose().