Add-in memungkinkan Anda menyematkan aplikasi web pihak ketiga ke dalam Basis sebagai dialog modal. Add-in berjalan dalam iframe yang terisolasi (sandboxed) dan berkomunikasi dengan Basis melalui protokol postMessage yang terstruktur — Basis menjadi perantara semua akses data menggunakan sesi pengguna yang sedang login, sehingga add-in tidak memerlukan kredensial tersendiri.
Contoh penggunaan:
Buka Administrasi → Ekstensi / Add-In → Baru.
| Field | Keterangan |
|---|---|
| Nama | Ditampilkan pada tombol/menu di dalam Basis. |
| Entry URL | URL lengkap aplikasi web add-in, mis. https://zatca.example.com/basis. Harus menggunakan https://. |
| Allowed Origin | Origin (scheme + host) yang diterima Basis untuk pesan masuk, mis. https://zatca.example.com. Divalidasi pada setiap pesan yang masuk. |
| Placement | Lokasi tombol add-in ditampilkan. Pilih satu atau lebih dari daftar. |
| Lebar / Tinggi Modal | Nilai CSS untuk ukuran dialog, mis. 85vw / 85vh. Di perangkat mobile dialog selalu melebar hampir penuh layar. |
| Aktif | Add-in yang tidak aktif disembunyikan dari semua pengguna. |
| Placement | Lokasi tombol |
|---|---|
| Sales Invoice | Halaman preview Faktur Penjualan |
| Purchase Invoice | Halaman preview Faktur Pembelian |
| Credit Note | Halaman preview Nota Kredit |
| Debit Note | Halaman preview Nota Debit |
| Sales Order | Halaman preview Sales Order |
| Purchase Order | Halaman preview Purchase Order |
| Delivery Note | Halaman preview Surat Jalan |
| Receipt Note | Halaman preview Nota Penerimaan |
| Payment | Halaman preview Pengeluaran |
| Receipt | Halaman preview Penerimaan |
| Journal Entry | Halaman preview Jurnal |
| Contra Entry | Halaman preview Kontra Voucher |
| Global | Bilah navigasi atas — selalu terlihat di semua halaman |
Add-in dengan placement Global menampilkan tombolnya di navigasi atas, di sebelah tombol tema. Add-in Global tidak menerima entityId dan cocok untuk operasi lintas dokumen (dasbor, pengiriman massal, dll.).
Jika beberapa add-in berbagi placement yang sama, Basis menampilkan dropdown menu yang menampilkan semua add-in di bawah label "Ekstensi".
Add-in adalah aplikasi web biasa yang di-host di mana saja (server sendiri, CDN, SaaS). Basis memuatnya dalam <iframe> dengan atribut:
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
Tidak perlu instalasi SDK. Komunikasi dilakukan melalui postMessage biasa. Origin add-in harus sesuai dengan field Allowed Origin yang didaftarkan di Basis — Basis menolak semua pesan dari origin lain.
Basis (host) Add-In (iframe)
─────────────────────────────────────────────────
Pengguna buka halaman preview
→ klik tombol → buka modal iframe
→ iframe selesai load → basis:init ───────────►
Add-in terima konteks
(placement, entityId…)
◄─────────── basis:request (resource)
→ proxy ke service layer
→ basis:response ─────────────────────────────►
Add-in render UI
◄─────────── basis:setAddinSettings
◄─────────── basis:toast / basis:close
Basis menangani semua pemanggilan service di sisi server menggunakan sesi pengguna yang login. Add-in tidak pernah menerima token, kredensial, atau akses langsung ke database.
Saat iframe selesai dimuat, Basis mengirim pesan basis:init:
window.addEventListener('message', (event) => {
// SELALU validasi origin sebelum memproses
if (event.origin !== 'https://host-basis-anda.com') return;
const msg = event.data;
if (msg.type !== 'basis:init') return;
console.log(msg.placement); // mis. "SalesInvoice"
console.log(msg.entityType); // mis. "SalesInvoice"
console.log(msg.entityId); // string UUID, null untuk Global
console.log(msg.language); // "en" atau "id"
});
Payload basis:init:
{
"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 | Nilai |
|---|---|
placement |
Nama enum placement yang didaftarkan (mis. "SalesInvoice") |
entityType |
String tipe dokumen. CreditNote dan DebitNote berbagi placement dengan SalesInvoice/PurchaseInvoice tetapi memiliki nilai entityType yang berbeda. |
entityId |
UUID dokumen saat ini, atau null untuk Global. |
language |
Kode bahasa dua huruf pengguna aktif ("en" atau "id"). |
userId |
UUID pengguna yang sedang login. |
userName |
Nama tampilan pengguna yang sedang login. |
userEmail |
Alamat email pengguna yang sedang login. |
businessId |
Identifier internal bisnis aktif. |
businessName |
Nama tampilan bisnis aktif. |
businessRole |
Peran pengguna di bisnis ini: "Owner", "Admin", "Staff", dll. |
Simpan nilai-nilai ini — Anda akan menyertakan entityId pada permintaan data berikutnya.
Kirim pesan basis:request dan terima basis:response (atau basis:error) kembali.
Fungsi helper:
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 memvalidasi origin di server; gunakan '*' di sini
);
});
}
window.addEventListener('message', (event) => {
if (event.origin !== 'https://host-basis-anda.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];
}
});
voucherMengambil dokumen saat ini (berdasarkan entityType + entityId dari basis:init):
const invoice = await basisRequest('voucher');
// Mengembalikan VoucherDetailResponse atau objek detail order lengkap
Mengembalikan DTO yang sama dengan REST API Basis untuk tipe dokumen tersebut. GUID seperti partyId, itemId, dan taxCategoryId disertakan tetapi tidak di-resolve ke objek lengkap.
voucherFullMengambil dokumen saat ini beserta semua entitas yang direferensikan, dalam satu panggilan. Dirancang untuk integrasi yang membutuhkan data lengkap tanpa melakukan banyak request tambahan.
const data = await basisRequest('voucherFull');
// {
// voucher: { …voucher lengkap… },
// party: { name, taxNumber, addresses, customFields, … },
// items: { "<itemId>": { code, name, customFields, … }, … },
// taxCategories: { "<taxCategoryId>": { name, customFields, rates: [ { name, rate, customFields, … } ] }, … },
// customFieldDefs: [ { id, name, dataType, … }, … ]
// }
items adalah map itemId → ItemDetailResponse. Custom fields pada item (mis. kode HS, negara asal) disertakan di dalam setiap objek item.
taxCategories adalah map taxCategoryId → TaxCategoryResponse. Setiap entri memuat nama kategori, customFields-nya, dan array rates — masing-masing tarif juga memiliki customFields sendiri. Ini adalah tempat yang tepat untuk menyimpan kode invoicing yang dibutuhkan standar e-faktur (mis. kode jenis pajak DJP, kode kategori ZATCA), karena kode tersebut merupakan bagian dari konfigurasi pajak, bukan dari dokumen itu sendiri.
customFieldDefs mendaftar semua definisi field untuk bisnis ini, sehingga add-in dapat menginterpretasi dictionary customFields (yang diindeks oleh CustomFieldDef.Id).
Persentase tarif pajak per baris juga tersedia di voucher.ledgerEntries — entri dengan sourceType = "Tax" memuat taxRate, taxableAmount, dan taxConfigName, dihubungkan kembali ke barisnya melalui sourceItemAllocationId.
businessMengambil profil bisnis aktif:
const biz = await basisRequest('business');
// { name, taxNumber, address, city, country, currency, … }
addinSettingsMengambil pengaturan tersimpan milik add-in (disimpan di database bisnis):
const settings = await basisRequest('addinSettings', { key: 'my-addin-v1' });
// Mengembalikan objek yang sebelumnya disimpan via basis:setAddinSettings
// Mengembalikan {} jika belum ada pengaturan yang disimpan
key adalah string stabil yang Anda definisikan dalam kode add-in. Key ini bertahan meskipun add-in dihapus dan didaftarkan ulang, atau URL host berubah — Basis menyimpannya secara terpisah dari record AddInDef.
apiProxy penuh ke REST API Basis. Berikan path relatif dan method HTTP — Basis meneruskan permintaan di sisi server menggunakan sesi dan izin pengguna yang login. Segmen businessId ditambahkan otomatis; jangan sertakan dalam path.
// Daftar faktur penjualan (paginated)
const list = await basisRequest('api', {
method: 'GET',
path: '/sales?page=1&pageSize=20'
});
// Ambil detail pihak (NPWP, alamat, dll.)
const party = await basisRequest('api', {
method: 'GET',
path: `/parties/${partyId}`
});
// Buat record baru
const invoice = await basisRequest('api', {
method: 'POST',
path: '/sales',
body: JSON.stringify({ date: '2026-06-06', partyId: '…', lines: [ … ] })
});
// Update record
await basisRequest('api', {
method: 'PUT',
path: `/sales/${invoiceId}`,
body: JSON.stringify({ … })
});
// Hapus record
await basisRequest('api', { method: 'DELETE', path: `/sales/${invoiceId}` });
Semua endpoint REST API Basis dapat diakses — /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, dan lainnya. Lihat dokumentasi REST API untuk daftar endpoint lengkap.
Wrapper ApiResponse<T>.data dibuka otomatis sebelum nilai dikembalikan ke add-in.
Envelope respons:
{ "type": "basis:response", "id": "r1", "data": { … } }
Envelope error:
{ "type": "basis:error", "id": "r1", "message": "Not found" }
Simpan konfigurasi add-in ke database bisnis (mis. kredensial API, status onboarding):
window.parent.postMessage({
type: 'basis:setAddinSettings',
key: 'my-addin-v1',
settings: { apiKey: 'abc123', region: 'SA', onboarded: true }
}, '*');
settings dapat berupa objek JSON apa pun. Basis menyimpannya sebagai blob JSON dengan key yang diberikan di dalam database bisnis. Tidak ada respons yang dikirim balik.
Catatan keamanan: Basis menyimpan pengaturan sebagai plaintext. Jangan simpan private key atau secret sensitif langsung di sini. Gunakan layanan manajemen kunci (KMS) Anda sendiri jika enkripsi diperlukan.
Tulis nilai ke custom field pada dokumen yang sedang dibuka (entity di halaman preview). Hanya tersedia di transaction placement — tidak di Global (yang tidak memiliki konteks entity).
window.parent.postMessage({
type: 'basis:setCustomField',
fieldId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', // GUID CustomFieldDef
value: 'REF-20260601-0042'
}, '*');
fieldId adalah GUID CustomFieldDef.Id dari Pengaturan → Custom Fields. Nilai selalu berupa string; Basis mengurai ke tipe penyimpanan yang sesuai:
| Tipe field | Format nilai |
|---|---|
| Single line / Paragraph / List | String teks biasa |
| Number | String desimal invariant culture, mis. "1234.56" |
| Date | ISO 8601, mis. "2026-06-06" atau "2026-06-06T00:00:00Z" |
| Yes/No | "true" atau "false" |
Menggunakan GUID milik add-in (penyimpanan privat): fieldId tidak perlu didaftarkan di Administrasi → Custom Fields. Anda dapat menggunakan GUID yang Anda buat sekali dan hardcode dalam kode add-in. Basis akan menyimpan nilainya di blob customFields.strings milik entity. Berguna untuk menyimpan status integrasi (status pengiriman, nomor referensi, timestamp) langsung pada dokumen tanpa perlu konfigurasi admin.
// GUID privat — hardcoded di add-in, tidak pernah berubah
const SUBMISSION_STATUS_KEY = '11111111-aaaa-bbbb-cccc-000000000001';
window.parent.postMessage({
type: 'basis:setCustomField',
fieldId: SUBMISSION_STATUS_KEY,
value: 'submitted'
}, '*');
Menggunakan field terdaftar (tampil di UI): Jika field harus muncul di form Basis dan dokumen PDF, field tersebut harus didaftarkan di Administrasi → Custom Fields. Karena fieldId berbeda di setiap instalasi Basis, pola discovery yang direkomendasikan adalah:
GET /custom-field-defs via resource:api untuk mendaftar field yang tersedia.basis:setAddinSettings.fieldId → tulis nilai.Tidak ada respons yang dikirim balik. Kirim basis:refresh setelahnya agar halaman preview memuat ulang dan menampilkan nilai yang diperbarui.
window.parent.postMessage({
type: 'basis:toast',
intent: 'success', // 'success' | 'error' | 'warning' | 'info'
message: 'Berhasil dikirim ke ZATCA.'
}, '*');
Instruksikan Basis untuk memuat ulang dokumen saat ini dan meregenerasi PDF/preview:
window.parent.postMessage({ type: 'basis:refresh' }, '*');
Gunakan ini setelah menulis custom field agar pengguna melihat nilai yang diperbarui.
window.parent.postMessage({ type: 'basis:close' }, '*');
Menutup modal. Halaman preview di bawahnya tetap terbuka.
Add-in satu file minimal yang membaca voucher dan menyimpan pengaturan:
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Add-In Saya</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>Add-In Saya</h2>
<button onclick="loadVoucher()">Muat Voucher</button>
<button onclick="submit()">Kirim</button>
<button onclick="closeMe()">Tutup</button>
<pre id="out">Menunggu init…</pre>
<script>
const BASIS_ORIGIN = 'https://app.basis-apps.net'; // ganti dengan host Anda
let _ctx = null;
let _seq = 0;
const _pending = {};
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 diterima:\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));
}
});
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 }, '*');
});
}
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() {
window.parent.postMessage({
type: 'basis:setAddinSettings',
key: 'my-addin-v1',
settings: { lastSubmit: new Date().toISOString() }
}, '*');
window.parent.postMessage({
type: 'basis:toast', intent: 'success', message: 'Selesai!'
}, '*');
window.parent.postMessage({ type: 'basis:refresh' }, '*');
window.parent.postMessage({ type: 'basis:close' }, '*');
}
function closeMe() {
window.parent.postMessage({ type: 'basis:close' }, '*');
}
</script>
</body>
</html>
| Aspek | Cara penanganan |
|---|---|
| Validasi origin | Basis memvalidasi event.origin === AllowedOrigin pada setiap pesan masuk. Pesan dari origin lain diam-diam diabaikan. |
| Autentikasi | Semua permintaan data menggunakan sesi server pengguna yang login. Tidak ada token yang diteruskan ke iframe. |
| Izin akses | Add-in mewarisi izin pengguna yang sedang login — tidak dapat membaca atau menulis data yang tidak dapat diakses pengguna secara langsung. |
| Sandbox iframe | allow-scripts allow-same-origin allow-forms allow-popups — tidak ada navigasi top-level, tidak ada akses storage di luar origin add-in. |
| HTTPS | Entry URL harus menggunakan https://. Basis Cloud menolak origin http:// sepenuhnya. |