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:
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. |
| 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.
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.
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.
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.
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') { /* … */ }
});
voucherFetches 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.
voucherFullFetches 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.
businessFetches the current business profile:
const biz = await basisRequest('business');
// { name, taxNumber, address, city, country, currency, … }
addinSettingsFetches 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.
apiFull 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" }
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.
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:
GET /custom-field-defs via resource:api to list available fields.basis:setAddinSettings.fieldId → write the value.No response is sent back. Send basis:refresh afterward so the preview page reloads and shows the updated value.
window.parent.postMessage({
type: 'basis:toast',
intent: 'success', // 'success' | 'error' | 'warning' | 'info'
message: 'Submitted to ZATCA successfully.'
}, '*');
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.
window.parent.postMessage({ type: 'basis:close' }, '*');
Closes the modal. The underlying preview page remains open.
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>
| 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. |