Appearance
portal
22 endpoints.
GET /api/v1/portal/recent-customers
Recent Customers
200 — Successful Response
json
{}GET /api/v1/portal/brand-insights
Brand Insights
200 — Successful Response
json
{}GET /api/v1/portal/status
Status
The linked portal, this site's settings and its portal-access summary.
200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Status Api V1 Portal Status Get"
}GET /api/v1/portal/sync
Sync View
Both sides of the link table-by-table, the drift between them, and the sync job's run log.
200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Sync View Api V1 Portal Sync Get"
}POST /api/v1/portal/sync/run
Sync Run
Run the sync now, through the scheduler's own executor — so a manual run is logged, counted and single-flighted exactly like a scheduled one, and lands in the same log this tab reads back. Superuser-only: Phase B creates logins and emails customers.
200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Sync Run Api V1 Portal Sync Run Post"
}PUT /api/v1/portal/settings
Put Settings
Save the integration settings. Superuser-only: these decide whether customers are handed portal logins at all, which is a platform-policy switch rather than a day-to-day one.
Request body
json
{
"properties": {
"auto_grant": {
"type": "boolean",
"title": "Auto Grant",
"default": true
},
"include_resellers": {
"type": "boolean",
"title": "Include Resellers",
"default": true
},
"grant_on_customer_create": {
"type": "boolean",
"title": "Grant On Customer Create",
"default": true
}
},
"type": "object",
"title": "SettingsIn"
}200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Put Settings Api V1 Portal Settings Put"
}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/portal/link
Portal Link
Which portal fronts this site — {site, host, url}, url null when none.
The same _portal_view the console's /status returns, minus the settings and adoption counts it can only read with the app installed. The sidebar's Quick Access row asks this: a link needs a portal, not a licence.
Null on a customer-portal site ITSELF (2026-08-31, owner: "the portal does not need a self service portal quick link"). _portal_view answers for a BOOKS site, and every portal resolves its own books site's portal back to itself — lubb_portal -> lubb -> lubb_portal — so the sidebar rendered a "Self-Service Portal" row that opened a new tab on the page you were already looking at. It affected all four portals in the estate, not just this one. The console's /status is untouched: that runs on the ERP, where "which portal fronts me" is exactly the question being asked.
200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Portal Link Api V1 Portal Link Get"
}GET /api/v1/portal/iptv-line-owners
Iptv Line Owners
Who each Box Seats line belongs to — for the IPTV admin on admin.boxseats.tv, which runs on the Dispatcharr box (2026-09-25) with only the IPTV tables and so cannot see Box Seats' subscriptions, web trials or CRM leads itself.
On link_router because it is server-to-server: the ordinary portal router sits behind social_tenant_ctx, which needs a signed-in user and answered this call 404. Server-to-server only: the X-Iptv-Owners-Secret header must match settings.IPTV_LINE_OWNERS_SECRET, and an empty secret refuses every call. Read from bs — Box Seats' books — whatever host this arrives on, by the same resolver the hub's own Lines grid used (app.apps.portal.grids.iptv._line_owners), so both can never disagree.
200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Iptv Line Owners Api V1 Portal Iptv Line Owners Get"
}GET /api/v1/portal/summary
Summary
200 — Successful Response
json
{}GET /api/v1/portal/reseller-summary
Reseller Summary
The headline figures for a reseller's own channel — what the Resellers dashboard tab draws (2026-09-12, owner: "resellers to see resellers tab").
Three counts, each the count of a grid the reseller can open, and each scoped by the SAME helpers those grids scope with (_reseller_sub_customer_ids / _reseller_own_ids / _reseller_invoicing_enabled / reseller_invoice_sendable in portal/grids/portal.py) rather than by a second expression of the ownership rule. That matters more here than it looks: the reseller-billed invoice shape (billed to the reseller, reference naming the customer it covers) is ~15 of 19 rows on tc, and a naive customer_id IN <sub-users> count would read zero for most resellers while their grid showed six.
Answers 200 with available: false rather than 403 when this login has no reseller account of its own — a platform superuser is the usual case, and the tab simply is not offered to them. Same contract as GET /reseller-settings/clients and /domains, and for the same reason: "you are signed in as an operator" is a fact about the page, not a failed request, and returning it as an error renders a red "Failed to load" where an explanation belongs.
Best-effort throughout: an unreachable books site answers zeros with available: false, never a 500. The tab is a summary of grids the reseller can always open directly.
200 — Successful Response
json
{
"additionalProperties": true,
"type": "object",
"title": "Response Reseller Summary Api V1 Portal Reseller Summary Get"
}GET /api/v1/portal/voice-usage
Voice Usage
Call Records (CDR) stats for the signed-in customer's PBX domain(s), plus what they currently buy in monthly airtime versus what their own call history says they should.
Everything here is read live from the PBX box — call volume/minutes/spend from GET /api/v1/call-history (whole-window aggregates only, never the rows), the wallet/unlimited plan state from GET /api/v1/balance/summary. The one local input is the customer's active VOICE-AIR-R50 subscription quantity, which is what actually gets credited to the wallet each month by the portal's own native recharge job and therefore IS their current monthly airtime.
Best-effort like the rest of the Overview: a customer with no voice domain, an unreachable PBX or an unconfigured connection gets {"available": false} and the frontend simply omits the section, rather than an error that breaks the page.
200 — Successful Response
json
{}GET /api/v1/portal/webstore
Webstore
Origin of the public webstore this portal fronts — the "Visit Webstore" / "Place an Order" buttons' target, which the frontend turns into an auto-login link by appending ?sso_token=<the caller's own portal JWT> (exchanged for a website session by app.apps.instances.api.public_website.sso_exchange).
The storefront lives on the BOOKS site (portal.telecloud.co.za → tc, portal.boxseats.tv → bs), so the domain is read from that site's websites row rather than hardcoded: the verified custom_domain when its provisioning reached live, else the always-working temp_domain (<slug>.lubb.co.za). url: None when the books site doesn't run the storefront app at all, has no storefront row, hasn't published one, or has neither domain yet — the frontend hides the buttons entirely in that case rather than linking somewhere that 404s.
The website check is not just an optimisation: websites is an app-gated table (models.tables_for_site), so on a books site without that app installed — bs, whose portal is portal.boxseats.tv — the table does not exist and querying it raises UndefinedTable, 500ing the portal overview page instead of quietly hiding the buttons.
200 — Successful Response
json
{}GET /api/v1/portal/pay-link
Pay Link
The "Make a Payment" link for the signed-in customer's left-menu button — resolves the most recent Sales Invoice belonging to a linked customer that's actually payable right now, and returns the same public invoice view already linked from invoice emails/PDFs (see app.api.public_documents.view_invoice), which carries the exact same "Pay Now" button. Payability (submitted, not a credit note, Paystack-settlable currency, and the customer's whole ACCOUNT balance — not just this invoice's own total, balance-forward — actually owing something) is delegated to accounting_pdf.invoice_pay_urls, the single source of truth the invoice's own embedded Pay Now button uses, so this can never drift out of sync with it. url is an absolute link to the books site's own domain (see _books_base_url) — the invoice's pay token only resolves against tc's own DB, not the caller's (portal) site; url: None when the login has no linked customer or nothing currently payable.
200 — Successful Response
json
{}GET /api/v1/portal/order-form
Order Form
What the New Order form needs: the customer it is locked to, and the items this account can order at the price they will be charged.
200 — Successful Response
json
{}POST /api/v1/portal/orders
Place Order
Raise and submit a Sales Order for the signed-in customer, and hand back its Paystack Pay Now link. Paying it invoices the order and raises its Activations (see the submit note below).
Request body
json
{
"properties": {
"items": {
"items": {
"properties": {
"item_id": {
"type": "integer",
"title": "Item Id"
},
"qty": {
"type": "number",
"maximum": 100000,
"exclusiveMinimum": 0,
"title": "Qty"
}
},
"type": "object",
"required": [
"item_id",
"qty"
],
"title": "PortalOrderLine"
},
"type": "array",
"maxItems": 100,
"minItems": 1,
"title": "Items"
},
"notes": {
"anyOf": [
{
"type": "string",
"maxLength": 1000
},
{
"type": "null"
}
],
"title": "Notes"
}
},
"type": "object",
"required": [
"items"
],
"title": "PortalOrderIn"
}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/portal/document-pdf/{voucher_type}/{voucher_id}
Document Pdf
Return the PDF of one accounting document (invoice or payment) as base64, for the portal Statement's clickable reference link. Reads cross-DB from the books site like everything else on the portal; authorises by checking the document's customer is one the caller's login is linked to.
Only acc_sales_invoice (invoice / credit note) and acc_payment_entry (payment / refund) are supported — journal entries carry no customer-facing PDF and return 404.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
voucher_type | path | yes | string | |
voucher_id | path | 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"
}POST /api/v1/portal/contact
Contact
Portal "Contact Us" form, reachable from the account menu — always goes to the central helpdesk inbox (not the per-site branding.support_email, unlike the website's public contact form) so every site's enquiries land in one place, tagged with the site/org they came from. Name/email are read off the authenticated session rather than trusted from the client, since this router already runs behind social_tenant_ctx (wired in main.py).
Request body
json
{
"properties": {
"subject": {
"anyOf": [
{
"type": "string",
"maxLength": 200
},
{
"type": "null"
}
],
"title": "Subject"
},
"message": {
"type": "string",
"maxLength": 5000,
"minLength": 1,
"title": "Message"
}
},
"type": "object",
"required": [
"message"
],
"title": "ContactIn"
}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/portal/boxseats
Boxseats
The signed-in customer's own Box Seats lines, credentials included.
Returns {available, server_url, download_url, setup_url, support_email, lines: [...]}. available is False — with an empty lines — on every site that does not sell this, so the card simply never renders elsewhere and the frontend needs no per-site knowledge.
SCOPED EXACTLY LIKE :func:summary, and that is the whole security of this endpoint: _org_want is the org switcher's own answer, subscriptions are matched on customer_id (the login's AccCustomer) or reseller_user_id (a sub-user login's own handle), and a blank want returns nothing. A superadmin on the all-organisations view gets nothing either — "every org" is a meaningful aggregate for invoices and a plaintext dump of every customer's password for this.
THE CREDENTIAL COMES OFF DISPATCHARR, keyed by the account name recorded on the subscription (provision_data.dispatcharr_username) — the same link app.apps.portal.grids.backbone._send_boxseats_login_details reads, and for the same reasons its docstring gives: the supplier lines behind IptvLine are the ones we BUY and authenticate nowhere a customer is pointed, and the name is never re-derived from the customer's own name because that derivation is positional and re-deriving it in a second place is what once minted nine duplicate accounts.
A subscription whose account is missing or not on the box still comes back, with username: null and a note. A customer with a live service and a blank card would read it as the service being gone; "we're still setting this up, contact us" is both truer and actionable.
200 — Successful Response
json
{}GET /api/v1/portal/app-dashboard/apps
App Dashboard Apps
Which apps this audience's picker may offer. apps: null for the superadmin audience — its tabs are the Superadmin container's, which the menu only sends to the operator seat.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
audience | query | no | string |
200 — Successful Response
json
{
"type": "object",
"additionalProperties": true,
"title": "Response App Dashboard Apps Api V1 Portal App Dashboard Apps Get"
}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/portal/app-dashboard
App Dashboard
One app's chart figures — see the module docstring.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
app | query | yes | string | |
audience | query | no | string | |
slugs | query | no | string | |
days | query | no | integer |
200 — Successful Response
json
{
"type": "object",
"additionalProperties": true,
"title": "Response App Dashboard Api V1 Portal App Dashboard Get"
}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/portal/app-dashboard/groups
Subscription Groups
The item groups holding this audience's live/suspended subscriptions, each with the operator console that serves it (apps) folded in.
groups is this door's own books (the customer picker's flat list); instances is the superadmin tree — every family brand this door may read, each with its own books' groups, the consoles paired across the lot.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
audience | query | no | string |
200 — Successful Response
json
{
"type": "object",
"additionalProperties": true,
"title": "Response Subscription Groups Api V1 Portal App Dashboard Groups Get"
}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/portal/app-dashboard/group
Subscription Group Dashboard
One item group's charts: status, subscriptions and units by product, new and cancelled per month (12 months), and — superadmin only — the customers holding the most of them. instance (superadmin tree) names which family brand's books to read; unset reads this door's own.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
group_id | query | yes | integer | |
audience | query | no | string | |
instance | query | no |
200 — Successful Response
json
{
"type": "object",
"additionalProperties": true,
"title": "Response Subscription Group Dashboard Api V1 Portal App Dashboard Group Get"
}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"
}