Appearance
erp-dashboard
42 endpoints.
GET /api/v1/erp/instances
List Erp Instances
The tabs across the top of a dashboard, in display order — the requesting site's own erp_dashboard_sites override if it has one, else the single default tab over its own books.
scope says WHICH dashboard is asking (see erp_credentials.DashboardScope): own for /dashboard, which reads this site's own books, and group for /group-dashboard, the operator's row of other sites. all — every tab, the historic answer — remains the default so nothing that already calls this changes behaviour.
group_tabs is the size of the group row whatever scope was asked for, which is how /dashboard knows whether to offer the link across to /group-dashboard without a second round trip. It is 0 everywhere but the hub.
The GROUP row is resolved against home_db — this site's own session — and never against the books switcher, because erp_dashboard_sites is hub CONFIGURATION (which businesses the operator's estate view covers), not books data. get_instance_db follows X-Books-Site, so with the switcher on Telecloud the row was read out of tc's app_settings, whose single stored tab names no other site — _is_cross_instance_tab dropped it and /group-dashboard answered {"sites":[],"group_tabs":0} and rendered blank (2026-09-06). It also silently removed /dashboard's link across to /group-dashboard, by the same route. own/all still follow the switcher: switch to Telecloud and /dashboard reporting on tc's books under tc's branding is the point of the switcher.
home_db costs no second connection — get_switched_books_db already declares get_db as a sub-dependency, so FastAPI hands back the same cached session, and an unswitched request has db is home_db.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
scope | query | no | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/sections
List Sections
The three top-level product sections the category tabs are grouped under (see DASHBOARD_SECTIONS) — static and site-independent, so no filtering by installed app happens here; /categories already carries each category's own "section" id for the frontend to bucket by.
200 — Successful Response
json
{}GET /api/v1/erp/categories
List Categories
The category sub-tabs available on this site.
Filtered to categories an INSTALLED app actually owns (2026-08-27) — a tab belongs to its app now, so a category whose app isn't installed here has no tab to offer. Categories no catalog app owns are kept regardless: the per-person panel ids ("mychats", "calendar") are fetched through their own endpoints rather than an app's dashboard, and a legacy id (_LEGACY_CATEGORY_IDS) is still serving whoever it was already assigned to.
200 — Successful Response
json
{}GET /api/v1/erp/my-chats
My Chats
The My Chats tab's data — the caller's own Lubb Inbox assigned- conversation queue, matched by email. user_id lets a superuser viewing someone ELSE's person tab (e.g. Ahmed on Faizaan's tab) see that person's queue instead of their own; anyone else may only ever fetch their own.
Scoped to the org selected in the sidebar switcher, which owns the Lubb Inbox account this reads (app.services.inbox_accounts). Someone who is an agent in two orgs sees a different queue in each, and someone who is an agent in neither sees an empty tab rather than another org's work.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
user_id | query | no |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/my-chats/signal
My Chats Signal
The poll behind the My Chats tab's "new chat" dot — just the newest assigned-to-me activity timestamp and unread count, deliberately much cheaper than /my-chats above (see inbox_metrics.my_chat_signal) because every signed-in user polls it continuously, on whichever tab they're on.
Always the CALLER's own queue — no user_id override like /my-chats has: the dot means "something new for YOU", so a superuser sitting on someone else's person tab still gets their own signal, not that person's.
200 — Successful Response
json
{}GET /api/v1/erp/inbox/open-conversations
Inbox Open Conversations
The helpdesk's open-conversation count for THIS brand, on its own.
Feeds the customer portal dashboard's KPIs tab (owner, 2026-09-11: "add to kpis, inbox open conversations", then "we use portal.lubb.co.za and switch between sites"). It cannot come from the Inbox tab's own stats: the inbox app is installed on the books sites and not on the portal hosts, so _category_available blanks that category there — and installing the app to get one number would put the whole Inbox surface on the portal.
Narrowed to the door being served. The four portals (portal.lubb.co.za, portal.telecloud.co.za, …) are four hosts onto ONE merged Lubb Inbox account, and the operator moves between them with the family switcher rather than by switching books — so an unnarrowed count would read the same on all four. The brand is this site's Branding company name matched against the inbox's "Site" (see inbox_metrics.open_conversation_snapshot); the estate-wide figures come back alongside it, never instead of it.
Same org scoping and same degrade-to-empty contract as /my-chats above: the account belongs to the org selected in the switcher, and an unconfigured or unreachable Lubb Inbox answers configured: False rather than erroring, so the card simply doesn't render.
Staff-only for free — the whole router sits behind deny_customer_facing_non_staff, which on a customer-facing host admits superusers only, exactly the gate the KPIs tab uses on the frontend.
200 — Successful Response
json
{}GET /api/v1/erp/inbox/live-chat-triage
Inbox Live Chat Triage
The Lubb Desk tab's Live Chat triage: recent Channel::WebWidget conversations bucketed into "genuinely missed" (never got an agent reply), "dead leads" (agent replied, visitor never wrote back, resolved anyway) and "slow but recovered" (slow first reply, visitor kept talking) — see app.services.inbox_metrics.live_chat_triage for the exact rules. Built 2026-09-21 off a manual read of one inbox's history; this is the same read done automatically, across every Live Chat inbox.
Narrowed to this door's brand the same way /inbox/open-conversations is (Branding's company name against the inbox's "Site"); a brand that matches no inbox falls through to every Live Chat inbox on the account rather than showing a false zero — see open_conversation_snapshot's docstring for why that's the right default.
Same degrade-to-empty contract as the rest of this file: an unconfigured or unreachable Lubb Desk answers configured: False rather than erroring, so the card simply doesn't render.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
days | query | no | integer |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/voice/registration-health
Voice Registration Health
The PBX's failed-registration and zero-registration-domain counts, alone.
Feeds the customer portal dashboard's KPIs tab beside the Inbox card (owner, 2026-09-11: "move failed registrations and unregistered domains ... into kpis" / "Failed SIP registrations — get data from pbx"). It cannot come from the Voice tab's own stats for the same reason the Inbox card can't come from the Inbox tab's: pbx_admin/pbx are installed on the books site and not on every portal host — portal.lubb.co.za has neither — so _category_available blanks the voice category there, and installing an app to get two numbers would put the whole PBX (Admin) surface on the portal.
Scoped per viewer exactly as the Voice tab is (see viewer_pbx_domains, which fails closed): a superuser reads the platform, anyone else reads only their own org's PBXs. Staff-only for free — the whole router sits behind deny_customer_facing_non_staff, which on a customer-facing host admits superusers only, the same gate the KPIs tab uses on the frontend.
Degrades to configured: False rather than erroring when the portal credentials are missing or the box is unreachable, so the cards simply don't render.
200 — Successful Response
json
{}GET /api/v1/erp/voice/carrier-topups
Voice Carrier Topups
What to pay each carrier (HEROTEL, BACKSPACE, MTN) per month, with the calculation — the portal staff page's Telecloud section (owner, 2026-09-15). See carrier_topups_snapshot.
Carrier spend is platform money, so only a platform-wide viewer gets it: viewer_pbx_domains answers None for exactly that viewer and a domain list (or []) for anyone scoped to a customer.
200 — Successful Response
json
{}GET /api/v1/erp/instances/{instance}/stats
Erp Instance Stats
Live cards + recent items for one dashboard tab and category over days.
A card only carries a link when it has an external home to open — today the CloudTools portal cards; every other card drills into this platform's own grids from the frontend. A tab whose target site is missing still returns 200 with empty cards and an errors map, so it degrades gracefully instead of failing the page.
"operations" is superuser-only (unlike every other category, which "any logged-in user" per the module docstring covers) — it carries the same wholesale PBX margin figures the Voice tab shows a superuser, which must never reach a non-superuser login.
"voice" is readable by anyone but SCOPED to the viewer's own customer since 2026-08-27 (see viewer_pbx_domains). It used to be platform-wide for every logged-in user, which put the whole estate's domains, live registrations and call volumes in front of a customer admin on the customer-facing portal. It is scoped rather than superuser-gated because unlike Operations it is genuinely useful to a customer once narrowed — their own extensions, registrations and call spend.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
instance | path | yes | string | |
category | query | no | string | |
days | query | no | integer |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/public/instances
Public Instances
Tabs available on the public dashboard (id + label only). Doubles as the password-check endpoint for the unlock screen.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
x-public-password | header | no |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/public/categories
Public Categories
Category sub-tabs for the public dashboard — the internal set minus Voice (PBX platform internals stay off the shareable view).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
x-public-password | header | no |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/public/instances/{instance}/stats
Public Instance Stats
Stats (cards + items + charts) for one tab and category — password-gated. Portal cards deep-link just like the internal dashboard (opening them still requires the viewer's own portal login). Unknown/unconfigured tabs 404; unknown category 400.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
instance | path | yes | string | |
category | query | no | string | |
days | query | no | integer | |
x-public-password | header | no |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/tab-groups
Get Tab Groups
This site's per-person dashboard tab groups (empty = feature off). Readable by any logged-in user — the dashboard needs it to render. Dashboard visibility is per-person: a non-superuser only receives their OWN tab; only superusers (Ahmed) get everyone's. enabled says the feature is on tenant-wide even when the caller's filtered list came back empty, so the dashboard can show just the Tasks tab instead of falling back to the flat all-apps row. tasks_enabled gates the whole Tasks feature (Tasks tab + Timesheet + card timers) for this site.
200 — Successful Response
json
{}PUT /api/v1/erp/tab-groups
Put Tab Groups
Replace the tab-group list. An empty list turns per-person tabs off. Superuser-only: GET is filtered per person for everyone else, so a non-superuser saving the whole list would silently wipe the tabs they can't see.
Request body
json
{
"properties": {
"groups": {
"items": {
"properties": {
"user_id": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "User Id"
},
"label": {
"type": "string",
"maxLength": 64,
"minLength": 1,
"title": "Label"
},
"slugs": {
"items": {
"type": "string"
},
"type": "array",
"title": "Slugs"
}
},
"type": "object",
"required": [
"label"
],
"title": "TabGroupIn"
},
"type": "array",
"title": "Groups"
}
},
"type": "object",
"required": [
"groups"
],
"title": "TabGroupsIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}POST /api/v1/erp/system/failed-emails/checked
Mark Failed Emails Checked
Mark every not-yet-checked failed send in the last days as triaged — the "Mark all as checked" button on the System tab's failed-emails table (app.apps.erp.services.system_metrics._failed_emails_table).
Checked rows drop off that table so a new failure isn't buried under a stretch of already-handled ones; they keep counting in the Failed card and stay in System → Email Log, which is the permanent audit trail. Nothing is deleted and a successful send is never touched.
Superuser-only for the same reason the System tab itself is: infra failure detail isn't for a non-admin login. Scoped to whichever site's db session the caller opened, exactly like the tab that renders it (email_logs is a per-site-DB table).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
days | query | no | integer |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/tasks-enabled
Put Tasks Enabled
Turn this site's dashboard Tasks feature on/off (Tasks tab, Timesheet button, card timers). Superuser-only, like the rest of Dashboard settings; stored per site so disabling it on eldo never touches tc.
Request body
json
{
"properties": {
"enabled": {
"type": "boolean",
"title": "Enabled"
}
},
"type": "object",
"required": [
"enabled"
],
"title": "TasksEnabledIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/grid-tabs
Put Grid Tabs
Set which app dashboards the Grid tab lists (see _GRID_TABS_KEY). Superuser-only, like the rest of Dashboard settings.
Request body
json
{
"properties": {
"tabs": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Tabs"
}
},
"type": "object",
"title": "GridTabsIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/tab-groups/users
Tab Group Users
Active users assignable to a tab group (the settings dialog's picker). Superuser-only, like the PUT it feeds.
Hidden platform accounts excluded, for the reason the org-users grid gives (app/grids/platform.py): Ahmed and Faizaan are seeded as internal superadmins on EVERY site including customers' own ERPs so support can reach them, and a customer should see their own staff rather than two Lubb logins they did not create. They are also not people a tab group would ever be assigned to.
200 — Successful Response
json
{}GET /api/v1/erp/card-order
Get Card Order
This user's saved stat-card order for one layout key, or [] if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-order
Put Card Order
Upsert this user's stat-card order for one layout key.
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 128,
"minLength": 1,
"title": "Key"
},
"order": {
"items": {
"type": "string"
},
"type": "array",
"title": "Order"
}
},
"type": "object",
"required": [
"key",
"order"
],
"title": "CardOrderIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/card-target
Get Card Target
Company-wide saved targets for one layout key, or {} if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-target
Put Card Target
Merge the company-wide targets for one layout key (null values remove a key), visible to every user on the tenant rather than just the setter.
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 160,
"minLength": 1,
"title": "Key"
},
"targets": {
"additionalProperties": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
]
},
"type": "object",
"title": "Targets"
}
},
"type": "object",
"required": [
"key",
"targets"
],
"title": "CardTargetIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/electricity-balance
Get Electricity Balance
The stored balance for one tab. Readable by any logged-in user — it is already on the card they are looking at; only SETTING it is superuser-only.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
instance | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/electricity-balance
Put Electricity Balance
Replace the balance (superuser only). Stamped with who set it and when — a hand-kept number is only worth reading next to its age, and the card's caption shows both.
Request body
json
{
"properties": {
"site": {
"type": "string",
"maxLength": 64,
"minLength": 1,
"title": "Site"
},
"kwh": {
"type": "number",
"minimum": 0,
"title": "Kwh"
},
"instance": {
"type": "string",
"maxLength": 64,
"minLength": 1,
"title": "Site"
}
},
"type": "object",
"required": [
"site",
"kwh"
],
"title": "ElectricityBalanceIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/card-color
Get Card Color
The site's saved color rules for one layout key, or {} if none. Company-wide (see the section note above), so a KPI someone configured shows up on every dashboard that renders that card — including the tab of the person the app was assigned to.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-color
Put Card Color
Merge the company-wide color rules for one layout key (null values remove a key). Gated like put_card_target above — these rules are shared now, so setting one is an admin act rather than a personal preference.
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 160,
"minLength": 1,
"title": "Key"
},
"colors": {
"additionalProperties": {
"anyOf": [
{
"$ref": "#/components/schemas/ColorRuleSet"
},
{
"type": "null"
}
]
},
"type": "object",
"title": "Colors"
}
},
"type": "object",
"required": [
"key",
"colors"
],
"title": "CardColorIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/card-group
Get Card Group
This user's saved card->group overrides for one layout key, or {} if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-group
Put Card Group
Merge this user's card->group overrides for one layout key (null values remove a key).
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 160,
"minLength": 1,
"title": "Key"
},
"groups": {
"additionalProperties": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"type": "object",
"title": "Groups"
}
},
"type": "object",
"required": [
"key",
"groups"
],
"title": "CardGroupIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/card-hidden
Get Card Hidden
This user's hidden card keys for one layout key, or [] if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-hidden
Put Card Hidden
Replace this user's hidden-card list for one layout key.
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 160,
"minLength": 1,
"title": "Key"
},
"hidden": {
"items": {
"type": "string"
},
"type": "array",
"title": "Hidden"
}
},
"type": "object",
"required": [
"key",
"hidden"
],
"title": "CardHiddenIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/card-admin-hidden
Get Card Admin Hidden
The site-wide admin-hidden card keys for one layout key, or [] if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-admin-hidden
Put Card Admin Hidden
Replace the site-wide admin-hidden card list for one layout key. Admin+ only — this hides the card for every user on the tab, not just the caller. Restorable (unlike card-deleted below).
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 128,
"minLength": 1,
"title": "Key"
},
"hidden": {
"items": {
"type": "string"
},
"type": "array",
"title": "Hidden"
}
},
"type": "object",
"required": [
"key",
"hidden"
],
"title": "CardAdminHiddenIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/card-deleted
Get Card Deleted
The site-wide deleted card keys for one layout key, or [] if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/card-deleted
Put Card Deleted
Replace the site-wide deleted-card list for one layout key. Admin+ only — this permanently removes the card for every user on the tab (no restore UI), not just the caller.
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 128,
"minLength": 1,
"title": "Key"
},
"deleted": {
"items": {
"type": "string"
},
"type": "array",
"title": "Deleted"
}
},
"type": "object",
"required": [
"key",
"deleted"
],
"title": "CardDeletedIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}POST /api/v1/erp/card-campaign
Create Card Call Campaign
Send the people behind a KPI card to the AI Call Center as a new campaign.
The dashboard shows a population — "Quotes — Submitted: 3", "Uncontactable leads: 892" — and until now the only thing you could do with one was open the grid. This lands the same population as a campaign the AI agents dial.
The card key is all the client sends. erp_import.card_source turns it into (source, filters) here, on the server, because a client that could post its own source and filters could dial any table on the books.
Where the campaign lands. Campaigns, their targets and the qualifier agents live with the AI Call Center app, which is not necessarily on the site showing this dashboard (it moved tc -> portal on 2026-07-25). So the destination is DERIVED the same way the Leads grid derives it — the site with convai installed that owns the crm-campaigns grid — and this writes there, in one transaction. Local when this site is that site.
Refuses rather than guessing when there is no such site, more than one, or the destination's tenant of the same id is a different organisation by name (the two sites keep independent tenants tables that merely happen to share ids today).
Request body
json
{
"properties": {
"card_key": {
"type": "string",
"title": "Card Key"
},
"campaign_name": {
"type": "string",
"title": "Campaign Name"
},
"site_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
},
"instance_id": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
},
"type": "object",
"required": [
"card_key",
"campaign_name"
],
"title": "CardCampaignIn",
"description": "Turn one KPI card into an AI Call Center campaign."
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}POST /api/v1/erp/card-prefs
Get Card Prefs
Every site-wide card surface for many layout keys at once.
Read-only despite the verb (see CardPrefsIn). Returns one map per kind, {layout_key: value}, carrying an entry for EVERY key asked for — defaulted exactly as the single-key handlers above default a missing row ({} for target/color, [] for deleted/admin-hidden) — so the caller never has to distinguish "not stored" from "not returned".
Request body
json
{
"properties": {
"keys": {
"additionalProperties": {
"items": {
"type": "string"
},
"type": "array"
},
"type": "object",
"title": "Keys"
}
},
"type": "object",
"title": "CardPrefsIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/section-title
Get Section Title
The site-wide custom section titles for one layout key, or {} if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/section-title
Put Section Title
Replace the site-wide section-title overrides for one layout key. Admin+ only — renames the section for every user on the tab. Blank entries are dropped (that section reverts to its default label).
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 128,
"minLength": 1,
"title": "Key"
},
"titles": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Titles"
}
},
"type": "object",
"required": [
"key",
"titles"
],
"title": "SectionTitleIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}GET /api/v1/erp/canvas-layout
Get Canvas Layout
This user's saved canvas layout for one layout key, or {} if none.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
key | query | yes | string |
200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}PUT /api/v1/erp/canvas-layout
Put Canvas Layout
Replace this user's canvas layout for one layout key.
Request body
json
{
"properties": {
"key": {
"type": "string",
"maxLength": 160,
"minLength": 1,
"title": "Key"
},
"layout": {
"additionalProperties": {
"$ref": "#/components/schemas/CanvasCell"
},
"type": "object",
"title": "Layout"
}
},
"type": "object",
"required": [
"key",
"layout"
],
"title": "CanvasLayoutIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}DELETE /api/v1/erp/stale-records
Delete Stale Records
Cancel every stale record backing one of the dashboard's stale-item cards.
card_key must be quotes_stale or orders_stale. Admin+ only — this is a bulk write across all customer-scoped rows the current user can see, so it needs the same guard as card-admin-hidden / card-deleted.
Returns the number of records cancelled, or 0 if none matched.
Request body
json
{
"properties": {
"card_key": {
"type": "string",
"maxLength": 64,
"minLength": 1,
"title": "Card Key"
}
},
"type": "object",
"required": [
"card_key"
],
"title": "StaleRecordsDeleteIn"
}200 — Successful Response
json
{}422 — Validation Error
json
{
"properties": {
"detail": {
"items": {
"properties": {
"loc": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
},
"type": "array",
"title": "Location"
},
"msg": {
"type": "string",
"title": "Message"
},
"type": {
"type": "string",
"title": "Error Type"
}
},
"type": "object",
"required": [
"loc",
"msg",
"type"
],
"title": "ValidationError"
},
"type": "array",
"title": "Detail"
}
},
"type": "object",
"title": "HTTPValidationError"
}