API Access in Apps
How Operator Apps talk to ERP.net and other services
Apps built with the App Builder can read and write ERP.net data through the ERP.net Domain API, and they can also call external REST APIs when needed. This topic explains how both work, what limits apply, and how to handle errors.
What Apps Can Do with ERP.net Data
Apps built with the App Builder have access to the ERP.net Domain API (OData v4), enabling them to:
- Query data — Fetch and display records from any ERP entity
- Create records — Build forms that create new entries in the system
- Update records — Modify existing data with validation
- Display dashboards — Visualize data with charts, tables, and summary cards
- Filter and search — Add interactive controls for data exploration
- Respond to context — React to the connected instance and enterprise company
- Call AI — Invoke Pure AI for one-shot completions or run full Operator agents (built-in or user-defined) with tools and capabilities. See AI in Apps.
- Run server-side scripts — Execute server-side JavaScript on the connected ERP.net instance for work that is more efficient to run next to the data, such as batch processing, transformations, or multi-step operations. This runs with the user's token and permissions — it does not grant elevated access.
Data Volume Limits
App Builder apps are lightweight client-side applications with strict data limits to ensure performance and stability:
- Maximum 1,000 rows per request — Each API call can return at most 1,000 rows. Always apply filters to keep result sets within this limit.
- Maximum 10,000 rows in memory — The app should never accumulate more than 10,000 rows across all loaded data sets combined.
If your use case requires processing larger volumes of data, consider these strategies:
- Narrower filters — Use date ranges, status filters, or other criteria to reduce the data set.
- Paginated views — Load one page of data at a time without keeping all pages in memory.
- Summary queries — Use aggregate or summary data instead of loading raw detail rows.
- Server-side processing — Offload heavy data processing to the ERP system rather than pulling everything into the browser.
Domain API Transactions
The ERP.net Domain API allows only one open transaction per session. If an app opens a transaction (for a multi-step write that must be atomic) and fails to close it after an error, the session becomes unusable for any further transactional work until it expires — every later attempt fails with "Maximum allowed transactions for this session have been reached." The App Builder is instructed to avoid transactions when a single write would do, and — when one is genuinely needed — to wrap it in a try / catch / finally block so the transaction is always rolled back on error. You don't need to ask for this behavior; the App Builder applies it automatically. If you ever see this error from an older app, regenerate or patch it in the App Builder to pick up the safer pattern.
Error Handling for ERP.net Calls
When window.operator.fetch(...) fails, the rejected Error carries the exact ERP.net server message, so apps should surface it to the user instead of a generic "failed" toast. The error object includes:
error.message— the ERP.net server message (already in the instance's language, e.g. *"Вие нямате достъп до обект Gen_Products(**73d7a)").error.isErpError—truewhen ERP.net rejected the request,falsefor transport/authentication failures.error.status— HTTP status returned by ERP.net (e.g.403,500).error.statusText— friendly label (e.g. "ERP.net permission denied", "ERP.net rejected the request").error.code,error.type,error.details— when ERP.net provides them.
Apps generated by the App Builder follow this pattern automatically:
try {
await window.operator.fetch('/Production_Technologies_RecipeIngredients', {
method: 'POST',
body: payload,
});
} catch (e) {
if (e.isErpError) {
toast.error('ERP.net rejected the change', { description: e.message });
} else {
toast.error('Could not reach ERP.net', { description: e.message });
}
}
Most "the app can't save to ERP.net" reports are in fact ERP.net permission errors — the connected user lacks read or write access on a referenced entity. The full error is also recorded in the app's runtime logs (visible to the App Builder agent), which usually points to the exact missing permission.
The same e.message also carries proxy-side reasons such as "Your connection to ERP.net expired and could not be refreshed. Please reconnect the instance." or "No ERP.net instance is currently selected." — apps do not need any special-case code; surfacing e.message is always meaningful. These improvements apply live to every existing app on the next page reload — no rebuild or App Builder edit is required.
Automatic request serialization
ERP.net rejects concurrent calls from the same session with HTTP 429. To prevent this, Operator automatically serializes every window.operator.fetch() call inside the app — even if the app code accidentally fires them in parallel (e.g. Promise.all([...])), Operator queues them and sends one at a time. App authors should still write sequential await code for clear loading and error states, but this safety net protects existing apps from accidental 429s with no rebuild needed.
Connection validation on app start
When a user opens an app at /app/:slug, Operator validates the active ERP.net connection before mounting the app iframe. If the access token is expired, Operator silently attempts to refresh it. If the refresh fails, the app does not load — instead, Operator displays a full-screen "Session expired for {instance}" overlay with a Reconnect button that walks the user through the ERP.net sign-in flow. As soon as sign-in completes, the iframe boots with a fresh token and the app runs normally. App authors do not need to handle this case in code.
External API Access
App Builder apps can call external REST APIs directly using the standard browser fetch() API — no special SDK needed. This is useful for integrating third-party services, public data sources, or custom backends alongside your ERP data.
How It Works
Use fetch() as you would in any web application:
const response = await fetch("https://api.example.com/data", {
headers: { "Authorization": "Bearer YOUR_TOKEN" }
});
const data = await response.json();
CORS Restrictions
Apps run in a browser sandbox, so standard CORS (Cross-Origin Resource Sharing) rules apply:
- If the external API includes an
Access-Control-Allow-Originheader, you can call it directly withfetch(). - If it does not (e.g. EU VIES, many government APIs), the browser blocks the response. For these cases use
window.operator.corsFetch(url, options)— Operator forwards the request server-side and returns the response wrapped in CORS headers your app can read.
const res = await window.operator.corsFetch(
'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ countryCode: 'BG', vatNumber: '121084091' }),
}
);
if (res.ok) console.log(res.json());
corsFetch is not an open proxy:
- Only signed-in Operator users can call it.
- Limited to 60 requests / minute / user.
- Sensitive headers (
Authorization,Cookie) are stripped before forwarding, so never use it for APIs that require a secret API key — build a dedicated backend function so the secret stays server-side. - Internal hosts (
*.erp.net,*.supabase.co,*.lovable.app,localhost, private IPs) are blocked.
Tips
- Public APIs with CORS (weather, exchange rates) — call directly with
fetch(). - Public APIs without CORS — call through
window.operator.corsFetch(). - APIs that need a secret key — neither path is safe in client code; ask for a dedicated server function.
- ERP.net data should always be accessed through
window.operator.fetch(), which handles authentication and proxying automatically. - Sequential requests — As with ERP API calls, avoid concurrent requests to external APIs. Use
awaitfor each call to prevent rate limiting or ordering issues.
Opening Another App
An app can send the user straight into another app they already have access to — useful for hub-and-spoke setups, launchers, or a "back to the main app" link.
// Open another app by its slug (as shown in My Apps)
await window.operator.apps.open('sales-overview');
// Open it in a new tab/window instead of replacing the current view
await window.operator.apps.open('sales-overview', { newTab: true });
Build a launcher from whatever the signed-in user can actually open:
const apps = await window.operator.apps.list();
// [{ slug: 'sales-overview', name: 'Sales Overview', description: '…' }, …]
apps.forEach(app => addTile(app.name, () => window.operator.apps.open(app.slug)));
How it behaves
- Same running mode. Inside an installed app, the target opens in the same installed window. In full screen, it opens full screen. Inside the App Builder preview, it opens in a new tab so the builder is never hijacked.
- Same instance. The target app runs against the ERP.net instance the user is currently working in.
- Access is respected. Only apps shared with the signed-in user can be opened or listed. If the slug is unknown or the user has no access, the call fails with a clear message — wrap it in
try/catchand show a friendly note.
Just tell the App Builder which app to open ("add a button that opens the Deliveries app") — it will look up the correct slug and generate the call for you.
Hosting Another App Inside Yours
Instead of navigating away, an app can host another app in a panel — a workspace app that shows a report app on the right, a dashboard that tiles several small apps, or a tabbed shell around existing apps.
// Fill a container with another app
await window.operator.apps.embedInto('#report-panel', 'sales-overview');
For full control over the iframe, get the ready-to-run HTML instead:
const child = await window.operator.apps.embed('sales-overview');
const frame = document.createElement('iframe');
frame.style.cssText = 'width:100%;height:100%;border:0';
document.getElementById('report-panel').appendChild(frame);
frame.srcdoc = child.html; // child.slug, child.name are also returned
A tabbed workspace hosting two apps:
const tabs = { deliveries: 'deliveries-daily', sales: 'sales-overview' };
async function showTab(key) {
try {
await window.operator.apps.embedInto('#workspace', tabs[key]);
} catch (e) {
document.getElementById('workspace').textContent = e.message;
}
}
showTab('deliveries');
How it behaves
- The hosted app is fully alive. It keeps its own ERP data access, AI calls, email and logging — everything routes through Operator exactly as if the app were opened on its own.
- Same user, same instance. The hosted app runs as the signed-in user, against the instance currently in use, and only apps shared with that user can be hosted.
- Works everywhere. Identical behaviour in the App Builder preview, full screen and installed (PWA) mode.
- One level only. A hosted app cannot host a further app, and an app cannot host itself.
Just describe what you want ("show the Sales Overview app in a panel next to my order list") — the App Builder picks the right slug and writes the embedding code.
Related
- App Builder Overview
- Referencing Other Apps — reusing another app's code while building