ESC
Type to search…
v2026
This documentation is still being improved and may not fully reflect how the application works. Join the forum to ask questions and share feedback →
Docs Developer Add-In Extensions

Add-In Extensions

Add-ins let you embed third-party web apps inside Basis as a modal dialog. The add-in runs in a sandboxed iframe and communicates with Basis through a structured postMessage protocol — Basis proxies all data access using the current user's session, so the add-in never needs its own credentials.

Common use cases:

  • e-Invoicing submissions (DJP Coretax, KSA ZATCA, MyInvois, etc.)
  • Digital signature integration
  • Logistics / shipping label generation
  • Custom approval workflows
  • ERP bridge — push Basis data to an external system

Registering an Add-In (Users)

Go to Administration → Extensions / Add-Ins → New.

Field Description
Name Displayed in the button/menu inside Basis.
Entry URL Full URL of the add-in web app, e.g. https://zatca.example.com/basis. Must be https://.
Allowed Origin The origin (scheme + host) Basis will accept messages from, e.g. https://zatca.example.com. This is validated on every incoming message.
Placements Where the add-in button appears. Select one or more from the list.
Modal Width / Height CSS values controlling the dialog size, e.g. 85vw / 85vh. On mobile the dialog always expands to near-full-screen.
Active Inactive add-ins are hidden from all users.

Placements

Placement Where the button appears
Sales Invoice Sales Invoice preview page
Purchase Invoice Purchase Invoice preview page
Credit Note Credit Note preview page
Debit Note Debit Note preview page
Sales Order Sales Order preview page
Purchase Order Purchase Order preview page
Delivery Note Delivery Note preview page
Receipt Note Receipt Note preview page
Payment Payment Voucher preview page
Receipt Receipt Voucher preview page
Journal Entry Journal Entry preview page
Contra Entry Contra Entry preview page
Global Header navigation bar — always visible across all pages

When an add-in has the Global placement, its button appears in the top navigation bar next to the theme toggle. Global add-ins receive no entityId and are intended for cross-document operations (dashboards, bulk submissions, etc.).

When multiple add-ins share a placement, Basis renders a dropdown menu listing all of them under an "Extensions" label.


Building an Add-In (Developers)

An add-in is a standard web app hosted anywhere (your own server, CDN, SaaS). Basis loads it in a sandboxed <iframe> with:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups"

No SDK installation needed. Communication is pure postMessage. The add-in's origin must match the Allowed Origin field registered in Basis — Basis rejects all messages from other origins.

Architecture

Basis (host)                     Add-In (iframe)
─────────────────────────────────────────────────
User opens preview page
  → button click → opens modal iframe
  → iframe loads → basis:init ──────────────────►
                                 Add-in receives context
                                 (placement, entityId…)
                   ◄─────────── basis:request (resource)
  → proxy call to service layer
  → basis:response ─────────────────────────────►
                                 Add-in renders UI
                   ◄─────────── basis:setAddinSettings
                   ◄─────────── basis:toast / basis:close

Basis handles all service calls server-side using the logged-in user's session. The add-in never receives tokens, credentials, or direct database access.


Step 1 — Receive Initialization

When the iframe finishes loading, Basis sends a basis:init message:

window.addEventListener('message', (event) => {
    // ALWAYS validate origin before processing
    if (event.origin !== 'https://your-basis-host.com') return;

    const msg = event.data;
    if (msg.type !== 'basis:init') return;

    console.log(msg.placement);   // e.g. "SalesInvoice"
    console.log(msg.entityType);  // e.g. "SalesInvoice"
    console.log(msg.entityId);    // UUID string, null for Global
    console.log(msg.language);    // "en" or "id"
});

basis:init payload:

