Skip to content

portal ​

← All modules

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"
}

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
{}

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

NameInRequiredTypeDescription
voucher_typepathyesstring
voucher_idpathyesstring

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

NameInRequiredTypeDescription
audiencequerynostring

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

NameInRequiredTypeDescription
appqueryyesstring
audiencequerynostring
slugsquerynostring
daysquerynointeger

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

NameInRequiredTypeDescription
audiencequerynostring

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

NameInRequiredTypeDescription
group_idqueryyesinteger
audiencequerynostring
instancequeryno

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"
}

Lubb One Documentation