Skip to content

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.

  1. 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-action in a dialog. The console only accepts an invoice placement once your app has invoices:read, and it renders only for businesses whose installation holds that scope. See UI extensions.

  2. 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.

  3. 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, …
  4. Initialize App Bridge on the page

    import { createApp } from '@onebooks/app-bridge';
    const app = createApp(); // reads host, target and resourceId from the URL
    await app.ready(); // required: the host waits up to 15 s for this
    const invoiceId = app.params.resourceId;

    app.params holds the query parameters above; app.context() gives you the merchant’s language, theme and business name too.

  5. 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() attaches Authorization: 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.

  6. 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 production
    async function clientFor(req) {
    const sessionToken = (req.get('Authorization') ?? '').replace(/^Bearer /, '');
    const claims = await verifySessionToken(sessionToken, { clientId: CLIENT_ID }); // throws if invalid
    const 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 answer 401 when the session token doesn’t verify, and when a refresh fails or an API call answers 401, 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.

  7. Write app data back

    When the merchant submits the form, send the value the same way:

    // page
    await app.fetch(`/api/invoice-panel?invoiceId=${encodeURIComponent(invoiceId)}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ referenceCode }),
    });
    // server
    server.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 TEXT field, say) comes back as 400 INVALID_APP_DATA_VALUE with a details array — see Errors.

  8. 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.

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.

  • App data — types, limits and the merchant-editable flag this guide left off.
  • App Bridge reference — every action beyond ready(), fetch(), toast() and close().