{
  "type":         "basis:init",
  "placement":    "SalesInvoice",
  "entityType":   "SalesInvoice",
  "entityId":     "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "language":     "id",
  "userId":       "a1b2c3d4-0000-0000-0000-000000000001",
  "userName":     "Budi Santoso",
  "userEmail":    "budi@example.com",
  "businessId":   "acme-id",
  "businessName": "PT Acme Indonesia",
  "businessRole": "Owner"
}
Field Value
placement Enum name matching the registered placement (e.g. "SalesInvoice")
entityType Document type string. CreditNote and DebitNote share the SalesInvoice/PurchaseInvoice placement but have distinct entityType values.
entityId UUID of the current document, or null for Global.
language Two-letter language code of the current user ("en" or "id").
userId UUID of the logged-in user.
userName Display name of the logged-in user.
userEmail Email address of the logged-in user.
businessId Internal business identifier.
businessName Display name of the active business.
businessRole Role of the user in this business: "Owner", "Admin", "Staff", etc.

Save these values — you will include entityId in subsequent data requests.


Step 2 — Request Data

Send a basis:request message and receive a basis:response (or basis:error) back.

Helper function:

let _seq = 0;
const _pending = {};

function basisRequest(resource, extra = {}) {
    return new Promise((resolve, reject) => {
        const id = `r${++_seq}`;
        _pending[id] = { resolve, reject };

        window.parent.postMessage(
            { type: 'basis:request', id, resource, ...extra },
            '*'   // Basis validates origin server-side; use '*' here
        );
    });
}

window.addEventListener('message', (event) => {
    if (event.origin !== 'https://your-basis-host.com') return;
    const msg = event.data;

    if (msg.type === 'basis:response' && _pending[msg.id]) {
        _pending[msg.id].resolve(msg.data);
        delete _pending[msg.id];
    }
    if (msg.type === 'basis:error' && _pending[msg.id]) {
        _pending[msg.id].reject(new Error(msg.message));
        delete _pending[msg.id];
    }
    if (msg.type === 'basis:init') { /* … */ }
});

Resource: voucher

Fetches the current document (resolved from entityType + entityId from basis:init):

const invoice = await basisRequest('voucher');
// Returns the full VoucherDetailResponse or order detail object

Returns the same DTO as the Basis REST API for the document type. Contains header fields (number, date, partyId, total, lines, customFields, etc.). GUIDs such as partyId, itemId, and taxCategoryId are included but not resolved to full objects.

Resource: voucherFull

Fetches the current document with all referenced entities resolved in a single call. Designed for integrations that need complete data without making multiple follow-up requests.

const data = await basisRequest('voucherFull');
// {
//   voucher:         { …full voucher… },
//   party:           { name, taxNumber, addresses, customFields, … },
//   items:           { "<itemId>": { code, name, customFields, … }, … },
//   taxCategories:   { "<taxCategoryId>": { name, customFields, rates: [ { name, rate, customFields, … } ] }, … },
//   customFieldDefs: [ { id, name, dataType, … }, … ]
// }

items is a map of itemId → ItemDetailResponse. Item custom fields (e.g. HS code, origin country stored by the user) are included inside each item object.

taxCategories is a map of taxCategoryId → TaxCategoryResponse. Each entry contains the category name, its customFields, and a rates array — each rate also exposes its own customFields. This is the right place to store invoicing codes required by e-invoicing standards (e.g. a DJP tax type code, ZATCA category code), since those codes belong to the tax configuration rather than to the document itself.

customFieldDefs lists all field definitions for the business, so the add-in can interpret the customFields dictionaries (which are keyed by CustomFieldDef.Id).

Tax rate percentage per line is also available in the base voucher.ledgerEntries — entries where sourceType = "Tax" carry taxRate, taxableAmount, and taxConfigName, linked back to their line via sourceItemAllocationId.

Resource: business

Fetches the current business profile:

const biz = await basisRequest('business');
// { name, taxNumber, address, city, country, currency, … }

Resource: addinSettings

Fetches the add-in's own persisted settings (stored in the business database):

const settings = await basisRequest('addinSettings', { key: 'my-addin-v1' });
// Returns the object previously saved with basis:setAddinSettings
// Returns {} if no settings have been saved yet

The key is a stable string you define in your add-in code. It survives delete + re-registration of the add-in and host URL changes — Basis stores it independently from the AddInDef record.

Resource: api

Full proxy to the Basis REST API. Pass any relative path and HTTP method — Basis forwards the call server-side using the current user's session and permissions. The businessId segment is prepended automatically; do not include it in path.

// List sales invoices (paginated)
const list = await basisRequest('api', {
    method: 'GET',
    path:   '/sales?page=1&pageSize=20'
});

// Get a party with full detail (tax number, addresses, etc.)
const party = await basisRequest('api', {
    method: 'GET',
    path:   `/parties/${partyId}`
});

// Create a new record
const invoice = await basisRequest('api', {
    method: 'POST',
    path:   '/sales',
    body:   JSON.stringify({ date: '2026-06-06', partyId: '…', lines: [ … ] })
});

// Update a record
await basisRequest('api', {
    method: 'PUT',
    path:   `/sales/${invoiceId}`,
    body:   JSON.stringify({ … })
});

// Delete a record
await basisRequest('api', { method: 'DELETE', path: `/sales/${invoiceId}` });

All Basis REST API endpoints are accessible — /sales, /purchases, /payments, /receipts, /journals, /contra-entries, /sales-orders, /purchase-orders, /sales-orders/delivery-notes/{id}, /purchase-orders/receipt-notes/{id}, /parties, /items, /custom-field-defs, and more. See the REST API docs for the full endpoint list.

The ApiResponse<T>.data wrapper is unwrapped automatically before the value is returned to the add-in.

Response envelope:

{ "type": "basis:response", "id": "r1", "data": { … } }

Error envelope:

{ "type": "basis:error", "id": "r1", "message": "Not found" }

Step 3 — Write Data

Save Add-In Settings

Persist add-in configuration into the business database (e.g. API credentials, onboarding state):

window.parent.postMessage({
    type:     'basis:setAddinSettings',
    key:      'my-addin-v1',
    settings: { apiKey: 'abc123', region: 'SA', onboarded: true }
}, '*');

settings can be any JSON-serializable object. Basis stores it as a JSON blob keyed by key within the business database. No response is sent.

Security note: Basis stores settings as plaintext. Do not store plaintext private keys or secrets with high sensitivity. Use your own key management service for encryption if required.

Set a Custom Field Value

Write a value to a custom field on the current document (the entity open in the preview page). Only available on transaction placements — not on Global (which has no entity context).

window.parent.postMessage({
    type:    'basis:setCustomField',
    fieldId: '3fa85f64-5717-4562-b3fc-2c963f66afa6',  // CustomFieldDef GUID
    value:   'REF-20260601-0042'
}, '*');

fieldId is the CustomFieldDef.Id GUID from Settings → Custom Fields. The value is a string for all types; Basis parses it to the correct storage type:

Field type Value format
Single line / Paragraph / List Plain text string
Number Decimal string in invariant culture, e.g. "1234.56"
Date ISO 8601, e.g. "2026-06-06" or "2026-06-06T00:00:00Z"
Yes/No "true" or "false"

Using your own GUIDs (add-in private storage): fieldId does not need to be registered in Administration → Custom Fields. You can use a GUID you generate once and hardcode in your add-in code. Basis will store the value in the entity's customFields.strings blob. This is useful for storing integration state (submission status, reference IDs, timestamps) directly on the document without cluttering the admin UI.

// Private GUID — hardcoded in add-in, never changes
const SUBMISSION_STATUS_KEY = '11111111-aaaa-bbbb-cccc-000000000001';

window.parent.postMessage({
    type:    'basis:setCustomField',
    fieldId: SUBMISSION_STATUS_KEY,
    value:   'submitted'
}, '*');

Using admin-registered fields (visible in UI): If the field should appear in Basis forms and PDF documents, it must be registered in Administration → Custom Fields. Since fieldId differs per Basis installation, the recommended discovery pattern is:

  1. On first run, call GET /custom-field-defs via resource:api to list available fields.
  2. Let the user/admin select which field maps to the integration's output.
  3. Save the selection via basis:setAddinSettings.
  4. At runtime, read settings → retrieve fieldId → write the value.

No response is sent back. Send basis:refresh afterward so the preview page reloads and shows the updated value.


Step 4 — UI Actions

Show a Toast Notification

window.parent.postMessage({
    type:    'basis:toast',
    intent:  'success',   // 'success' | 'error' | 'warning' | 'info'
    message: 'Submitted to ZATCA successfully.'
}, '*');

Refresh the Host Page

Instructs Basis to reload the current document and regenerate the PDF/preview:

window.parent.postMessage({ type: 'basis:refresh' }, '*');

Use this after writing custom fields so the user sees the updated values.

Close the Dialog

window.parent.postMessage({ type: 'basis:close' }, '*');

Closes the modal. The underlying preview page remains open.


Complete Minimal Example

A minimal single-file add-in that reads the current voucher and saves settings:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My Add-In</title>
    <style>
        body { font-family: sans-serif; padding: 16px; }
        button { padding: 8px 16px; margin-top: 8px; cursor: pointer; }
        pre { background: #f5f5f5; padding: 12px; font-size: 12px; overflow: auto; }
    </style>
</head>
<body>
    <h2>My Add-In</h2>
    <button onclick="loadVoucher()">Load Voucher</button>
    <button onclick="submit()">Submit</button>
    <button onclick="closeMe()">Close</button>
    <pre id="out">Waiting for init…</pre>

    <script>
        const BASIS_ORIGIN = 'https://app.basis-apps.net'; // replace with your host
        let _ctx = null;
        let _seq  = 0;
        const _pending = {};

        // ── Message router ──────────────────────────────────────────
        window.addEventListener('message', (event) => {
            if (event.origin !== BASIS_ORIGIN) return;
            const msg = event.data;

            if (msg.type === 'basis:init') {
                _ctx = msg;
                document.getElementById('out').textContent =
                    'Init received:\n' + JSON.stringify(msg, null, 2);
                return;
            }
            if ((msg.type === 'basis:response' || msg.type === 'basis:error')
                    && _pending[msg.id]) {
                const p = _pending[msg.id];
                delete _pending[msg.id];
                msg.type === 'basis:response' ? p.resolve(msg.data) : p.reject(new Error(msg.message));
            }
        });

        // ── Request helper ───────────────────────────────────────────
        function basisRequest(resource, extra = {}) {
            return new Promise((resolve, reject) => {
                const id = `r${++_seq}`;
                _pending[id] = { resolve, reject };
                window.parent.postMessage({ type: 'basis:request', id, resource, ...extra }, '*');
            });
        }

        // ── Actions ──────────────────────────────────────────────────
        async function loadVoucher() {
            try {
                const voucher = await basisRequest('voucher');
                document.getElementById('out').textContent =
                    JSON.stringify(voucher, null, 2);
            } catch (e) {
                document.getElementById('out').textContent = 'Error: ' + e.message;
            }
        }

        async function submit() {
            // Example: save settings then show toast + refresh
            window.parent.postMessage({
                type: 'basis:setAddinSettings',
                key:  'my-addin-v1',
                settings: { lastSubmit: new Date().toISOString() }
            }, '*');

            window.parent.postMessage({
                type: 'basis:toast', intent: 'success', message: 'Done!'
            }, '*');

            window.parent.postMessage({ type: 'basis:refresh' }, '*');
            window.parent.postMessage({ type: 'basis:close' }, '*');
        }

        function closeMe() {
            window.parent.postMessage({ type: 'basis:close' }, '*');
        }
    </script>
</body>
</html>

Security Model

Concern How it is handled
Origin validation Basis validates event.origin === AllowedOrigin on every incoming message. Messages from any other origin are silently dropped.
Authentication All data requests use the logged-in user's server-side session. No token is passed to the iframe.
Permissions The add-in inherits the logged-in user's existing permissions — it cannot read or write data the user cannot access directly.
iframe sandbox allow-scripts allow-same-origin allow-forms allow-popups — no top-level navigation, no storage access beyond the add-in's own origin.
HTTPS Entry URL must use https://. Basis Cloud rejects http:// origins entirely.