The complete reference and training course for operating the CloudCX control plane — resellers, tenants, billing, automatic call distribution, the voice SBC, every digital channel, supervisor quality tooling, AI insights, security and day-two operations. Every screen is reproduced with annotated walkthroughs and hands-on exercises.
This manual is organised into the chapters listed below. Use the chapter and section numbers (for example § 7.4) as the stable cross-reference throughout — printed page numbers depend on your browser’s print engine and zoom.
Each chapter pairs a branded reproduction of the real CloudCX console screen — with numbered annotation markers — with step-by-step procedures, at least one worked example, and a Try it exercise for the training track. Watch for the four callout styles (Note, Tip, Warning, Danger): they carry the operational judgement around each task.
This is the official handbook and training course for CloudCX, the multi-tenant omnichannel contact-centre-as-a-service (CCaaS) platform. It teaches you to operate the platform control plane — the dark admin console at admin.cloudcx.app — from the very first sign-in through the day-two routines that keep resellers, tenants, channels and the voice fabric healthy. This first chapter explains who the book is for, the platform → reseller → tenant → user hierarchy that every later chapter assumes, and the conventions — callouts, screen mockups, numbered steps and Try-it exercises — the rest of the manual uses. Read it once; refer back to it whenever a later chapter’s notation is unclear.
This manual documents the Platform Admin console — the topmost tier of the platform. Resellers and tenants have their own portals with the same look and a narrower scope; they are covered by the companion Reseller and Agent / Supervisor manuals. Wherever a screen shown here also appears (scoped down) in those portals, the chapter says so.
CloudCX is operated by a small number of trusted people with broad authority. The book is written for two overlapping readers, and most chapters serve both:
The team that runs the whole CloudCX cloud: they create and cap resellers, provision tenants, manage wholesale rate cards and invoices, hold the shared provider keys, wire SIP carriers and least-cost routing, and own platform-wide security and licensing. In the user model this is the admin (often nicknamed super-admin) login, whose tenant_id and reseller_id are both empty — it belongs to no single customer and sees everything.
New operators learning the console before they touch production. Every procedural chapter pairs its instructions with at least one fully worked example and a Try it exercise you can rehearse in a sandbox tenant. Look for the dashed pink boxes; they are your hands-on checkpoints.
You do not need to be a developer to use this manual. You should, however, be comfortable with the vocabulary of a contact centre — queues, agents, DIDs, IVR, SLAs — and willing to work carefully, because actions taken in the admin console affect every customer on the platform. A short glossary of the terms used throughout appears in the appendix; the first time each term is used in a chapter it is briefly defined in place.
You can read the manual front to back as a course, or jump to a chapter and use it as a reference. Cross-references use the stable section number (for example “see § 4.2”), never a page number — printed page numbers depend on your browser’s print engine and zoom. The Table of Contents at the front links to every chapter.
Almost everything in CloudCX hangs off one idea: a strict, four-level tenancy hierarchy. Understanding it now will make every later chapter obvious, because each chapter is really about managing one level — or the relationship between two of them.
Read it left to right as “owns”: the platform owns resellers; a reseller owns its tenants; a tenant owns its users. Money and entitlements flow the same way — the platform sets wholesale rates and seat caps for a reseller; the reseller resells retail plans and seats to its tenants. There is one deliberate shortcut: a tenant may be a direct platform customer with no reseller above it (its reseller_id is empty), in which case the platform plays the reseller’s role for that account.
tenant_id and reseller_id are both null).Every person who signs in anywhere on CloudCX has exactly one user type, drawn from a fixed set of six: admin, reseller, tenant, subtenant, tl (team leader) and agent. The type, together with the account’s owning tenant and reseller, decides what that login can see and do. This manual is concerned with the admin type; the others are operated from the reseller and agent portals.
The table below summarises the levels and where each is managed in the console. The Manage in column doubles as a road-map of the manual: each navigation group becomes one or more chapters.
| Level | What it is | Owns | Managed in (nav group) | Login types |
|---|---|---|---|---|
| Platform | The CloudCX cloud itself | Resellers, shared keys, carriers, global policy | Operate · Platform · Governance | admin |
| Reseller | White-label partner / brand | Its tenants, brand, caps, wholesale plan | Tenancy › Resellers | reseller |
| Tenant | A customer’s contact centre | Queues, DIDs, channels, contacts, users | Tenancy › Tenants | tenant subtenant |
| Users | People who handle interactions | Their own sessions, presence & work | Reseller / Agent portals | tl agent |
Two platform-wide rules recur so often that they are worth fixing in your mind now. Both are called out again at the point of use, but you will save yourself surprises by learning them here.
Throughout the platform, an entitlement or cap of 0 means unlimited, not “none”. A reseller with max_customers = 0 may create any number of tenants. In the console this is shown as the infinity symbol (∞). Set a real positive number to impose a ceiling.
By default every channel routes through CloudCX’s shared provider keys. A reseller may be permitted to bring its own SMS, WhatsApp or SIP credentials; when present, those take precedence for that reseller’s tenants. The toggles live on the reseller record (§ 4).
A third convention worth noting: every record has a status — Active, Inactive or Locked. A login whose account (or whose tenant) is not active cannot sign in, regardless of password. You will use status constantly to suspend a partner or quarantine an account without deleting anything.
The console’s left-hand navigation is grouped to mirror this hierarchy: Tenancy for resellers and tenants, Revenue for the money that flows up the tree, Platform and Voice for the shared infrastructure every level uses, and Governance for the rules that bind them all. Chapter 2 tours that navigation in full.
Every screen in the admin console shares the same frame: a dark navigation rail on the left, a light topbar across the top, and the working content beneath it. Because this manual reproduces each screen as a branded mockup, it helps to know the parts once. The figure below is the Dashboard — the first screen you see after signing in — with the persistent frame elements numbered.
Live state of the entire CloudCX cloud — every reseller, tenant and channel in one place.
Before any of the rest of the manual is useful you must reach the console. Signing in is the simplest procedure in the book, so it is a good place to learn how procedures are presented here: a faithful screen, then numbered steps, then a worked example.
2FA (an authenticator app, or an email one-time code) is off by default and is enabled per account. If yours has it on, sign-in asks for the second factor only after your password is accepted; if it is off, you are signed in straight after the password. Enabling and managing 2FA is covered in the Security chapter (§ Governance).
Worked examples run a procedure end-to-end with realistic values so you can see the result, not just the steps. Here is the sign-in procedure as a brand-new administrator would experience it.
Aanya has just joined the CloudCX operations team. Her account [email protected] was created with the admin user type and 2FA switched on. It is her first morning and she wants to reach the Dashboard.
[email protected] and her password, then selects Sign in.Result: a successful, MFA-protected sign-in landing on the platform overview. Had Aanya mistyped her password she would have seen “Incorrect username or password”; had her account been set to inactive she would have been refused with “User is not active” even with the right password.
Set off from the running text you will find four coloured callouts and one training box. Each carries a specific kind of operational judgement; learn to read them at a glance, because the most important safety guidance in this manual lives inside them.
Background, context or a clarification. Useful to know, safe to skim. Notes never describe a risky action — they explain how something behaves or why a default is set the way it is.
A shortcut, best practice or recommendation that makes a task faster, cleaner or more reliable. Optional, but worth adopting — tips capture how experienced operators actually work.
Proceed with care. The action has consequences that are easy to get wrong — it may affect billing, interrupt live traffic, or be awkward to undo. Read the warning fully before you act.
Stop and think. This is a destructive or irreversible action — deleting a tenant, rotating a shared key, or anything that can cut off many customers at once. Be certain you are in the right place (check the environment chip) before continuing.
Every procedural chapter follows the same rhythm. Recognising it lets you skim for exactly the part you need.
Because the console evolves continuously, this manual does not paste live screenshots. Instead each screen is redrawn as a branded mockup that faithfully matches the real panel’s layout, fields and labels. A mockup carries small numbered annotation markers — the pink circles — and a matching numbered legend beneath it. Marker “3” in the picture always corresponds to item 3 in the list. Each figure ends with an auto-numbered caption such as Figure 1.4 (chapter 1, fourth figure), which is how figures are cross-referenced.
Instructions are given as a numbered list with gradient step chips, like the sign-in procedure in § 1.4.1. Follow them in order. The lead phrase of each step (in bold) is the action; the rest of the sentence is the detail. A procedure is almost always followed by a worked example with concrete values.
Trainees get hands-on practice through Try it boxes — the dashed pink panels. Each poses a small task to perform in a safe sandbox and lists what “done” looks like. They are optional for experienced administrators and essential for the training track.
A few notations recur throughout the manual:
.pathcodemax_customers, reseller_id.This manual is built to print cleanly to A4 — use your browser’s Print (or “Save as PDF”) with background graphics enabled. On-screen running headers and footers are hidden in print; a watermark and page furniture take over.
The chapters that follow walk the console in roughly the order you will use it, grouped exactly like the navigation rail. Use this as a map; each entry is a chapter you can jump to.
The Dashboard (platform overview & live state) and Reports / Analytics — reading the health of the whole cloud at a glance.
Resellers (brand, caps, bring-your-own credentials) and Tenants (the customers and their plans) — the heart of multi-tenant administration.
Billing — accounts and balances, wholesale rate cards, the invoices CloudCX issues, and online (Stripe) or manual settlement.
Call Routing (SIP carriers & least-cost routing), Channels (the omnichannel toggles) and Platform Credentials (the shared, encrypted provider & AI keys).
IVR / Call Flows and outbound Campaigns — the visual builders for the voice fabric.
Security (identity, policy, 2FA, fraud defence), Licensing (the platform entitlement pool) and global Settings.
You hold real authority over real customers. Until you are comfortable, run every Try-it exercise against a dedicated sandbox reseller and tenant — never against a live partner. Confirm the environment chip and the breadcrumb before any change, and prefer setting a record to inactive over deleting it.
A five-minute warm-up to confirm you can read every part of the frame and the hierarchy. Do this in your training environment.
Done when: you can point to all eight numbered frame elements from Figure 1.1 without looking them up, state which level of the hierarchy a given tenant sits at, and recite the build string from the footer.
With the hierarchy and conventions in hand, Chapter 2 gives you the guided tour of the console — the navigation rail, the topbar tools and the shared screen patterns — so that every screen thereafter feels familiar.
Before you provision a single reseller or route a single call, it pays to understand what CloudCX is and how its pieces fit together. This chapter is the mental model the rest of the manual builds on: the multi-tenant hierarchy you administer, the omnichannel queue every customer conversation flows through, and the planes — voice, application, data, channels and AI — that make it all run.
Read this as an administrator, not an engineer: you do not need to operate CloudCX SBC or CloudCX Switch by hand to run CloudCX well. But knowing which moving part does what will make every later chapter — trunks, ACD, channels, billing, security — click into place, and it will make you far faster when something needs diagnosing.
What CloudCX is as a product; the four-level tenancy model (platform → reseller → tenant → users); how a single queue serves six channels; the real architecture (telephony, application, data, channel and AI planes); and a worked example that traces one inbound call from carrier to conversation.
CloudCX is a multi-tenant, omnichannel Contact-Center-as-a-Service (CCaaS) platform. In plain terms: it is one cloud that lets many separate businesses each run a complete contact centre — phones, web chat, WhatsApp, SMS, email and social — without owning any telephony hardware, while you, the platform operator, run and monetise the whole thing from a single console at admin.cloudcx.app.
Two ideas distinguish CloudCX from a hosted PBX or a chat widget:
On top of that sits the commercial engine that makes CloudCX a platform rather than a single deployment: a white-label reseller tier, a per-feature, per-count licensing/entitlement engine, and built-in billing. Those three are what you spend most of your administrator time configuring, and they are the subject of Parts II and VIII.
Voice plus five digital channels land in one queue and one agent desktop — with full customer context carried across them.
Strict per-tenant isolation. Every config row, CDR, recording and report is scoped to a tenant; the licence engine gates every count.
Resellers run the platform under their own brand, domain and pricing — optionally with their own SIP and channel credentials.
Memorise this for tenant and reseller conversations: “CloudCX is one cloud that lets many businesses each run a complete, omnichannel contact centre — and lets partners resell that cloud under their own brand.”
Everything you administer hangs off one four-level hierarchy. Each level owns the level below it and inherits limits from the level above. Getting this picture firmly in your head is the single most useful thing in this chapter — almost every screen in the console is a view onto one of these levels.
In the data model these are exactly the records you will manage: a Reseller owns many Tenants (a tenant’s reseller_id can also be empty — a direct platform customer), and every User is typed as one of admin, reseller, tenant, subtenant, tl or agent. A platform admin’s tenant_id is null; a tenant user’s is set, which is what scopes them to their own data.
| Level | Login type(s) | You administer here | Console |
|---|---|---|---|
| Platform | admin | Resellers, direct tenants, platform credentials, global licensing, security, settings, routing & channels | Admin (this manual) |
| Reseller | reseller | Own brand & domain, own tenants, plans & rate cards, bring-your-own credentials, team & reports | Reseller portal |
| Tenant | tenant · subtenant | Extensions, users, queues & skills, IVR/call-flows, campaigns, channels, surveys, tickets, reports | Tenant admin |
| Users | tl · supervisor · agent | (They operate, not administer) live wallboard & QA · the omnichannel agent desktop | Supervisor / Agent |
Not every tenant needs a reseller. A tenant created with no owning reseller is a direct platform customer — you bill and support it yourself. A tenant created under a reseller inherits that reseller’s caps, branding and (where enabled) its credentials, and the reseller bills it. The same tenant provisioning form is used either way; the difference is whether you pick an owning reseller.
Two mechanisms keep tenants apart and keep the commercials honest:
tenant_id, and queries are scoped to it. One tenant can never see another’s contacts, recordings, CDRs or reports — the isolation is in the data layer, not just the UI.Leaving an entitlement count at 0 grants unlimited, not zero. To actually disable a feature for a reseller or tenant, turn its toggle off — do not set the count to 0 expecting a hard stop. This convention runs through every limit field in the product.
The admin console’s left navigation is grouped to match the model above. Figure 2.1 maps every navigation group to the part of the platform it governs — the dashboard you land on after signing in.
Live state of the entire CloudCX cloud — every reseller, tenant and channel in one place.
Two more groups sit between Tenancy and Governance. Revenue → Billing is where rate cards, invoicing and settlement live (Part VIII). Platform holds the shared plumbing every tenant draws on: Call Routing, Channels (the six-channel enable panel) and the Platform Credentials vault — covered next.
The word omnichannel is the heart of the product. Your customers reach out everywhere — they phone, they email, they message on WhatsApp, they text, they DM on social — and CloudCX answers them in one place. Every channel feeds the same routing brain, so an agent works a single, unified inbox with full context, rather than six disconnected tools.
CloudCX supports exactly six channels. These are the canonical set you enable per platform (and per tenant) on the Channels panel:
| Channel | Key | Direction | How it connects |
|---|---|---|---|
| Voice | voice | In & out | SIP trunks → CloudCX SBC SBC → CloudCX Switch media |
| Web chat | chat | In & out | Embeddable JS widget over WebSocket |
| In & out | WhatsApp Business Cloud API (Meta) webhook | ||
| SMS | sms | In & out | Twilio / Telnyx / CliSMS adapters |
| In & out | IMAP ingest → threaded tickets → SMTP | ||
| Social | social | In & out | Facebook / Instagram / X (Twitter) APIs |
Whatever the channel, an inbound interaction becomes a record the routing brain understands: a voice call (a leg with a CDR) or an omni thread (a conversation on one channel with inbound/outbound messages). The ACD then distributes it to the best available agent — the very same queue-and-skill engine for chat as for calls.
The Automatic Contact Distributor (ACD) is the routing brain shared by voice and every digital channel. Four concepts drive it:
queueschannel with a distribution strategy and an optional max_wait_seconds SLA. May be tenant-scoped or a shared platform default.skillsbilling or spanish. Agents hold skills at a proficiency level; queues require them at a minimum level.queue_membersqueue_strategyround_robin, longest_idle, fewest_calls or priority.Static configuration (membership + skills) lives in the database; live agent presence lives in CloudCX Cache. Routing joins the two in real time — which is why a queue can know, instantly, who is available and idle. ACD is covered in full in Chapter 10.
Because the same ACD serves all six channels, a skilled agent can be staffed once and handle calls and chats and WhatsApp from a single presence. That is the efficiency story you sell to tenants — and the reason channel and agent-session entitlements are licensed separately in the engine.
Under the console, CloudCX is a set of decoupled services grouped into five planes. You will rarely touch them directly, but every administrative action you take lands in one of them, and the health panel on your dashboard reports on them. Figure 2.2 is the map.
A few facts about this architecture are worth carrying with you, because they explain behaviour you will see in later chapters:
In staging every plane runs on a single host; in production the telephony, media and app/data roles split across nodes. It is the same containers, only differently placed and env-configured. You administer CloudCX identically in both — the split is an operations concern, not a console one.
To make the planes concrete, follow a single inbound phone call across them. This is the path every voice interaction takes, and it is the mental model to reach for when a call “doesn’t arrive” and you need to know where to look.
One architectural choice you will configure as a platform admin is where channel and AI credentials come from. CloudCX resolves them in a clear order, and the rule is the same for every channel:
CloudCX’s own shared keys for SMS, WhatsApp, email and AI, held in the Platform Credentials vault. Used by every tenant whose reseller has not brought its own.
When a reseller is allowed its own SMS, WhatsApp or SIP credentials, those take precedence for its tenants — its traffic runs on its accounts.
Every secret — a Twilio auth token, a Meta token, an IMAP password, the CloudCX AI key — is stored only as a Fernet-encrypted JSON blob; plaintext is never persisted, and no API ever returns a secret value. The single root key (BYOND_CREDS_KEY) lives in the server environment, outside the database.
If BYOND_CREDS_KEY is unset on the server, saving any credential returns 503 — the platform refuses to store a secret it cannot encrypt. Deleting a provider’s credentials is safe and idempotent, but the channel (or AI) goes idle until creds are configured again.
Let us make the model real with one concrete scenario you can hold in your head for the rest of the manual. A partner, Acme Communications, wants to resell CloudCX; their first customer is a retailer, Northwind Retail, who needs phones plus WhatsApp for a 25-seat support team.
Here is how that maps onto the four levels — and which later chapter does each step. You are reading the overview now; you will perform these in Parts II–IV.
login_domain (e.g. cx.acme.com), caps such as max customers and max agents, and toggles for bring-your-own SMS / WhatsApp / SIP. (Chapter 5.)reseller_id, on a billing plan Acme offers. Northwind inherits Acme’s brand and credentials. (Chapter 6.)0 would be unlimited, so caps are set deliberately. (Chapters 6–7.)english, orders), and staffs them on a Support queue using longest_idle. (Chapters 7 & 10.)The result is one branded slice of the platform: Acme sees only Acme; Northwind sees only Northwind; an inbound call to Northwind’s DID and an inbound WhatsApp message both land in the same Northwind queue and the same agent desktop — the omnichannel promise, delivered through the tenancy model.
| Level | In this scenario | Key record / field | Where |
|---|---|---|---|
| Platform | You operate the cloud | /admin · platform credentials | This manual |
| Reseller | Acme Communications | Reseller · login_domain | Ch. 5 |
| Tenant | Northwind Retail | Tenant.reseller_id | Ch. 6 |
| Users | 1 TL · 1 supervisor · 25 agents | User.user_type · skills | Ch. 7 |
| Channels | voice + whatsapp into one queue | Queue · OmniThread | Ch. 10–14 |
Goal: prove you can hold the whole model in your head before you touch a live console. On paper (or a whiteboard), do the following:
Check yourself: WhatsApp here runs on Acme’s own credentials (its bring-your-own toggle is on); voice rides the platform’s SIP unless Acme also brought its own trunk. If you set Northwind’s agent count to 0 you granted unlimited seats — not zero.
If you take five things from this chapter, take these:
tenant_id. Owned by a reseller, or a direct platform customer when it has none.0 means unlimited; disable a feature with its toggle, not by zeroing the count.With the model in hand, Chapter 3 walks you through signing in — 2FA, email-OTP and password reset — and a guided tour of the console you saw in Figure 2.1. Chapter 4 then details the access model (platform admin · reseller · tenant · supervisor · agent) before Part II turns the worked example above into real resellers and tenants.
This chapter takes you from a cold browser to a confident command of the CloudCX control plane. You will sign in for the first time, harden your own account with two-factor authentication (an authenticator app or emailed sign-in codes), learn how to recover a forgotten password, and then take a guided, fully annotated tour of every region of the console — the dark navigation rail, the light working area, and the controls you will reach for dozens of times a day.
The Platform Admin Console is served at admin.cloudcx.app (the running header on the sign-in card reads admin.cloudcx.app). It is the top tier of CloudCX — the place you operate the whole multi-tenant cloud. Resellers use their own white-label console and tenants use theirs; both are covered in later chapters. Everything in this chapter applies to the platform admin sign-in.
Account creation is not self-service at the platform tier. Your platform-administrator credentials are issued to you by CloudCX (or by an existing super-admin during onboarding, covered in Chapter 2). You will be given a username (or you may sign in with your email address) and an initial password. Have the following ready before you start:
Bookmark admin.cloudcx.app directly rather than relying on a search engine. The first thing the console does on load is check for a valid session token; if none is present it shows the sign-in card automatically, so the same URL serves both as your login page and your home page.
When you open the console without an active session, the entire window is dimmed behind a centred sign-in card. Nothing else in the application is reachable until you authenticate — this is the login gate.
A red banner reading “Invalid username or password” means the credentials did not match. The platform deliberately does not say which of the two was wrong — this is by design, to avoid revealing whether an account exists. If you instead see “Could not reach the server”, the problem is your network or connectivity, not your credentials. A repeated, rapid burst of attempts is rate-limited: pause for a minute and try again.
Suppose you are Sofia Aranha, a newly onboarded platform super-admin. CloudCX has issued you the email [email protected] and a temporary password.
[email protected] into Username or email.Sign in with your own credentials. Then look at the top-right of the topbar and write down the three things shown in your identity chip: your initials, your display name, and your role. Confirm the environment chip reads ENV: PROD. You will use these landmarks throughout the manual to confirm you are in the right place, as the right person, in the right environment.
Two-factor authentication adds a second, time-limited proof of identity on top of your password, so that a stolen password alone cannot sign in. CloudCX gives you two independent options, and you choose one:
A 6-digit code that rotates every 30 seconds inside an app on your phone — Google Authenticator, 1Password, Authy and the like. Works offline. This is the recommended option.
A 6-digit code emailed to your verified address each time you sign in, valid for 10 minutes. Useful when you cannot run an authenticator app, but depends on email delivery being configured.
You enable either the authenticator app or email codes — not both at once. If authenticator-app 2FA is on, the email-codes option is disabled until you turn the app off, and vice-versa. Both are opt-in and off by default: an account with neither enabled signs in with username and password exactly as in § 3.2.
Both controls live in the area of the console: open Security from the Governance group in the sidebar, then scroll to the Two-factor authentication and Email sign-in codes panels. They act on your own signed-in account.
Enrolment is a deliberate two-step handshake: the console generates a secret and shows it to you, and you prove your app has stored it correctly by typing back one live code. Only then is 2FA switched on.
Add this account to your authenticator app, then enter the 6-digit code it shows to finish.
otpauth:// deep link; on a phone it adds the account to your app in one tap.The secret is shown only during this enrolment. If you navigate away or click Cancel before activating, the pending secret is discarded and you must start enrolment again from Enable 2FA. Never store the raw secret or the otpauth URI in a shared document — anyone holding it can generate your codes.
The moment 2FA activates, the console shows a set of one-time backup codes. Each works exactly once and lets you sign in if you ever lose access to your authenticator. This is the only time they are displayed — only their hashes are stored, so they can never be shown again.
7H4K-9PXM
Q2WT-MN83
VK59-CPJ7
D8XA-3RYH
M6QB-W4ZP
L3FN-72KD
Save these codes in a password manager or print them and lock them away — not in the same place as your password, and never in plain text on a shared drive. After you click I’ve saved my codes, the Two-factor panel shows how many remain unused. If you run low, disable and re-enable 2FA to mint a fresh set.
Once 2FA is active, your sign-in becomes a two-stage flow. You will not see the code field on the first screen — it appears only after your password is accepted.
To remove authenticator-app 2FA, return to the Two-factor authentication panel (now showing Enabled ✓), enter a current 6-digit code (or an unused backup code) in the field, and click Disable 2FA. The secret and any remaining backup codes are cleared, and your account reverts to password-only sign-in until you enrol again.
If you cannot run an authenticator app, CloudCX can email you a one-time code at each sign-in instead. This is controlled by the Email sign-in codes panel, just below the Two-factor panel in Security.
When on, signing in asks for a 6-digit code emailed to [email protected]. Codes expire in 10 minutes. (Requires the Resend email credential to be configured.)
Each code is valid for 10 minutes and a fresh request is throttled (you will not be re-sent a new code within about a minute). After several wrong attempts a code is burned and you must request a new one by signing in again. Email codes depend on the platform Resend credential being set (see the Platform Credentials chapter); if email delivery is not configured, prefer the authenticator-app method.
If you cannot remember your password, use the self-service reset flow on the public site. It is intentionally privacy-preserving: requesting a reset always returns the same confirmation, whether or not the address is registered, so the page never reveals who has an account.
A link that has been used, or is older than an hour, returns “This reset link is invalid or has expired.” Simply request a fresh one. A password reset does not remove your two-factor enrolment — if 2FA is on, you will still be challenged for a code after signing in with the new password. If you have lost both your password and your authenticator, sign in with the new password and one of your backup codes; if those are gone too, contact another super-admin to recover the account.
Now that you are signed in, take a moment to learn the lay of the land. Every screen in the console follows the same three-part frame: a fixed navigation rail on the left, a sticky topbar across the top, and the content area that fills the rest. Master these once and every later chapter will feel familiar.
Live state of the entire CloudCX cloud.
The dark rail on the left is your map of the entire platform. Items are grouped by purpose, and clicking one swaps the content area to that view without a full page reload. Some items carry a small pill — a live count (for example the number of resellers or tenants) or a label such as NEW or KEYS. The currently selected item is marked with a gradient accent bar.
| Group | Item | What it is for | Covered in |
|---|---|---|---|
| Operate | Dashboard | Live platform overview — resellers, tenants, channels and calls at a glance. | Ch. 4 |
| Reports | Platform-wide analytics and exportable reporting. | Ch. 4 | |
| Tenancy | Resellers | Create and manage white-label partners and their entitlements. | Ch. 5 |
| Tenants | Manage the customer accounts that live under resellers. | Ch. 6 | |
| Revenue | Billing | Wholesale pricing, balances, statements and the payment provider. | Ch. 7 |
| Platform | Call Routing | SIP trunks and least-cost routing for voice. | Ch. 8 |
| Channels | The six omnichannel surfaces (voice, web chat, WhatsApp, SMS, email, social). | Ch. 9 | |
| Platform Credentials | Provider API keys and secrets used across the platform. | Ch. 10 | |
| Voice | IVR / Call Flows | The visual IVR and call-flow builder (opens a dedicated page). | Ch. 11 |
| Campaigns | Outbound campaign management (opens a dedicated page). | Ch. 11 | |
| Governance | Security | Password policy, your own 2FA, IP rules and SSO. | Ch. 12 |
| Licensing | The licensed seat and capacity pools allocated to resellers. | Ch. 12 | |
| Settings | Your signed-in identity and platform configuration links. | Ch. 12 |
The counts on Resellers and Tenants are read live from the API as the console loads, so they double as an at-a-glance scoreboard. If a pill shows a dash (—) the count could not be fetched — usually a transient connectivity issue rather than “zero.”
The topbar stays pinned as you scroll. From left to right it shows the page title and scope, a global search box, the environment chip, a notifications bell with an unread badge, your identity chip, and the sign-out button.
// platform overview · all resellers).⌘K/users/me.1) Click through every group in the navigation rail and note which views open a new page versus swapping the content area (hint: the Voice items open dedicated pages). 2) Press ⌘K (or Ctrl+K) to focus the global search and type the name of a reseller or a DID to see how the search scope behaves. 3) Finally, open Security and confirm your Two-factor authentication status — if it still reads Not enabled, complete § 3.4 now before moving on to Chapter 4.
You can now sign in securely, protect your account with a second factor, recover a lost password, and find your way around every region of the console. The chapters that follow drill into each navigation group in turn — starting with the Dashboard and Reports in Chapter 4.
Everything an account can see, change, or be billed for in CloudCX flows from a single decision: what type of login is this, and which slice of the platform does it own? This chapter is the canonical reference for the six login types, the three-tier tenancy hierarchy that contains them, and the row-level isolation model that keeps one customer’s data invisible to every other customer. Get this right and the rest of the manual — billing, channels, supervision, security — falls into place.
The difference between the six user types (admin, reseller, tenant, subtenant, tl, agent); the Platform Admin → Reseller → Tenant containment model; exactly what each role sees in its console; how the platform scopes every query so tenants never collide (the “tenant scope” rule); and how to provision, verify and harden each kind of account. Read this before any chapter that grants access to data.
Every person (and every machine token) that signs in to CloudCX is a single User record carrying one user type. There are exactly six, and they never change at runtime — a login is created as a type and stays that type for its life. The type, together with two optional ownership pointers (tenant_id and reseller_id), is the only thing the platform consults when it decides what you may touch.
| Type | Who it is | Signs in at | Owns / sees |
|---|---|---|---|
| admin | Platform Administrator (CloudCX staff / you) | admin.cloudcx.app | The entire platform — every reseller, every tenant, every row. |
| reseller | White-label partner operator | cloudcx.app/reseller | Only the tenants under their own reseller, and their own team. |
| tenant | Customer account owner / administrator | cloudcx.app/app | Everything belonging to their one tenant. |
| subtenant | A department / business-unit admin inside a tenant | cloudcx.app/app | Their tenant’s data (a delegated admin within the same tenant). |
| tl | Team Leader / Supervisor | cloudcx.app/supervisor | Live ops & quality tools for their tenant; counts as a seat. |
| agent | Front-line contact-centre agent | cloudcx.app/agent | Only their own queued work and presence; counts as a seat. |
Three of the types are tenancy tiers (admin → reseller → tenant) and three are roles inside a tenant (subtenant, tl, agent). tl is the platform’s internal name for a Team Leader; in every UI and throughout this manual it is presented as the Supervisor. The two seat-consuming types — agent and tl — are the ones that count against licensing entitlements (see Chapter 6).
CloudCX is multi-tenant by construction. The platform is divided into three nested tiers of ownership. Each tier can only ever reach down into the tier it contains — never sideways to a sibling, and never up to its parent.
Two ownership pointers wire this together on every User row:
reseller users.A Tenant itself also carries a reseller_id: when set, the tenant belongs to that reseller (white-label); when NULL, it is a direct platform customer that you, the admin, manage yourself. This single column is what lets a reseller see “their” tenants and nobody else’s.
A user’s type and its tenant are set at creation and are intentionally immutable through the console. To “promote” an agent to Supervisor you create a new tl login (or have an admin re-provision the account) — you cannot flip the type on an existing record. This keeps the isolation model auditable: a login’s blast radius never silently grows.
The promise that makes a CCaaS platform safe to sell is simple to state: a user from one tenant can never read or change another tenant’s data. CloudCX enforces this not with hope, but with a single rule applied to every query that touches tenant-owned data. The platform calls it the tenant scope, and it resolves identically everywhere — the dashboard counts, the contact list, the live-call wallboard, every report.
When any login asks for tenant-owned rows, the platform narrows the query by the caller’s identity, in this exact order:
| Caller | Recognised when… | Rows they can see |
|---|---|---|
| Platform Admin | type = admin and tenant_id = NULL |
All rows — every tenant, plus platform-level rows that belong to no tenant. |
| Reseller user | reseller_id is set |
Only rows whose tenant is one of this reseller’s tenants. |
| Tenant / subtenant / tl / agent | tenant_id is set |
Only rows whose tenant_id equals their own. |
| Anything else | no admin scope, no reseller, no tenant | Nothing. The query returns an empty set. |
When a login fetches a single record by id that it is not permitted to see, CloudCX returns 404 Not Found — not 403 Forbidden. This is deliberate. A 403 would confirm the record exists; a 404 reveals nothing. So if a tenant user pastes another tenant’s contact ID into a URL, the platform behaves exactly as if that row did not exist. Do not file these 404s as bugs — they are the isolation model working.
A few rows belong to no tenant: anonymous web-chat threads that arrive before they are claimed, platform demo data, and shared platform defaults. These tenant-NULL rows are visible to the Platform Admin only. No reseller and no tenant user can ever see them — the scope rule treats “belongs to no tenant” as “belongs to the platform.”
After a successful sign-in (Chapter 3), CloudCX issues an access token that encodes the three facts the scope needs, so every later request is evaluated without a second password prompt:
// decoded access-token payload (illustrative) { "sub": "a3f1…", // the user id "ut": "tenant", // user_type — drives every permission check "tid": "7c2e…", // tenant_id (null for the platform admin) "rid": null // reseller_id (set only for reseller logins) }
You will see the same three-way fan-out (admin → all, reseller → own tenants, tenant → self) in the live-call wallboard, the open-chat counter, the CDR-today tile and every list in the product. There is no per-screen access logic to learn: master Table 4.2 once and you understand isolation everywhere.
The four panels below summarise the day-to-day surface of each tier. Each maps to a distinct console; an account only ever lands in the one console its type permits.
The widest console. Creates resellers and direct tenants; manages shared platform credentials; sets platform security policy; drives any supervisor control (monitor / whisper / barge) against any live call. Sees every reseller, tenant, user and CDR. This is the role this manual is written for.
A scoped, white-label control plane. Creates and manages its own customers (tenants), sets branding and wholesale plans, and builds a small team of reseller and agent logins. Never sees another reseller’s tenants, and cannot create admin, subtenant or tl logins.
The customer’s own administration. Manages contacts, queues, channels, agents and Supervisors within the one tenant. A subtenant is a delegated admin for a department inside the same tenant — same data boundary, narrower remit.
Supervisor gets the live-ops wallboard and quality tools — presence, live calls, monitor/whisper/barge, QA scoring. Agent gets only their own work: handle conversations and calls, set presence, view their own queue. Both consume a licensed seat.
As Platform Admin you manage tenancy from the dark CloudCX console. The two places roles and tenancy converge are the Tenancy nav group (Resellers and Tenants) and the per-tenant Agents view, where Supervisor and Agent logins are provisioned. The figure below highlights the navigation that frames this whole chapter.
Every customer account on the platform, across all resellers.
| Tenant | Reseller | Seats | Status |
|---|---|---|---|
| NBNorthwind Banknorthwind | Acme Comms | 42 | active |
| HCHelios Carehelios | Direct | 18 | active |
| OTOrbit Travelorbit | Acme Comms | 7 | inactive |
tenant login (§ 4.6).reseller_id is NULL).agent + tl logins in that tenant; this is what licensing meters.The green PROD chip in the top bar tells you which environment you are operating. Tenancy changes here are live and immediately affect customers. When you are training or rehearsing, confirm you are on a non-production environment first.
Creating a tenant does two things at once: it creates the Tenant container, and it bootstraps that tenant’s first user — a tenant-type login that becomes the customer’s account owner. From there the customer (or you) creates Supervisors and Agents inside that tenant.
tenant-type User with the username and password you supplied — the account owner.A tenant’s slug-style username is globally unique, but a user’s username only has to be unique inside its own tenant. Two different tenants can each have a user called support without collision — the platform keys on the (tenant, username) pair. Plan your naming with this in mind.
tenant-type owner login from the username + owner password — one step, two records.tenant user.Inside a tenant, the working population is made of Agents (agent) and Supervisors (tl). These are the two types that consume a licensed seat: the platform counts every agent + tl login in a tenant against that tenant’s entitlement. The screen below is where you review and add them.
| Name | Username | Role | Status |
|---|---|---|---|
| MAMara Adler | mara | Supervisor (tl) | active |
| DIDev Iqbal | dev.i | Agent | active |
| RKRae Kim | rae.k | Agent | locked |
agent + tl count against the tenant’s entitlement; creation is blocked once the cap is reached.agent logins: front-line, see only their own work.tl logins: live ops & quality tools for the tenant.active, inactive or locked; a non-active user cannot sign in (§ 4.9).agent) or Supervisor (tl).The tl / Supervisor role does more than “see more rows” — it carries live-call privileges that ordinary agents do not. On a live call a Supervisor (or the Platform Admin) can step in three escalating ways:
listen-onlycoachthree-wayThese controls always respect tenant scope: a Supervisor only sees — and may only act on — live calls belonging to their own tenant. A reseller Supervisor view is scoped to that reseller’s tenants; the Platform Admin can act on any call. Agents have none of these powers.
Listening to, whispering on, or barging into a customer’s live conversation is sensitive. CloudCX records every monitor / whisper / barge action. Ensure your organisation’s call-recording and supervision notices are in place before granting Supervisor seats — the capability is real and immediate.
Type decides what a login may do; status decides whether it may sign in at all. Every user carries one of three statuses, checked at login and on every authenticated request:
Normal. The login works and is subject to its role’s scope.
Disabled by an admin. Sign-in is refused; data is retained. Reversible.
Blocked (e.g. after security review). Sign-in is refused until unlocked.
To off-board a person, set their login to inactive rather than deleting it. The session is refused immediately (an existing token stops working on its next request), the seat is freed, and the audit trail and any owned records stay intact. Deletion is irreversible and breaks historical attribution.
A reseller builds a small staff team in its own portal. Crucially, a reseller may only create two kinds of team login: another reseller operator, or an agent. A reseller can never mint admin, tenant, subtenant or tl logins, and every tenant it creates is force-stamped with its own reseller_id — there is no way for a reseller to reach across to another partner’s customers. This is the same isolation rule from § 4.3, applied at the creation step.
Independently of type, any login can enrol opt-in two-factor authentication (TOTP authenticator app, or email one-time code) from its own account-security settings. 2FA is off by default and does not change the scope rules — it only hardens sign-in. As Platform Admin you should enable it on your own admin account first (see Chapter 3).
Let us provision a complete, isolated tenant from nothing: a direct customer with one Supervisor and two Agents, and prove the isolation holds.
helios · type tenantmara · type tldev.i, rae.k · type agenthelios, email [email protected], a policy-compliant owner password, Reseller = Direct. Create. → You now have the Tenant and a tenant owner login helios.mara, set a password. → Seats used ticks to 1.dev.i and rae.k. → Seats used ticks to 3; the KPI tiles read 2 Agents, 1 Supervisor.dev.i. Confirm Dev sees only Helios queues — no other tenant’s conversations — and can set presence but has no monitor / whisper / barge controls.mara. Confirm Mara sees the Helios wallboard (live calls, presence, open chats for Helios only) and can monitor a Helios call — but no other tenant’s.dev.i, attempt to open a record id that belongs to a different tenant. Expect 404 Not Found, exactly as if the record did not exist (§ 4.3).rae.k to inactive. Confirm Rae can no longer sign in and that Seats used drops back to 2, freeing the seat without deleting history.One tenant (Helios Care), four logins (1 tenant owner + 1 tl + 2 agents, one of which is inactive), 2 active seats, and three proofs on record: agents are blind beyond their own work, the Supervisor is scoped to Helios, and cross-tenant lookups return 404. That is the entire access model, demonstrated.
Exercise 4-A. Using a training/non-production environment, complete steps 1–7 above for a tenant of your own naming. Then answer in your own words:
tenant. Could it, by itself, see a second tenant you also created? Why not — which field stops it?tl? If not, who must, and why is that the safer default?type = tl but status = inactive. Can they monitor a call? Name both gates that decide the answer.admin, reseller, tenant) and three in-tenant roles (subtenant, tl, agent). tl is the Supervisor.tenant_id and reseller_id.reseller and agent team logins, and its tenants are force-stamped to its own scope.A reseller is the white-label partner tier that sits between you (the platform) and the enterprise customers who actually use CloudCX. A reseller resells the platform under its own brand, sells to its own customers (tenants), prices them with its own plans, and — where you permit it — connects its own carriers and channel providers. This chapter shows you how to create a reseller, brand it, set its entitlement caps, switch on its “bring-your-own” connectivity, and what the partner then sees inside their own white-label console.
Everything in this chapter happens in the platform admin console at admin.cloudcx.app under the view. The partner’s own self-service console — covered at the end of the chapter — lives at a separate address: cloudcx.app/reseller.
CloudCX is a strict three-level tenancy: Platform Admin → Reseller → Tenant. You operate the platform;
a reseller operates a book of business on top of it; each tenant is one enterprise customer with its own agents,
numbers and channels. Every object a reseller can touch is scoped to its own reseller_id, so one
partner can never see or modify another partner’s brand, customers or billing.
A reseller record carries four kinds of data. Keep them straight — the rest of the chapter is organised around them:
Company name, the primary login username, and the admin email. The username is unique platform-wide and indexes the partner.
Brand name, logo, two brand colours and a unique login domain — the white-label face the partner’s customers see.
Caps on customers, agents and channels (0 = unlimited), plus three bring-your-own toggles (SIP / SMS / WhatsApp).
The currency and per-seat price at which you bill the partner. Drives the wholesale ceiling shown on the reseller’s drawer.
Throughout the platform, a cap of 0 means unlimited, not “none”. A reseller with
max_customers = 0 can create as many tenants as it likes. Set a real number whenever you want a hard
ceiling enforced.
Open Resellers from the Tenancy group in the left navigation. The list is the home for every white-label partner on the platform. Each row summarises one reseller; click a row to open its entitlements drawer (§ 5.5). The toolbar above the table lets you filter by status and search by name or domain.
White-label partners running CloudCX under their own brand. Click a row to manage entitlements and SIP.
| Reseller | Brand domain | Customers (cap) | Agents (licensed) | BYO-SIP | Status | |
|---|---|---|---|---|---|---|
| NTNimbus TelecomNimbus CX | cx.nimbustel.com | 14 / 50 | 500 | On | Active | Manage › |
| ACAcme CommsAcme Connect | — not set — | — / 25 | 120 | Off | Active | Manage › |
| OROrbit VoiceOrbit | talk.orbit.io | — / ∞ | ∞ | On | Inactive | Manage › |
—) means the live count is not on the list payload; ∞ means an unlimited cap (0).The list endpoint returns the reseller records themselves, not a per-partner live tenant count, so the
Customers column renders — / cap rather than fabricating a zero. The true live usage
(“12 / 50”) is computed inside the partner’s own console and on the Licensing page.
Click Add reseller to reveal the New reseller form inline at the top of the list. The form is
split into two columns: identity & branding on the left, caps & connectivity on the right.
Only three fields are mandatory (marked *); everything else has a sensible default you can refine
later from the entitlements drawer.
cx.nimbustel.com. You can leave both blank now and brand later from the drawer.0 for any cap you want unlimited.Both the login username and the login domain are unique platform-wide. Creating a reseller whose
username or domain collides with an existing one is rejected with a 409 Conflict
(“username or login domain already exists”). Choose distinctive values — a partner’s
domain is hard to change once their customers are using it.
The create form deliberately does not collect a reseller-admin password. The reseller record is
created on its own; its primary reseller login is bootstrapped separately (§ 5.6). When a
password is later supplied, it must satisfy the platform password policy or the request is rejected with
422.
Every field the create form accepts, its constraint, and what it controls downstream:
nameusernameemailbrand_namelogin_domaincx.nimbustel.com).max_customers0 = unlimited. Default 50. Enforced live when the partner adds a customer.max_agents0 = unlimited. Default 500.max_channels0 = unlimited; the platform maximum is 6.allow_own_sip_trunkThree further fields are part of the reseller record but are set from the entitlements drawer or via the API rather than this form:
| Field | API key | Default | Purpose |
|---|---|---|---|
| Allow own SMS | allow_own_sms | off | BYO gate for the SMS provider (Twilio-style creds). |
| Allow own WhatsApp | allow_own_whatsapp | off | BYO gate for WhatsApp (Meta Cloud API creds). |
| Wholesale currency | wholesale_currency | USD | Currency you bill the partner in. |
| Wholesale seat price | wholesale_seat_price | 0.00 | Per-agent / month wholesale rate; drives the wholesale ceiling on the drawer. |
If you just need the partner record to exist (for example to start importing customers), create it with only the three required fields. You can return to the entitlements drawer at any time to add branding, raise caps and switch on bring-your-own connectivity — nothing on the create form is permanent except the username.
Branding is what turns a generic CloudCX instance into the partner’s product. Five fields make up the white-label identity. You can seed them on the create form; the partner can also refine the visual ones from inside their own console. Only the platform admin can be sure of the login domain being globally unique, so treat that field as something you own jointly with the partner.
#1E6FFF#0B1F44cx.nimbustel.comThe partner’s own console exposes exactly these as an editable branding panel. Here is the partner-side view so you know what they will see when they finish the white-label:
Setting login_domain on the record only tells CloudCX which host belongs to which partner. For the
domain to actually serve, its DNS must point at the platform and a TLS certificate must cover it. Coordinate the
CNAME and certificate with the partner before you promise them a go-live date for the branded URL.
Click any reseller row to slide out the entitlements drawer. This is where you do nearly all day-two reseller management: review the partner at a glance, adjust the licensed caps, flip the three bring-your-own toggles, and see the wholesale position. Changes to the caps are saved with Save entitlements; the three BYO toggles save immediately as you flip them.
CURRENTLY: ENABLED/DISABLED; these save the moment you flip them.When a partner tries to add a customer beyond max_customers, the platform rejects it with
“customer limit reached for this reseller.” The check combines the stored cap with a live count of the
partner’s tenants, so raising the cap in this drawer takes effect immediately — no re-provisioning.
By default every channel a partner uses runs on CloudCX’s shared platform credentials — your carriers, your SMS account, your WhatsApp number. The three allow-own toggles let a partner instead supply its own provider credentials for that channel, so traffic flows over the partner’s own accounts and appears under the partner’s own sender identities. You hold the master switch; the partner enters the actual secrets in their console.
| Toggle | Entitlement flag | Channel | What the partner then supplies |
|---|---|---|---|
| Allow own SIP trunks | allow_own_sip_trunk | Voice | Its own carrier trunk(s): host, username, password (+ optional proxy / port). |
| Allow own SMS | allow_own_sms | SMS | Its own SMS provider keys: account_sid, auth_token, from_number. |
| Allow own WhatsApp | allow_own_whatsapp | Its own Meta Cloud API creds: token, phone_id, verify_token. |
For any send, CloudCX resolves the effective credentials for the channel in a fixed order. A channel uses the partner’s own credentials only when it is both allowed and configured; otherwise it “binds to CloudCX” and falls back to the shared platform credentials.
Switching allow_own_sms (or whatsapp / sip) off immediately stops resolution from using the
partner’s stored credentials — the channel re-binds to CloudCX’s shared creds on the very next send
— but the encrypted credential row is not erased. Flip the toggle back on and the partner’s own
connectivity resumes. To remove the secrets entirely, the partner must delete them from their console (or you
rotate the platform key).
Partner credentials are encrypted at rest (Fernet) and are never returned by any API — the console
only ever reports whether a channel is allowed and configured, plus the field names it
expects. The single root key, BYOND_CREDS_KEY, lives in the server environment, not the database. If
that key is missing the platform refuses to store new secrets (returning 503) rather than persist
anything in plaintext.
Once a partner has a login (§ 5.8) it signs in to its own self-service console at
cloudcx.app/reseller — or, after the login domain is published, at the partner’s
own host such as cx.nimbustel.com. Everything the partner sees there is scoped strictly to
its own reseller_id; it can never reach another partner’s data or any platform-admin function.
Knowing this surface helps you support partners and understand exactly where your admin-side toggles land.
Self-edit brand name, logo, the two colours and (subject to uniqueness) the login domain.
Create and list its tenants — capped by max_customers, enforced live.
Build retail billing plans (base + per-seat + per-minute + per-channel pricing).
Self-provision carrier trunks — only when allow_own_sip_trunk is on.
Enter its own SMS / WhatsApp / SIP provider secrets — only for the channels you allowed.
Add reseller-admin / agent team logins, view its entitlement usage, and export reseller-scoped CSV reports.
The partner’s “bring-your-own” credentials panel mirrors your toggles exactly: a channel you left off appears locked, while a channel you enabled becomes editable. Here is that partner-side panel:
auth_token are masked and never echoed back.allow_own_whatsapp is off; the partner cannot enter creds until you enable it.Let’s onboard a real partner end-to-end. Nimbus Telecom is a regional carrier that wants to resell
CloudCX as Nimbus CX at cx.nimbustel.com. They will bring their own SIP carrier and their own
Twilio SMS account, but use CloudCX’s shared WhatsApp to start. We license them for up to 25 customers
and 250 agent seats at a wholesale rate of USD 12 / seat / month.
cx.nimbustel.comNimbus Telecom, username nimbus_admin, email [email protected]. Brand Nimbus CX, login domain cx.nimbustel.com. Set Max customers 25, Max agents 250, Max channels 6. Leave Allow own SIP trunks off for now. Click Create reseller.USD and the seat price is 12.00. The wholesale ceiling now reads USD 3,000 (250 seats × 12). Click Save entitlements.CURRENTLY: ENABLED). Leave Allow own WhatsApp off so WhatsApp stays bound to CloudCX.cloudcx.app/reseller, finishes branding (logo + the two colours), enters its SIP trunk and Twilio SMS credentials, then creates its first customer (tenant). WhatsApp keeps using CloudCX until you enable BYO for it.The quick-create form intentionally omits a password, so the partner record initially has no login. Provision the primary reseller-admin user with a single API call (the password is enforced against the platform policy):
# Provision the primary reseller-admin login for an existing partner. # A 422 means the password failed the platform password policy. curl -X POST https://cloudcx.app/api/v1/resellers \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Nimbus Telecom", "username": "nimbus_admin", "email": "[email protected]", "admin_username": "nimbus_admin", "admin_password": "S3tA-Strong-Passphrase!", "brand_name": "Nimbus CX", "login_domain": "cx.nimbustel.com", "max_customers": 25, "max_agents": 250, "max_channels": 6, "allow_own_sip_trunk": true, "allow_own_sms": true }'
When the create payload carries admin_password (and optionally admin_username, which
defaults to the reseller’s username), CloudCX bootstraps the primary reseller-type login alongside the
record — the partner can sign in immediately. Omit it (as the UI form does) and provision the login later.
Either way the password must satisfy the platform password policy.
In a non-production environment, onboard a fictional partner and verify each control end-to-end:
CURRENTLY: ENABLED shows on the SMS toggle.cloudcx.app/reseller. Confirm SMS is editable while WhatsApp and SIP show managed by CloudCX.Success looks like: caps enforced live, toggles reflected on both sides, and secrets never displayed.
For every new partner, in order: (1) create record + brand, (2) set caps, (3) set wholesale rate, (4) enable only the bring-your-own channels they need, (5) bootstrap the login, (6) hand off and confirm their first customer is created.
reseller_id.With a partner onboarded, the natural next step is to populate it with customers. Chapter 6 — Tenants covers creating and configuring the enterprise customers that sit under a reseller; the partner’s retail plans and your wholesale rate cards are covered in Chapter 23 — Billing.
A tenant is a single business customer running its own contact centre on CloudCX — its own agents, numbers, queues, channels and data, fully isolated from every other tenant. This chapter shows you how to create a tenant from the platform console, configure its core identity, understand how seat and feature limits are licensed and enforced, and read the live entitlement counters. It closes with a fully worked provisioning example and a hands-on Try-it exercise.
CloudCX is a three-tier platform: Platform Admin → Reseller → Tenant. A tenant can be owned by a reseller (white-label tier) or be a platform-direct customer with no reseller (reseller_id = NULL). Licensing caps — how many customers and agent seats are allowed — live on the reseller, so a tenant inherits its room-to-grow from its parent. Platform-direct tenants are uncapped today. Resellers themselves are covered in Chapter 5; this chapter is about the tenants beneath them.
Open the console at admin.cloudcx.app, sign in with your platform-administrator account, and select Tenants in the dark sidebar under the Tenancy group. This is the platform-wide book of every enterprise customer — those belonging to resellers and those you sell to directly. The list is loaded live from GET /api/v1/tenants and ordered newest-first.
Use the breadcrumb chip below as your map for the procedures in this chapter:
Every enterprise customer across all resellers and the platform-direct book.
| Tenant | Username | Dial prefix | Status | |
|---|---|---|---|---|
| NGNorthgate Banktenant | northgate | [email protected] | 9 | Active |
| ACAcme Retailtenant | acme-retail | [email protected] | — | Active |
| BVBluewave Telcotenant | bluewave | [email protected] | 7 | Inactive |
| HMHelix Medicaltenant | helix-med | [email protected] | — | Locked |
// live · /api/v1/tenants source line confirm the table is live, not demo data.Every live panel in the CloudCX console prints a small mono source line (for example // live · /api/v1/tenants or // live · no tenants yet). If it instead reads // data unavailable or you see a "Couldn’t load tenants" toast, you are looking at a connectivity problem — not an empty platform. Treat the source line as ground truth before acting.
Click Add tenant to open the create form. A tenant needs only four things to come to life: a display name, a unique login username, a contact email, and the password for its first login. An optional dial prefix can be set now or later. When you save, CloudCX does two things in one transaction: it creates the tenant record, and it bootstraps the tenant’s primary login (a user of type tenant) using the username, email and password you supplied.
The create form maps directly onto the POST /api/v1/tenants request body. Each field, its constraints and how CloudCX uses it:
nameusernameemailpasswordtenant user. 4–128 characters at the schema level, then run through the live platform password policy; a weak value is rejected with 422 before anything is written.dial_prefixThe handler validates the password policy, checks the tenant_limit entitlement, inserts the tenants row, then inserts the primary tenant-type users row — all in a single unit of work. If the username collides on flush, the whole thing rolls back and you get a 409, so you never end up with a half-created tenant or an orphan login.
Northwind Logistics.northwind-log. If it is already taken anywhere on the platform you will be told on save.The password you type here unlocks the tenant’s primary account — the one that can create that tenant’s agents, queues and channel connections. Never reuse a password across tenants, never send it in clear text, and tell the customer to rotate it on first sign-in. CloudCX never displays it again.
| Symptom | HTTP | Meaning & fix |
|---|---|---|
| Tenant username already exists | 409 | The username is taken somewhere on the platform. Pick another; nothing was saved. |
| Password rejected | 422 | The initial password failed the platform password policy. Strengthen it (length / complexity) and retry. |
| Customer / tenant limit reached | 409 | A licensing cap would be exceeded (relevant when the tenant is created under a capped reseller). Raise the cap or remove an inactive tenant first — see § 6.3. |
| Invalid email | 422 | The email failed format validation. Correct it and retry. |
| Not authorised | 401/403 | Only a platform-admin may create tenants here. Sign in with the right role. |
CloudCX gates commercial capacity with a per-count entitlement engine. Every limit is a single number, and the platform follows one golden rule throughout:
LicenseExceeded and the API answers 409 Conflict.409A scope is the entity a limit applies to. Today, caps are stored on the reseller row, so every tenant inherits its head-room from its parent reseller. Three numeric caps drive tenant capacity:
The platform scope (a tenant created directly, with no reseller) has no stored caps today, so its limits resolve to unlimited. The enforcement gate is nonetheless wired into the create path, so the moment a platform-level cap is ever stored it will start blocking automatically — no code change required.
| Reseller cap | Feature gated | What "used" counts |
|---|---|---|
max_customers | tenant_limit | Number of tenants under the reseller. |
max_agents | agent_sessions | Users of type agent + tl across the reseller’s tenants. |
max_channels | chat · sms · email · whatsapp | Shared channel allotment reused across the per-channel features. |
Because seat and customer caps live on the reseller, the way you "license" a tenant for growth is to set the right max_customers and max_agents on its parent reseller (Chapter 5 → Resellers → Entitlements). A platform-direct tenant has no such ceiling — cap it by sizing the platform, or by moving it under a metered reseller.
The engine recognises a broad catalogue of licensable features — mirroring the reference license dashboard — even though only the three caps above are stored as numbers today. The rest resolve to unlimited until a cap is configured, so they never block spuriously. Knowing the catalogue helps you read the entitlement panel and anticipate future metering.
reseller · tenant_limit · agent_sessions · live_calls · extensions
sms · chat · email · whatsapp · social_media
crm · qa · tl · supervisor · user_roles · ticket
ai_bot · whatsapp_bot · transcript · google_tts
report_scheduler · custom_report · survey
advance_routing · voicemail · event_notification · mfa · sso
The gate is checked before the work is done. When you (or a reseller) try to add a tenant or seat that would exceed a stored cap, the request is refused cleanly and nothing is created:
If the tenant_limit check fails, the API returns 409 Conflict with a message such as “Customer limit reached for this reseller” (reseller path) or the engine’s detail “License limit reached for ‘tenant_limit’: used N + requested 1 > allotted N”. The same pattern protects agent-seat creation against agent_sessions.
Entitlements are checked at create time, not retroactively. If you reduce max_customers to a number below the tenants already provisioned, those existing tenants keep running — but no new ones can be added until usage drops back under the cap. Reduce caps deliberately, and communicate the freeze to the affected reseller.
CloudCX makes the entitlement engine observable. The platform Licensing view (sidebar → Revenue → Licensing) shows the platform-wide picture; each reseller and tenant portal shows its own scope. Behind it, GET /api/v1/admin/platform/entitlements returns an entitlements summary: headline used counts plus a per-feature row of allotted / used / unlimited / remaining.
Licensed capacity vs. live usage across the platform.
| Feature | Allotted | Used | Remaining |
|---|---|---|---|
| tenant_limit | unlimited | 86 | — |
| agent_sessions | unlimited | 512 | — |
customers_used).The summary payload is small and self-describing. Read the per-feature rows like this:
// platform scope — no stored caps today, so allotted = 0 (unlimited) { "scope": "platform", "customers_used": 86, // live tenant count "agents_used": 512, // agent + tl users, platform-wide "entitlements": [ { "feature":"tenant_limit", "allotted":0, "used":86, "unlimited":true, "remaining":null }, { "feature":"agent_sessions", "allotted":0, "used":512, "unlimited":true, "remaining":null } ] }
For a reseller scope the shape is identical but the numbers bite: allotted carries the stored cap and remaining is allotted − used (never negative). That is how the reseller portal renders headlines like “12 / 50 customers”.
Even where no cap is stored, the used figures are real database aggregates — total tenants, total agent/team-lead users. So the Licensing view is a trustworthy capacity dashboard today, and it becomes a true headroom gauge the instant you store caps. The per-feature pools for AI sessions, transcript minutes, recording storage and dialer ports are metered at the service layer and will surface here as that metering API ships.
A reseller, Acme Comms, has just signed a new end-customer, Northwind Logistics, and asked you to stand up their tenant. Acme is licensed for 50 customers and currently has 49. Follow the flow end-to-end.
max_customers in Chapter 5 → Resellers → Entitlements, otherwise the create would 409.)Because Northwind belongs to a reseller, the cleanest path is for Acme to create it from its reseller portal (which forces the new tenant under Acme and enforces max_customers automatically). As platform-admin you can also create it here; if you do, remember to set its owning reseller so it counts against Acme’s cap. Either way the field values are the same:
tenant login both exist. The console drops Northwind Logistics at the top of the Tenants list as Active.{
"id": "e7c1…a93f",
"name": "Northwind Logistics",
"username": "northwind-log",
"email": "[email protected]",
"status": "active",
"dial_prefix": "9",
"created_at": "2026-06-15T09:42:11Z"
}
max_customers (Resellers → Entitlements). The very next create will succeed — no restart, no migration.After creating a tenant, give the customer: their username, the console URL (their reseller’s branded login domain if set, otherwise cloudcx.app), and the initial password over a secure channel, with a note to rotate it on first sign-in and to verify the contact email. Their next steps — agents, queues and channels — are covered in the tenant-facing guide.
In a non-production / training environment:
max_customers to exactly 1 (Resellers → Entitlements).Trial One / trial-one. Confirm a 201 and that it appears in the Tenants list.remaining dropped to 0 again.Success looks like: you can articulate, from the live counters, exactly why the second create failed and what single change unblocked it.
max_customers, max_agents, max_channels) live on the reseller; platform-direct tenants are uncapped today.Everyone who signs in to CloudCX — you, your resellers, every tenant administrator, every team lead and every front-line agent — is a typed user account. This chapter is the operator’s guide to that directory: how the account types fit the platform hierarchy, where each kind of user is created, how to give agents the skills that route work to them, and how to harden sign-in with a password policy and two-factor authentication. It ends with a full worked example — standing up a supervisor and two skilled agents from scratch — and a Try-it exercise.
CloudCX is a strict three-tier platform: Platform Admin → Reseller → Tenant, with team leads and agents beneath a tenant. Each tier creates the tier below it. As a platform administrator you create resellers and platform-direct tenants; a reseller creates its own customers and team; a tenant creates its own agents and supervisors. The platform Security view in your console is the read-only book of every account across all tiers, plus the controls that apply platform-wide (password policy) and to your own login (2FA).
Every login is a row in the platform users table with one user_type. The type fixes what the account can see and do, and which slice of the tenant tree it is scoped to. There are exactly six types:
| Type | Console label | Scope (what they manage) | Created by |
|---|---|---|---|
admin | Platform admin | Everything — all resellers, all tenants, platform settings. tenant_id and reseller_id are both NULL. | Seeded / another admin |
reseller | Reseller | One reseller’s brand, customers and team (reseller_id set, tenant_id NULL). | Platform admin |
tenant | Tenant | The primary administrator login for one tenant (tenant_id set). | Reseller or platform admin |
subtenant | Sub-tenant | A delegated administrator within a tenant (department / business unit). | Tenant admin |
tl | Team lead | A supervisor inside a tenant — sees and coaches a team of agents. | Tenant / sub-tenant admin |
agent | Agent | A front-line user who handles calls and digital conversations. | Tenant / sub-tenant admin, or a reseller for its own team |
Two phrases recur throughout this manual, so fix them now. A supervisor is a tl (team-lead) account — the words are interchangeable. An agent is an agent account. Skills-based routing (Chapter 8) only ever delivers interactions to agent and tl users, never to administrators.
A username only has to be unique inside its tenant (the database key is (tenant_id, username)). So maria in Tenant A and maria in Tenant B are two different people — sign-in resolves the account by tenant. Platform admins have tenant_id = NULL, so keep admin usernames globally distinct to avoid ambiguity.
Open the console at admin.cloudcx.app, sign in as a platform administrator, and choose Security in the dark sidebar under the Governance group. The top of this view is the Platform accounts roster — every login the platform knows about, fetched live from GET /api/v1/users and shown newest-first.
Every account, fetched from the platform directory.
| Account | Role | Scope | Status | Created |
|---|---|---|---|---|
| AOAdmin Operator[email protected] | Platform admin | Platform | active | 2026-01-04 |
| ACAcme Comms[email protected] | Reseller | Reseller | active | 2026-03-22 |
| NCNorthwind Care[email protected] | Tenant | Tenant | active | 2026-05-08 |
| MSMaria Santos[email protected] | Team lead | Tenant | active | 2026-05-09 |
| JLJames Lee[email protected] | Agent | Tenant | active | 2026-05-09 |
| PRPriya Raman[email protected] | Agent | Tenant | inactive | 2026-05-10 |
data-view="security").user_type (Platform admin / Reseller / Tenant / Sub-tenant / Team lead / Agent).tenant_id is set, Reseller if reseller_id is set, else Platform.The platform Security view shows every account but does not create, edit or delete them from this screen — that keeps the platform console out of any single tenant’s day-to-day user administration. Accounts are provisioned where they belong: resellers and platform-direct tenants from Resellers / Tenants (Chapters 5–6), and a reseller’s own team and a tenant’s agents through the portals and API described next.
Agents and supervisors are created one tier down from the platform — by a reseller for its own team, or by a tenant administrator for the contact-centre staff. The mechanics are the same everywhere: a username, an email, a starting password (which must satisfy the platform password policy — § 7.5), and a role. The role you pick becomes the new account’s user_type.
A reseller manages its team from the reseller portal’s Team panel (/reseller → Team), backed by GET /api/v1/reseller/team and POST /api/v1/reseller/team. A reseller is deliberately restricted to two roles for its own staff:
user_type = reseller).user_type = agent).A reseller can never mint a platform admin, a tenant, a sub-tenant or a team-lead login — the server forces the new account’s reseller_id to the caller’s own reseller and rejects any role outside {reseller, agent} with 422. Team leads (supervisors) are created inside a tenant, not in the reseller team panel; see § 7.3.3.
Users belonging to your reseller account.
| Name | Role | Active | ||
|---|---|---|---|---|
| ACAcme Comms | [email protected] | Reseller | Edit | |
| DODana Okafor | [email protected] | Agent | Edit | |
| RTRavi Tan | [email protected] | Agent | Edit |
user_type, limited to Reseller or Agent.active and inactive via PATCH /reseller/team/{id}.reseller_id is yours, newest-first.422 with a message listing exactly what is missing.active immediately. A duplicate username returns 409.POST /reseller/team.Within a tenant, the staff directory is richer: a tenant (or sub-tenant) administrator creates agents and team leads (supervisors) for the contact centre. The account types are the same six from Table 7.1 — you pick Agent for a handler and Team lead for a supervisor who will monitor, whisper and barge (see Chapter 11 on supervision). Programmatically, every account is created through the platform user API:
Create a supervisor (team lead) under a tenant POST /api/v1/users # body { "username": "maria.santos", "password": "Wint3r!Garden42", "user_type": "tl", # tl = team lead / supervisor "email": "[email protected]", "tenant_id": "<northwind-tenant-id>", "first_name": "Maria", "last_name": "Santos" }
Swap "user_type": "tl" for "agent" to create a front-line agent instead. The new account inherits the tenant’s scope from tenant_id, so it can only ever see that tenant’s data. The starting password is validated against the platform policy exactly as in the portal flow.
The number of agent/team-lead seats a tenant may run is capped by its parent reseller’s licensed seat pool (the reseller’s max_agents entitlement). Creating staff consumes from that pool; when it is exhausted the create call is refused with 409. Watch the live counters on the Licensing view and in the reseller’s entitlement summary — the seat economics are covered in Chapters 5 and 6.
A skill is a named competency — Billing, Spanish, Tier-2 Support, VIP — that you attach to agents at a proficiency level. Queues then require a skill at a minimum level, and the ACD only routes an interaction to an agent who holds every required skill at or above that level. This is skills-based routing; the routing engine itself is Chapter 8. Here we cover the three building blocks an administrator manages.
The three building blocks, with their endpoints, are:
A name (and optional description) owned by a tenant. POST /api/v1/acd/skills.
A skill granted to a user at a proficiency level (1+). POST /acd/agents/{id}/skills.
A skill a queue requires at a min level. POST /acd/queues/{id}/skills.
Skills are managed under the ACD. Create one with a clear, reusable name — you will assign it to many agents and reference it from many queues:
Create the “Billing” skill POST /api/v1/acd/skills { "name": "Billing", "description": "Invoices, refunds, payment disputes" } # 201 Created { "id": "3f2a…", "tenant_id": "<tenant>", "name": "Billing", "description": "Invoices…" }
When a tenant or reseller administrator creates a skill, it is automatically stamped with their own tenant_id — you cannot create a skill in someone else’s tenant. A platform admin may target any tenant, or leave tenant_id NULL to create a shared platform-level skill.
Granting a skill to an agent records a proficiency level — a positive integer where higher means more capable. A common convention is 1 = trained, 3 = proficient, 5 = expert, but the scale is yours; what matters is consistency with the min level you put on queues.
Agents who hold this skill, and at what level.
| Agent | Type | Level | |
|---|---|---|---|
| JLJames Lee | Agent | Level 3 | Remove |
| MSMaria Santos | Team lead | Level 5 | Remove |
| AKAnya Kraft | Agent | Level 1 | Remove |
POST /acd/agents/{user_id}/skills with a level).DELETE /acd/agents/{user_id}/skills).409 — remove and re-add to change the level.Grant Billing at level 3 to an agent POST /api/v1/acd/agents/<james-user-id>/skills { "skill_id": "3f2a…", "level": 3 } # level defaults to 1 if omitted
Finally, tell a queue which skills it demands. A queue may require several skills, each with its own min level; an agent must clear every requirement to be eligible. Attach a required skill with:
Require Billing at min level 2 on the Billing queue POST /api/v1/acd/queues/<billing-queue-id>/skills { "skill_id": "3f2a…", "min_level": 2 } # min_level defaults to 1
With the example numbers, James (Billing 3) and Maria (Billing 5) clear the queue’s min 2, but Anya (Billing 1) does not — she will not be offered Billing interactions until her level is raised. You can confirm staffing live at GET /acd/queues/{id}/stats, which reports the queue’s member count, how many are available right now, and its required skills.
Adding an agent as a queue member (POST /acd/queues/{id}/members) and granting the skills a queue requires are two different things. Routing needs both: an agent is offered a queue’s work only when they are a member and hold every required skill at the minimum level. Keep skills as the portable competency and membership as the assignment — an agent can hold “Spanish” without being staffed on every Spanish queue.
The password policy is a single platform-wide rule that every new password is checked against — reseller team members, tenant and reseller bootstrap logins, and any password change. You edit it from the platform console under Security → Password policy, backed by GET/PUT /api/v1/admin/platform/security/password-policy. The defaults are deliberately permissive (minimum length 8, no character-class requirements) so nothing breaks before you opt in.
At least one A–Z character.
At least one a–z character.
At least one 0–9 character.
At least one non-alphanumeric.
Tightening the policy does not re-validate or expire existing passwords — current users keep their logins until they next change a password. When a new password fails, the create/change call returns 422 with a single human-readable message, for example: Password does not meet the policy: must contain at least 12 characters, an uppercase letter, a digit. If you need everyone moved to the stronger rule at once, drive a coordinated password reset.
CloudCX supports two opt-in second factors, both off by default so that turning them on never disturbs anyone who has not enrolled. From Security → Two-factor authentication you manage the second factor on your own signed-in account; every account holder manages their own from their respective console.
The panel walks through three states — enroll (scan a secret), activate (confirm a code, receive backup codes) and enabled. Behind them sit three endpoints: POST /auth/mfa/enroll, POST /auth/mfa/activate and POST /auth/mfa/disable.
Add this account to your authenticator app, then enter the 6-digit code it shows to finish.
otpauth QR / deep link — opens your authenticator app
Each code works once if you lose access to your authenticator. They will not be shown again.
XXXXX-XXXXX.There is no admin “reset my 2FA” button — the secret is encrypted at rest and the platform never sees your codes. If you lose your authenticator and your backup codes, recovery means a manual, audited account intervention by the platform operator. Print or vault the backup codes the moment they appear, and store them apart from the device running the authenticator.
Once enabled, the sign-in flow asks for a second factor after the password is accepted. Supply a current TOTP code (or, in a pinch, an unused backup code) in the code field. If you omit it, the API replies 401 mfa_required so the console knows to prompt; a wrong code returns 401 Invalid 2FA code. Email sign-in codes work the same way, challenging with otp_required and delivering the code by email.
To turn 2FA off, open the panel (it shows Enabled ✓), enter a current 6-digit code, and click Disable 2FA. CloudCX verifies the code (a backup code also works), then clears the secret and all backup codes. The account returns to single-factor sign-in.
The single highest-value account to protect is yours — the platform administrator. Enrol your own login in 2FA first, then make it a stated requirement that every reseller and tenant administrator does the same. Combined with a strong password policy and (for sensitive deployments) IP access rules, this is the baseline you should hold every operator to.
An account’s lifecycle is governed by its status, which has three values:
403.The most common operation — a leaver, or a contractor between shifts — is to deactivate rather than delete. Deactivating preserves all of the account’s history (CDRs, conversations, QA scores) while immediately barring sign-in. In a reseller’s Team panel this is the Active toggle (a one-click flip via PATCH /reseller/team/{id} with {"active": false}); for tenant staff the same status field is set to inactive.
status = inactive). The change is immediate: any new sign-in is refused with 403 User is not active.status = active) to restore access — no re-creation, no lost history.Prefer inactive for anyone who might return, or whose records you must retain. Hard deletion exists but cascades widely — deleting a tenant removes all its users, queues and skills via foreign-key cascade. A deactivated agent also vacates their licensed seat conceptually but keeps their skill grants, so re-activation restores them to exactly their previous routing eligibility.
Northwind Care, a tenant under the Acme Comms reseller, is opening a billing line. As the administrator you will: create a supervisor and two agents, build the Billing skill, grade the agents, require the skill on the Billing queue, harden the password policy, and turn on 2FA for the supervisor. Follow it end-to-end.
maria.santos as a tl in the Northwind tenant with password Wint3r!Garden42 (12+ chars, mixed case, a digit — it passes).james.lee and priya.raman as agent in the same tenant, each with a policy-compliant password.POST /acd/skills with name Billing. Note its returned id.POST /acd/queues/{id}/members).GET /acd/queues/{id}/stats. Maria and James clear min 2; Priya (level 1) does not — she is a member but not yet eligible for Billing work.The skill calls, in order # 4 — create the skill POST /api/v1/acd/skills { "name": "Billing" } # -> id 3f2a… # 5 — grade the three users POST /api/v1/acd/agents/<maria>/skills { "skill_id": "3f2a…", "level": 5 } POST /api/v1/acd/agents/<james>/skills { "skill_id": "3f2a…", "level": 3 } POST /api/v1/acd/agents/<priya>/skills { "skill_id": "3f2a…", "level": 1 } # 6 — require it on the queue, min level 2 POST /api/v1/acd/queues/<billing-q>/skills { "skill_id": "3f2a…", "min_level": 2 } # 9 — confirm staffing GET /api/v1/acd/queues/<billing-q>/stats # -> { members: 3, available_now: 2, required_skills: [{ skill_id: 3f2a…, min_level: 2 }] }
Result. The Billing queue is staffed by three members, two of whom are immediately eligible. When Priya finishes onboarding, remove and re-add her Billing grant at level 2 (or higher) and she joins the eligible pool with no other change. Maria’s sign-in is now protected by a second factor, and every account was forced to meet the stronger password policy at creation.
Extend the worked example in your training tenant:
sofia.diaz at level 5.priya.raman, confirm her status pill flips to inactive and that a sign-in attempt is refused, then re-activate her.422 message CloudCX returns.tl).inactive) rather than delete to bar sign-in while preserving history and skill grants; re-activation is one click.Every voice interaction on CloudCX crosses a carrier. Inbound, a customer dials one of your DIDs (the numbers you own) and the platform routes the call to a flow, queue, extension or mailbox. Outbound, the dialer hands the call to a SIP trunk — an outbound carrier — chosen by your least-cost-routing (LCR) rules. This chapter covers the whole Call Routing workspace: managing the inbound DID inventory, registering a SIP carrier, writing LCR rules that pick the cheapest matching trunk per destination prefix, and reading the review-only SBC config the platform generates for each trunk. It closes with a fully worked carrier cut-over and a hands-on Try-it exercise.
CloudCX is the control plane. It stores your numbers, trunks and routing rules and uses them at call time to pick a gateway. It deliberately never writes to or reloads your live CloudCX Switch or CloudCX SBC configuration. Provisioning a trunk creates a row; the generated gateway config is review text for an operator to apply on the session border controller (SBC) by hand. This keeps a working voice path safe: until you both provision a trunk and install its gateway, nothing about outbound dialling changes.
Open the console at admin.cloudcx.app, sign in with your platform-administrator account, and select Call Routing in the dark sidebar under the Platform group. The page brings four things together on one screen: the live ACD queues and inbound DIDs that decide where a call lands, and the SIP carrier trunks plus least-cost routing that carry outbound traffic.
Use the breadcrumb chip below as your map for the procedures in this chapter:
The live ACD queues and inbound DIDs, plus the SIP carrier trunks and least-cost routing that carry outbound traffic.
The grey monospace text beside the page title (// live · /api/v1/acd/queues + /dids) tells you the panels are showing real data. If a panel reads // data unavailable or a list shows // … unavailable, the call failed — check your session and the API before assuming the inventory is empty.
A DID (Direct Inward Dialing number) is a phone number a tenant owns. When a customer calls it, the inbound engine looks the dialled number up and routes the call to its configured destination. Every DID is tenant-scoped: a tenant user sees only their own numbers; a platform administrator sees all of them. Writes are admin-gated, so creating, editing or removing a DID requires an administrator.
On the Call Routing screen the Inbound DIDs panel is a read-only roster — it shows the number, its name, its destination and whether it is live. To add or edit a DID you use the dedicated Numbers view inside the IVR Builder, reached from Voice › IVR Builder › Numbers. Both surfaces talk to the same /dids API.
Each DID carries a destination_type and a destination_ref. The type chooses what kind of thing answers; the ref points at the specific one:
| destination_type | What answers the call | destination_ref is… |
|---|---|---|
flow | An IVR flow (menus, prompts, business hours, branching). | The published IVR flow’s id (picked from a list). |
queue | An ACD queue — straight to skills-based agent routing. | The queue’s reference. |
extension | A single extension / endpoint. | The extension number. |
voicemail | A mailbox that records a message. | The mailbox identifier. |
Setting a DID inactive (active = false) stops it routing but preserves the row for history and easy re-enablement. Only delete a number you have genuinely released back to the carrier.
The DIDs this tenant owns and where each inbound call is routed.
| Number | Name | Destination | Reference | Status | |
|---|---|---|---|---|---|
| +65 3158 1200 | SG main line | flow | Main Greeting | active | Edit · Delete |
| +65 3158 1234 | SG sales DID | queue | sales | active | Edit · Delete |
| +44 20 4525 9000 | UK reception | voicemail | vm-uk-reception | off | Edit · Delete |
GET /dids).POST /dids).destination_type (flow / queue / extension / voicemail).PATCH / DELETE /dids/{id}).+6531581200). This is the string the inbound engine matches a call against, so it must match what arrives on the trunk.SG main line — this is what appears in the roster and in reports.Creating the DID tells CloudCX where to send a call to that number. It does not make the carrier deliver calls for that number to your SBC — that is provisioned with the carrier and routed in your inbound dialplan. A DID whose carrier delivery or destination isn’t ready will simply never ring.
A SIP trunk is an outbound carrier the platform can route a call over. Each trunk is one row holding the gateway address, optional SIP authentication, transport and codec preferences, and a channel cap. Critically, the trunk’s name doubles as the CloudCX Switch CloudCX Switch gateway name — the dialer routes through byondswitch/gateway/<name>/<number> — so the name is restricted to a safe gateway charset (letters, digits and . _ - only; no spaces or metacharacters).
Every trunk (and every LCR rule) carries two optional owners, which together set its scope:
| tenant_id | reseller_id | Scope — who routes over it |
|---|---|---|
| set | — | A single tenant’s own trunk/rule. |
| — | set | A reseller’s self-provisioned trunk/rule (shared by its tenants). |
| — | — | Platform-shared — a CloudCX wholesale trunk every scope can fall back to. |
At call time a tenant call considers its own trunks plus the platform-shared ones; a reseller call considers its own plus platform-shared. A tenant can therefore never route over another tenant’s carrier, yet you can still offer a shared CloudCX wholesale trunk as a default. As a platform administrator you set the owner when you create the trunk; a self-provisioning reseller’s trunks are always pinned to that reseller.
From the Call Routing screen, click + Add trunk in the SIP carrier trunks panel. The form opens inline. Most fields are optional — only Name and Gateway host are required.
name., _, - only — e.g. primary-carrier or didww-sg. Rejected (422) if it contains a space or other metacharacter.gateway_hostsip.carrier.com. Used as the SIP realm and, unless a proxy is set, as the next hop.gateway_port1–65535. Defaults to 5060.max_channelsusernamepasswordfrom_domainFrom domain, if the carrier requires a specific one. Defaults to the gateway host.proxyhost:port.transportudp. Use TLS for encrypted signalling where the carrier supports it.codecsOPUS,PCMU,PCMA. Leave blank to use the carrier/profile default.registerenabledPOST /admin/telephony/trunks (the password is encrypted at rest).primary-carrier. Remember this becomes the gateway your dial strings reference.udp + OPUS,PCMU,PCMA), and set Max channels to your contracted concurrency (or leave 0 for unlimited).A saved trunk renders as one row: a green dot when enabled (grey when disabled), the name, and a monospace meta line of host:port · TRANSPORT · register|IP auth, followed by its channel cap (“30 ch” or “no cap”), an enabled / disabled pill, and three row actions: view config, edit and delete.
host:port · transport · auth meta.max_channels = 0).Click edit (✎) and the form repopulates every field except the password, which shows the hint leave blank to keep current. Save with the password blank to keep the stored secret; type a new value only when you are rotating it. To remove authentication entirely (move to IP auth), clear the password field explicitly.
With trunks registered, least-cost routing decides which trunk carries each outbound call. An LCR rule maps a destination prefix (the leading digits of the dialled number) to a trunk, with a priority and an optional per-minute cost. At call time the platform evaluates the rules in scope and picks the best matching trunk.
With no trunks and no LCR rules, outbound dialling is byte-for-byte unchanged: the platform finds no match and falls back to the existing default gateway (fs_outbound_gateway). Selection is also fully defensive — any lookup error degrades to the fallback — so turning LCR on can never take down a working dial path. You opt in to LCR by adding rules.
The dialled number is normalised to digits, then candidate rules (those in the call’s scope whose trunk is enabled) are ranked. Selection follows this order:
destination_prefix is a leading prefix of the dialled digits. The empty prefix “” is a catch-all that matches everything.6597 beats 65 beats the catch-all for a Singapore mobile.priority number wins (lower = preferred).per_minute_cost (rules without a cost rank after those with one), and finally the most recently created rule.The winning rule’s trunk supplies the CloudCX Switch gateway name for the byondswitch/gateway/<name>/<number> dial string. If that trunk is missing or disabled, or its name fails the safe-charset check, selection yields nothing and the call falls back to the default gateway — never a broken dial string.
In the Least-cost routing panel, click + Add rule.
destination_prefix1, 44, 65. Non-digits are stripped (a leading + or spaces are fine to type). An empty value is the catch-all default. Required by the UI.trunk_idpriorityper_minute_cost6591) sits at the top — it wins for Singapore mobiles.A catch-all rule (empty prefix) routes every destination that no longer prefix matches — including international numbers you may not have rated. Give it a sensible high priority number (so any specific rule beats it) and point it at a trunk that can legitimately reach everywhere, or omit it and let unmatched calls fall back to the default gateway.
Provisioning a trunk records its details; it does not touch your SBC. To install the gateway you ask CloudCX to generate the config text for the trunk, review it, and apply it on the SBC yourself. Click the view config action (⟨⟩) on a trunk row to open the config viewer.
The viewer calls GET /admin/telephony/trunks/{id}/config and shows two blocks for the same trunk:
<gateway> XML block — drop it in as conf/sip_profiles/external/<name>.xml (or inside the external profile’s <gateways>), then run CloudCX Switch service external rescan.dispatcher.list next-hop line, plus (for a registration trunk) a uacreg row for outbound REGISTER.Because the operator needs a working gateway, the real SIP password is embedded in this text (decrypted only here) — which is exactly why the endpoint is admin-gated. If the password cannot be decrypted (no key, or none stored) the config emits the placeholder <SET_PASSWORD_ON_SBC> so the text is obviously incomplete rather than silently wrong.
# ============================================================ # Generated SBC gateway config for trunk 'primary-carrier' # host=sip.carrier.com:5060 transport=udp # REVIEW ONLY — apply manually on the SBC. # ============================================================ <!-- CloudCX Switch CloudCX Switch gateway for SIP trunk primary-carrier --> <gateway name="primary-carrier"> <param name="username" value="byond-sg"/> <param name="password" value="s3cr3t-from-vault"/> <param name="auth-username" value="byond-sg"/> <param name="realm" value="sip.carrier.com"/> <param name="proxy" value="sip.carrier.com:5060"/> <param name="register" value="false"/> <param name="transport" value="udp"/> <param name="codec-prefs" value="OPUS,PCMU,PCMA"/> </gateway> # CloudCX SBC edge SBC config for SIP trunk primary-carrier # dispatcher.list (reload: kamcmd dispatcher.reload) # 1 sip:sip.carrier.com:5060;transport=udp 0 0 name=primary-carrier
The copied text contains the trunk’s real SIP password. Treat it like any credential: copy it straight into your SBC change, don’t paste it into chat or tickets, and clear your clipboard afterwards. Nothing about generating this text changes the live SBC — you must apply and reload it deliberately.
Goal: route Singapore mobile calls (prefix 6591…6599) over a cheaper wholesale carrier, while everything else continues to use the primary carrier — with the primary as the safety net.
budget-mobile with the wholesaler’s host, the right transport/codecs, your channel cap, and Enabled on. Save and confirm the 201.budget-mobile row, review the CloudCX Switch block, apply it on the SBC and run CloudCX Switch service external rescan. Verify the gateway shows up / registers as expected.659, trunk budget-mobile, priority 5, cost 0.0040. Save.primary-carrier at a high priority number (e.g. 100) exists, so all non-mobile traffic still has a home.659 above the catch-all (longest prefix first). A test dial to +65 9123 4567 now matches 659 and routes via budget-mobile; a dial to a +65 6… landline matches only the catch-all and stays on primary-carrier.Why this is safe: the new carrier only takes traffic the moment its LCR rule exists and its gateway is installed. If you add the rule but skip the gateway, the dialer can’t reach the trunk and the call falls back to the default gateway — you never strand mobile calls on a half-built carrier.
In a non-production / training environment:
test-primary (a catch-all carrier) and test-uk (a UK carrier). Confirm both appear in the trunk list and in the LCR Trunk dropdown.“” → test-primary @ prio 100; 44 → test-uk @ prio 10; 4420 → test-uk @ prio 5 with cost 0.0050.test-uk; confirm the CloudCX Switch <gateway name="test-uk"> block and the CloudCX SBC dispatcher line both name the trunk, and note the “REVIEW ONLY” header.test-uk (toggle Enabled off) and reason out what a call to +44 20 … would now do, given that selection skips disabled trunks.Success looks like: you can predict, for any dialled number, which trunk LCR will pick (or that it falls back), and you can explain why deleting all rows is non-disruptive.
fs_outbound_gateway — outbound is unchanged until you opt in.view config) is review only — a CloudCX Switch gateway block plus a CloudCX SBC snippet, with the real password embedded. CloudCX never writes or reloads your live SBC; you apply it by hand.When a caller dials one of your numbers, something has to decide what they hear and where they land — a greeting, a press-1-for-sales menu, a queue of agents, a voicemail box, or a straight transfer to an extension. In CloudCX that decision is a flow: a small graph of nodes you draw on a drag-and-drop canvas, wire together, validate, and publish. This chapter takes you through the builder end to end — the canvas and its parts, every node type and its fields, how a flow is serialized and validated, how to attach it to a phone number, and a fully worked main-menu · hours · queue IVR you can reproduce in minutes.
A published flow is stored as a single JSON document of the shape { "start": "<node id>", "nodes": { "<id>": {…}, … } }. The builder is just a friendly editor for that document — it emits the same JSON the inbound voice engine reads at call time. The two halves share one contract (app/schemas/ivr.py), so anything the builder lets you save is something the interpreter knows how to walk. The flow does not run until you press Publish and a DID points at it.
The call-flow builder is a dedicated tool that lives beside the admin console at admin.cloudcx.app/ivr-builder.html. It uses the same sign-in as the console (your admin session carries over); if your session has lapsed it shows a brief Sign-in required gate and bounces you to admin.cloudcx.app to re-authenticate. From the console you reach it via the Voice group in the sidebar; from inside the builder the ← CONSOLE chip in the top-left returns you to the dashboard.
Two things to know before you start drawing:
Drag a node from the palette onto this canvas, wire the outputs to targets, then Save and Publish. Or hit Load to open an existing flow.
PROD; the avatar shows the signed-in admin.new / unsaved / draft / published.If you are a platform administrator, set the Tenant selector before you save a new flow — the owning tenant is stamped at create time and cannot be changed afterwards. A tenant user has no picker; flows are pinned to their own tenant automatically.
A flow is built from three moving parts: nodes (the boxes), edges (the arrows that connect them), and a single Start node (the entry point the call begins at). You arrange nodes on the canvas purely for your own readability — position is a convenience, not part of the saved flow — while the edges are the routing logic.
Every node card has the same anatomy. Knowing it makes wiring fast:
id (its key in the JSON). Drag the header to reposition the card.menu node (Start) wired to a queue node.Dropping a connection back onto its own source node is refused with a toast. Loops between nodes are allowed (a menu’s Invalid input commonly points back at the menu to re-prompt), and the interpreter has a per-call visit cap so a misdrawn cycle can never wedge a live call.
Every node has a type and a small set of fields. The table below is the complete catalogue — the same set the palette offers and the same set the interpreter executes. The Outputs column lists each node’s outgoing edges; an output that is left blank simply means “end the call here.”
| Node | What it does | Key fields | Outputs (edges) |
|---|---|---|---|
| Play | Plays a prompt, then continues. | prompt | next required |
| Menu | Plays a prompt, gathers DTMF digit(s), routes on the pressed key. | prompt, timeout_ms (5000), max_digits (1), options | one per digit (options), invalid, timeout |
| Queue | Enqueues the caller into an ACD queue; plays hold music while hunting an agent. | queue_ref, timeout_sec (120) | on_timeout |
| Dial | Bridges the caller to an extension / number. | target, timeout_sec (30) | on_no_answer |
| Hours | Branches on business hours evaluated in a timezone. | timezone, ranges[] | open req, closed req |
| Voicemail | Plays an optional greeting, records a message to a mailbox. | mailbox, greeting (optional) | next (optional) |
| Hangup | Terminates the call. | — | none |
Three node types (Play, Menu, and the optional Voicemail greeting) carry a prompt. A prompt has a kind and one payload field:
kind:"tts"text you type to speech at call time. The everyday choice.kind:"say"text via the say engine (useful for numbers/IDs read out literally). Also requires text.kind:"audio"url (e.g. an https://…/greeting.wav or an uploaded asset). Requires url.The inspector swaps the field automatically: pick Audio and you get a URL box; pick TTS or Say and you get a Text box. A prompt that is missing its text (or url) is flagged before you can publish.
Understanding the runtime makes you a better flow author. When a call arrives the engine resolves the dialled DID, finds its destination, and — for a flow destination — loads the published, active flow, validates it, answers, then walks the graph from start:
startflow-type DID (the interpreter in flow_runtime.py).next.max_digits within timeout_ms. A matched digit follows its option; unmatched input follows invalid; no input follows timeout (and if either fallback is unset, the call ends gracefully).timezone against the open ranges and follows open or closed. If the ranges can’t be parsed it fails open — better to let a caller through than to wrongly say “closed.”timeout_sec; if nobody answers it follows on_timeout.target for timeout_sec; on no-answer it follows on_no_answer.next or hangs up.The interpreter only loads flows that are both published and active. A saved-but-unpublished draft never answers a call, and even a published flow does nothing until a DID routes to it (§ 9.7). Saving and publishing are two distinct steps.
Selecting a node opens its inspector on the right. The panel is type-aware: it always shows the node’s id (renameable) at the top, then the fields and routing for that type, with a footer to set Start or delete the node. Every change is live — the canvas card and edges update as you type, and the flow is marked unsaved.
START tag appears when this is the entry node.menu node — id, prompt, digit options and footer actions.Pick the ACD queue from the live list (the names come from /api/v1/acd/queues); set a timeout in seconds; wire On timeout to a fallback (often a voicemail).
Type a target extension or number (e.g. 1001 or +6531590000), a ring timeout, and an On no answer branch.
Set an IANA timezone (e.g. Asia/Singapore), add one or more open windows (day + HH:MM–HH:MM), then wire When OPEN and When CLOSED.
Give a mailbox name (e.g. acme-main); optionally enable a greeting prompt; optionally set After to continue, else the call ends after recording.
The default ids are menu_1, queue_1, … They work, but rename them to intent — main_menu, sales_q, closed_vm — and your flow JSON (and any later audit) reads like plain English. Renames repoint links for you, so do it early and often.
Authoring a flow has three gears: Save (persist a draft), validate (the builder’s pre-flight check, mirrored by the server), and Publish (make it live). A draft can be saved at any time, even half-finished; publishing is gated on a clean validation.
Both the builder (instantly, in-browser) and the server (authoritatively) run the same rules — the contract in app/schemas/ivr.py. A flow is publishable only when all of the following hold:
next; every Menu has a prompt and at least one digit option; Queue has a queue; Dial has a target; Voicemail has a mailbox; Hours has a timezone and both open and closed targets.source → missing-target pair by name.You can keep editing after publishing. Your edits are saved as the working draft; re-press Publish to push the new version live. Because a DID resolves the published flow by id (or name), re-publishing the same flow updates callers’ experience without touching the DID.
Let’s build a complete, real-world inbound flow for Acme Retail: during business hours, greet the caller and offer a menu (sales or support, each into its own queue); outside hours — or whenever a menu times out or no agent answers — take a voicemail. This is the canonical pattern and it exercises six of the seven node types.
The target graph has six nodes:
Main line IVR.Asia/Singapore and add windows for Mon–Fri 09:00–18:00 (one window per weekday). Rename its id to hours.greeting, set a TTS prompt: “Welcome to Acme Retail.”main_menu, set a TTS prompt: “Press 1 for sales, 2 for support.” Leave Timeout 5000 ms and Max digits 1.sales_q and support_q; pick the matching ACD queues; leave the 120 s timeout.closed_vm, set Mailbox acme-main, and enable a greeting: “We are closed. Please leave a message.”hours → When OPEN → greeting; greeting → Next → main_menu.main_menu Press 1 → sales_q and Press 2 → support_q. Connect its No input (timeout) → closed_vm; point Invalid input back at main_menu to re-prompt.hours When CLOSED → closed_vm; and each queue’s On timeout → closed_vm.{ "start": "hours", "nodes": { "hours": { "type":"hours", "timezone":"Asia/Singapore", "ranges":[{"day":0,"open":"09:00","close":"18:00"}, /* …Tue–Fri… */], "open":"greeting", "closed":"closed_vm" }, "greeting": { "type":"play", "prompt":{"kind":"tts","text":"Welcome to Acme Retail."}, "next":"main_menu" }, "main_menu": { "type":"menu", "timeout_ms":5000, "max_digits":1, "prompt":{"kind":"tts","text":"Press 1 for sales, 2 for support."}, "options":{"1":"sales_q","2":"support_q"}, "invalid":"main_menu", "timeout":"closed_vm" }, "sales_q": { "type":"queue", "queue_ref":"sales", "timeout_sec":120, "on_timeout":"closed_vm" }, "support_q": { "type":"queue", "queue_ref":"support", "timeout_sec":120, "on_timeout":"closed_vm" }, "closed_vm": { "type":"voicemail", "mailbox":"acme-main", "greeting":{"kind":"tts","text":"We are closed. Please leave a message."} } } }
Read the graph as a caller would: Mon 10:30 → hours open → greeting → menu → press 1 → sales queue (120 s) → if no agent → voicemail. Sat 14:00 → hours closed → straight to voicemail. If every path ends at an agent or a voicemail, you have no dead ends.
A flow only answers calls once a DID (a phone number you own) points at it. Switch to the DIDs tab to see every inbound number and where each one routes. A DID’s destination is one of four kinds: a flow (IVR), a queue (straight to ACD, no menu), an extension, or a voicemail mailbox.
Phone numbers you own and where each inbound call goes.
| Number | Name | Destination | Routes to | State |
|---|---|---|---|---|
| +6531591234 | Main line — Sales | flow | Main line IVR | active |
| +6531590010 | Support hotline | queue | support | active |
| +6531590099 | After-hours | voicemail | acme-main | off |
/api/v1/dids. Switch back to Builder any time.flow / queue / extension / voicemail, colour-coded.+6531591234) and a Label (e.g. Main line — Sales).The Target flow picker lists drafts too (marked (draft)). You can attach a DID to a draft, but the engine only runs published flows — until you publish, callers to that number hear the “menu is unavailable” fallback. Publish the flow, then your number is fully live.
In a non-production tenant, reproduce the worked example and prove every branch end to end:
main_menu: Menu has no digit options. Add the options and re-publish to green.closed_vm node, then re-publish — observe the dangling-reference errors naming each edge that pointed at it (e.g. main_menu → closed_vm). Re-add it and re-wire.Success looks like: a published flow whose every path ends at an agent or a voicemail, a number routed to it, and a clear mental trace of the open and closed paths.
{start, nodes} JSON the engine runs.flow) points at it — set that on the DIDs tab.Queues your menu feeds are configured in the ACD chapter; the agents who answer them, and the voicemail boxes your flows record into, are covered alongside the voice channel. With flows and DIDs in place, your inbound voice routing is complete end to end.
The Automatic Contact Distributor (ACD) is the brain that decides which agent handles which interaction. When a call rings in or a web chat opens, CloudCX picks the right available agent from a queue, using the agents’ skills and the queue’s chosen routing strategy. This chapter shows you how queues, skills, queue membership and the four strategies fit together, how to read a queue’s live stats, and — with a fully worked skills-based example — how to make sure the right interaction always reaches the right person.
You can see the live ACD queues in the platform console under at admin.cloudcx.app. The full set of ACD write operations — creating queues and skills, adding members, attaching required skills and granting agent skills — is exposed by the ACD API under /api/v1/acd. Throughout this chapter the read views are shown as console mockups and the management actions as the matching API calls, so you can drive them from a provisioning script or your own admin tooling.
CloudCX’s ACD joins three sources of truth to choose the next agent. Two are static configuration you manage (membership and skills, held in the database); the third is live presence — who is signed in and available right now — held in a fast in-memory store and updated every time an agent changes state. The chosen agent is always the intersection of all three, narrowed by the queue’s strategy.
Put plainly, the ACD answers one question on every inbound interaction: “Of the agents staffed on this queue, who is available right now AND meets every skill the queue requires — and among those, who should get it?” The first two clauses build the eligible set; the strategy breaks the tie.
Membership and skills are configuration — you set them and they persist. Availability is
live: an agent is only routable while their presence state is available (not
break, acw — after-call work — or offline). Presence is covered
with the supervisor wallboard in Chapter 16; here, just remember that a perfectly skilled, fully staffed
queue still routes to nobody if no member is currently available.
The Call Routing view is your read-only window onto the live ACD. The left panel, ACD queues, lists every queue you can see — each row shows the queue name, its strategy and channel, its priority, and whether it carries a wait-time SLA. (The same view also lists inbound DIDs and the SIP carrier trunks; those are covered in Chapter 11 — Telephony & the SBC.) Use this view to confirm that a queue exists, that it is pointed at the right channel, and that its strategy is what you intend before you start attaching skills and members.
Live distribution lines that connect interactions to agents.
| Queue | Strategy · channel | Priority | SLA |
|---|---|---|---|
| SBSales — Billing | priority · voice | prio 10 | max 45s |
| ESEspañol — Support | longest_idle · voice | prio 5 | max 60s |
| WCWeb Chat — General | fewest_calls · webchat | prio 0 | no SLA |
| DWDefault Webchat | longest_idle · webchat | prio 0 | no SLA |
/acd/queues.prio N; higher wins when several queues compete for the same agents.max Ns when a wait ceiling is set, or no SLA when max_wait_seconds is blank.The Call Routing view displays queues; it does not create or edit them. The Default Webchat row is
a platform-level queue the system creates automatically the first time a web chat needs routing — it has no
owning tenant and uses the longest_idle strategy. To create your own queues, set strategies, and
attach skills and members, use the ACD API described next (or your provisioning tooling that wraps it).
A queue is a waiting line for one channel with a configured distribution strategy. Every interaction that needs an agent is routed through exactly one queue. A queue can be tenant-scoped (owned by a single customer and visible only to them) or platform-level (no owning tenant — the shared default lines). The queue itself carries no agents and no skills directly; those are attached as members (§ 10.5) and required skills (§ 10.4).
string, 1–255text · optionalenumlongest_idle (default), round_robin, fewest_calls or priority. See § 10.6.string, ≤32voice, webchat, etc. Defaults to any (unrestricted).int · default 0int · optionaluuid · optionalvoice, webchat or any channel. One queue serves one channel; create separate queues for voice and chat even if the same team staffs both.longest_idle for fair rotation. Choose another only when you have a specific reason (§ 10.6 has a decision guide).priority at 0 unless this queue must out-rank others for shared agents. Set max_wait_seconds if the queue has a wait-time target./acd/queues. The response includes the new queue’s id — you’ll need it to attach members and skills.# POST /api/v1/acd/queues — returns the created queue incl. its id. curl -X POST https://cloudcx.app/api/v1/acd/queues \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Sales — Billing", "description": "Billing & account questions for the sales line", "strategy": "priority", "channel": "voice", "priority": 10, "max_wait_seconds": 45 }'
Deleting a queue (DELETE /acd/queues/{id}) also removes all of its
memberships and required-skill rows — the link rows are cascaded. It does not delete the
skills themselves or the agents. Live interactions already routed to an agent are unaffected; only future routing
through that queue stops. Re-create the queue and you must re-attach members and skills.
To change a queue’s strategy, priority or SLA later, PATCH /acd/queues/{id}
with only the fields you want to change — unset fields are left untouched. Switching the strategy takes
effect on the next interaction; in-flight routing is never disturbed.
A skill is a competency tag — billing, spanish, tier-2, retentions. Skills do two jobs: agents hold a skill at a proficiency level, and queues require a skill at a minimum level. An agent is eligible for a skill-gated queue only when they hold every required skill at or above the queue’s minimum. Like queues, a skill can be tenant-scoped or platform-level.
The competency itself: a name (e.g. billing) and optional description. Created once, then referenced by agents and queues.
An agent has a skill at a level — 1 = basic, higher = stronger. One row per agent-skill pair.
A queue requires a skill at a min level. An agent must meet it to be eligible for that queue.
Levels are simple integers (≥ 1). They gate eligibility, and for the priority strategy they also rank who gets the interaction first.
/acd/skills with a name (and optional description). The response returns the skill’s id./acd/agents/{user_id}/skills with the skill_id and a proficiency level (default 1). One grant per agent-skill pair./acd/queues/{queue_id}/skills with the skill_id and a min_level (default 1). Now only agents who hold the skill at that level or above are eligible./acd/queues/{queue_id}/stats and confirm the required skill is listed and that members / available_now look right (§ 10.7).# 1) Create the skill (returns its id, here $SKILL). curl -X POST https://cloudcx.app/api/v1/acd/skills \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "name": "billing", "description": "Billing & invoicing competency" }' # 2) Grant the skill to an agent at level 3. curl -X POST https://cloudcx.app/api/v1/acd/agents/$AGENT/skills \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "skill_id": "'$SKILL'", "level": 3 }' # 3) Require the skill on a queue at min_level 2. curl -X POST https://cloudcx.app/api/v1/acd/queues/$QUEUE/skills \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "skill_id": "'$SKILL'", "min_level": 2 }'
An agent can hold a given skill only once, and a queue can require a given skill only once. Re-posting the same
agent-skill or queue-skill pair returns 409 Conflict. To change a level, remove the existing
grant (DELETE the matching skill row) and re-add it with the new level — the API
treats the pair as a set membership, not an upsert.
Deleting a skill (DELETE /acd/skills/{id}) cascades to every
agent grant and every queue requirement for that skill. Queues that required it lose the requirement
silently — which can widen their eligible set. Review which queues require a skill before you delete
it.
A queue member is an agent staffed on a queue. Membership defines the candidate pool: only members can receive a queue’s interactions. The eligible set is then this pool narrowed to those who are available now and who meet every required skill. An agent can be a member of many queues; a queue can have many members.
The figure below shows the conceptual Queue members management surface — the staffed agents on a queue with their live presence and the queue’s required skills alongside, exactly the join the ACD performs at route time. (Membership and skill grants are applied through the ACD API; this is how they read back when assembled for an admin.)
| Agent | Presence | billing |
|---|---|---|
| PRPriya R. | available | lvl 3 |
| TMTom M. | acw | lvl 2 |
| L6Léo D. | available | lvl 1 |
billing ≥ 2.POST /acd/queues/{id}/members).billing level; below the queue’s min (red) means ineligible.billing ≥ 2).Reading this example: Priya is a member, available, and holds billing at level 3 —
eligible. Tom holds billing at level 2 (meets the gate) but is in after-call work, so he is not
routable right now. Léo is available but holds billing only at level 1, below the queue’s
min_level of 2 — ineligible. So at this instant the queue’s eligible set is just
{Priya}.
# Add a member (409 if already a member of this queue). curl -X POST https://cloudcx.app/api/v1/acd/queues/$QUEUE/members \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "user_id": "'$AGENT'" }' # Remove a member (404 if the membership does not exist). curl -X DELETE https://cloudcx.app/api/v1/acd/queues/$QUEUE/members \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "user_id": "'$AGENT'" }'
You can only manage membership on a queue your account may access, and you can only add an agent your account
may access. A platform admin can staff any agent on any queue; a tenant admin is confined to their own
tenant’s queues and agents. A queue/agent outside your scope reads back as 404 Not found.
Once the eligible set is built (members who are available and meet every required skill), the queue’s strategy decides which one of them actually gets the interaction. CloudCX ships four strategies. They differ only in how they pick within the eligible set — none of them changes who is eligible.
| Strategy | Picks | Tie-break | Best for |
|---|---|---|---|
longest_idle | The agent who has been available the longest (oldest presence timestamp). | — (the default) | Fair rotation; the standard, even-handed choice. |
round_robin | The next agent in a stable rotation, advanced by a per-queue cursor on every route. | Stable sort by agent id | Predictable, even cycling through a fixed roster. |
fewest_calls | The agent currently handling the fewest live interactions (least loaded). | Longest idle | Load balancing when handle times vary widely. |
priority | The agent with the highest skill proficiency (largest skill level). | Longest idle | Send the hardest work to your strongest agents first. |
Among the eligible agents, CloudCX picks the one whose presence has been available the longest. Each
time an agent changes state — including flipping back to available after wrap-up — their
“available since” timestamp is reset. So “longest idle” means “least recently active”,
which spreads work evenly without needing a separate counter. This is the fair, deterministic default and the same
idea CloudCX uses for basic chat assignment.
The eligible agents are placed in a stable order (sorted by their id) and a per-queue cursor advances by
one on every route, wrapping around the roster. The result is a predictable, even cycle: agent A, then B, then C,
then back to A. If the cursor store is briefly unavailable, the queue safely falls back to longest_idle
for that route, so a hiccup never stalls routing.
CloudCX counts each eligible agent’s live interactions (their currently open/assigned conversations) and picks the least loaded. Ties are broken by longest-idle. This balances concurrent load rather than raw turn order — useful where some interactions run long and you don’t want a busy agent handed yet another one while a free colleague sits idle.
CloudCX orders eligible agents by their highest skill level and picks the strongest first, breaking ties by
longest-idle. Pair this with a required-skill gate to build an expertise ladder: the gate decides who may
take the work, and the priority strategy decides that your most proficient eligible agent takes it
first.
Start with longest_idle — it is fair and needs nothing extra. Move to fewest_calls
when agents juggle several concurrent chats and load matters more than turn-taking. Use priority when
skill level should decide order (escalations, VIP lines). Reach for round_robin only when you
need a strictly predictable cycle through a fixed roster.
If a queue is missing, no member is available, or nobody meets the required skills, the ACD returns no agent rather than erroring — routing sits on the live request path and is built to never break it. The interaction is simply left unassigned for the caller to fall back on (hold, voicemail, overflow). A queue that “routes to nobody” almost always means nobody available or an over-tight skill gate, not a fault.
The queue stats endpoint gives you a one-shot snapshot of a queue’s health: its routing config, how
many agents are staffed, how many of those are available right now, and the skills it requires. It is
the fastest way to answer “why isn’t this queue routing?” — if available_now is
0, the queue has nobody to route to regardless of how it is configured.
priority · voice · prio 10 · SLA 45s
billing at level 2+ are eligible.
members in the stats payload).available_now).skill ≥ min_level; empty means the queue is open to all members.The stats payload is small and read-mostly — it is safe to poll for a live wallboard. Note that
available_now is a count of members who are available, computed against live presence; it never
fails on a cold cache (it just reports 0 availability rather than erroring).
# GET /api/v1/acd/queues/{id}/stats curl -s https://cloudcx.app/api/v1/acd/queues/$QUEUE/stats \ -H "Authorization: Bearer $TOKEN" # → snapshot of staffing, live availability and routing config: { "queue_id": "…", "name": "Sales — Billing", "strategy": "priority", "channel": "voice", "members": 3, "available_now": 1, "required_skills": [ { "skill_id": "…", "min_level": 2 } ] }
For day-to-day health, watch available_now against members. members high
but available_now at 0 = nobody signed in / everyone on break. Both healthy but callers
still wait = your skill gate may be excluding everyone who is available (see the next section’s worked
example).
Let’s build a skills-based voice queue end-to-end. The goal: Spanish-speaking billing calls should go
only to agents who speak Spanish and know billing, and the most experienced eligible agent should answer
first. We’ll require two skills and use the priority strategy so seniority decides order.
priority (highest skill level first)spanish ≥ 1 · billing ≥ 2Three agents are staffed on the queue. Their skills:
| Agent | spanish | billing | Presence | Eligible? |
|---|---|---|---|---|
| Priya R. | lvl 2 | lvl 3 | available | yes (billing 3) |
| Tom M. | — | lvl 4 | available | no — no spanish |
| Léo D. | lvl 3 | lvl 2 | available | yes (billing 2) |
Who gets the next Spanish billing call? Tom is out — he meets billing but holds no
spanish skill, so he fails the gate. Priya and Léo both pass (Spanish ≥ 1 and billing ≥ 2). With the
priority strategy, CloudCX ranks the eligible pair by their highest skill level: Priya’s
top level is 3 (billing), Léo’s top level is 3 (spanish) — a tie at 3, broken by longest-idle. So the
call goes to whichever of Priya or Léo has been available longer. Had we used longest_idle instead,
the level ranking would be ignored and the longest-available of the two would win directly.
/acd/queues with name Español — Billing, channel: "voice", strategy: "priority", max_wait_seconds: 45. Note the returned id as $QUEUE./acd/skills for spanish and again for billing; capture their ids as $SPA and $BILL.spanish=2, billing=3. Tom: billing=4 only. Léo: spanish=3, billing=2. Each is a POST to /acd/agents/{user_id}/skills./acd/queues/$QUEUE/skills twice: {skill_id:$SPA, min_level:1} and {skill_id:$BILL, min_level:2}./acd/queues/$QUEUE/members./acd/queues/$QUEUE/stats. Expect members: 3, two required skills, and available_now equal to however many of the three are currently available.# spanish at any level (≥1). curl -X POST https://cloudcx.app/api/v1/acd/queues/$QUEUE/skills \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "skill_id": "'$SPA'", "min_level": 1 }' # billing at level 2 or better. curl -X POST https://cloudcx.app/api/v1/acd/queues/$QUEUE/skills \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "skill_id": "'$BILL'", "min_level": 2 }'
A queue with two required skills admits only agents who satisfy both gates. Tom’s strong billing
level cannot compensate for a missing spanish skill. If you intended “Spanish or
billing”, you would model that as two separate queues, not one queue with two requirements.
In a non-production environment, build a small queue and prove the eligibility logic with live presence:
priority and a required skill tier2 ≥ 2.tier2 at levels 3, 2 and 1 respectively.members = 3 and the required skill reads tier2 ≥ 2.longest_idle (PATCH). Route again and confirm the pick now ignores level and follows availability order instead.available_now should drop by one, and routing should skip them.Success looks like: the under-level agent is never chosen, the strategy visibly changes who answers, and
available_now tracks presence in real time.
For every new queue, in order: (1) create the queue with the right channel and strategy, (2) create any skills it needs, (3) grant those skills to agents at sensible levels, (4) require the skills on the queue at their minimums, (5) staff the members, (6) verify with stats and a test interaction.
min_level; a queue with no required skills routes to all available members.min_level and the priority strategy also ranks by level.longest_idle (default, fair), round_robin (predictable cycle), fewest_calls (least loaded), priority (highest level first).members, available_now, strategy, channel and required skills — the first place to look when a queue isn’t routing.Queues decide who answers; Chapter 11 — Telephony & the SBC covers how the call gets to a
queue — inbound DIDs, SIP carrier trunks and least-cost routing. Live agent presence and the supervisor
wallboard that drives available_now are covered in Chapter 16 — Supervision & quality.
An outbound campaign is a list of contacts that CloudCX dials on your behalf and connects to agents. The platform ships three classic dialer modes — preview, progressive and predictive — a closed-loop abandonment-rate guard that keeps predictive dialing inside the legal cap, per-contact retry and disposition handling, optional calling-window schedules, and a tenant-scoped Do-Not-Call (DNC) list the dialer can never call past. This chapter shows you how to build a campaign, choose and tune its mode, load contacts, run it while watching the live wallboard, manage the DNC list, and walks a complete worked campaign end-to-end.
Everything in this chapter happens in the admin console at admin.cloudcx.app/campaigns, under the Voice group in the left navigation. The page has two tabs — — and a per-campaign detail view that opens when you click a campaign row. Creating campaigns, adding contacts and editing the DNC list are admin/supervisor actions; any signed-in agent can read live stats and (in preview mode) drive their own dials.
Every campaign carries one dial_mode. The mode decides who initiates the call and how
aggressively the dialer paces — from one-at-a-time, agent-driven previews to ratio over-dialing that places
more calls than there are free agents. Pick the mode to match the campaign’s sensitivity and the size of the
agent pool.
The agent sees the next contact before any call is placed and clicks to dial. No auto-dialing. Highest agent control; lowest throughput. Best for high-value or complex calls.
One live line per available agent. The system dials a number the moment an agent is free and bridges on answer. No abandoned calls by design; steady throughput.
Ratio over-dial: places lines per agent × free agents calls, anticipating no-answers. Highest throughput, governed by the abandonment-rate guard (§ 11.4).
All three share the same plumbing underneath. The dialer always checks the DNC list before placing a call, always honours the campaign’s calling window if one is set, always enforces max attempts and retry minutes, and always mirrors live counters to the supervisor wallboard. Only the pacing differs.
| Mode | Who dials | Lines paced per tick | Abandon risk | Use it for |
|---|---|---|---|---|
| Preview | The agent (screen-pop → click) | 0 (publishes the next contact only) | None | High-value, regulated or complex calls; small lists. |
| Progressive | The dialer, on agent-free | 1 per available agent | None (never more calls than agents) | Steady B2B / collections / renewals. |
| Predictive | The dialer, over-dialing | ceil(lines_per_agent × agents) | Managed — capped by the abandon guard | Large lists where throughput matters and a small abandon rate is acceptable. |
When in doubt, run a new list progressive first. It is self-pacing (one call per free agent) and can never abandon a customer. Once you know the list’s connect rate, switch to predictive and nudge lines per agent up to lift throughput — the abandon guard will hold you under the cap.
A campaign moves through four states. You drive the transitions from the detail view’s run controls; the dialer also auto-completes a campaign when there is genuinely no work left. Knowing the state machine explains exactly when the Start, Pause and Stop buttons are available.
409 Conflict).The dialer is resilient: if the platform restarts, every campaign still marked running has its runner re-spawned automatically on boot, and live counters self-heal from the database. You do not need to re-start a campaign after a deploy — only after you have explicitly paused or stopped it.
Open Outbound Campaigns from the Voice group. The Campaigns tab lists every campaign for the current tenant, newest first. Each row summarises one campaign — its mode, status, ACD queue and its attempt/retry policy — and clicking a row opens its detail view (§ 11.5). The + New campaign button reveals the inline create form (§ 11.3).
Create and operate outbound calling campaigns and watch the dialer live.
| Name | Mode | Status | Queue | Counts | |
|---|---|---|---|---|---|
| Q3Q3 Renewals | predictive | Running | Renewals AU | max 3 · retry 60m | open › |
| WBWinback Q2 | progressive | Paused | Sales | max 4 · retry 120m | open › |
| VRVIP Renewals | preview | Draft | — none — | max 3 · retry 60m | open › |
dial_mode (preview / progressive / predictive).If you sign in as a platform admin (no fixed tenant), a Tenant selector appears in the top bar. Pick a tenant before you create a campaign or add a DNC number — the selection scopes the list and pins the new record to that tenant. A tenant-scoped admin has no selector; everything is automatically pinned to their tenant.
Click + New campaign to expand the New campaign form inline at the top of the list. Only the Name is strictly required; every other field has a working default. An optional calling window block lets you restrict dialing to certain days and hours in a chosen timezone.
The ACD queue is technically optional, but it is what gives the dialer agents to bridge answered customers to. A campaign with no queue and no available agents will place no progressive/predictive calls (pacing is driven by the available-agent count), and any preview dial that connects will have nowhere to land. Always attach a queue for auto-dialed modes.
Every field the campaign form accepts, its constraint, and what it controls at runtime:
namedial_modepreview / progressive / predictive. Decides who dials and the pacing.queue_idcaller_idlines_per_agent1.0. The predictive over-dial ratio. 1.0 behaves like progressive.max_attempts3. A contact is retried until its attempts reach this ceiling.retry_minutes60. A no-answer/busy contact cools down this long before it is eligible again.scheduletimezone, days, start, end. Empty = always open.statusdraft; driven by the run controls thereafter.The optional schedule object restricts when the dialer may place calls. All keys are optional;
the dialer evaluates “now” in the schedule’s timezone and idles whenever the current moment falls
outside the window. A missing or malformed schedule fails open — the campaign dials at all times rather
than freezing silently.
| Key | Type / example | Meaning |
|---|---|---|
| timezone | IANA name, e.g. Australia/Sydney | The window is evaluated in this zone. Unknown zone → falls back to UTC. |
| days | ["mon","tue",…] or 0..6 | Allowed weekdays (0 = Mon). Empty/absent = every day. |
| start | "09:00" (24h) | Daily window open time. Needs end too. |
| end | "18:00" (24h) | Daily window close time. start > end means an overnight window (e.g. 20:00–06:00). |
"schedule": { "timezone": "Australia/Sydney", "days": ["mon", "tue", "wed", "thu", "fri"], "start": "09:00", "end": "18:00" }
Calling-hour rules are about the customer’s local time. Set the schedule timezone to the region you
are dialing — if you run a campaign from Singapore into Australian numbers, set
Australia/Sydney so “09:00–18:00” means the customer’s morning-to-evening, not yours.
In predictive mode the dialer deliberately places more calls than it has free agents, betting that some won’t answer. When that bet is wrong — a customer answers but no agent is free — the call is abandoned. Regulators cap the abandonment rate (commonly 3%), so CloudCX runs a real, closed-loop guard that throttles pacing back to one line per agent whenever the recent rate climbs to the ceiling, then lets it resume once it recovers.
Abandoned calls and live connects are counted in a sliding window so the rate reflects recent pacing, not the campaign’s whole history. The rate is simply abandons over answered calls in that window:
# Recent abandonment rate, evaluated every tick abandon_rate = abandons / (abandons + connects) # over the sliding window # The guard trips (pacing backs off to 1 line/agent) when BOTH hold: answered = abandons + connects if answered >= MIN_SAMPLE and abandon_rate >= MAX_RATE: budget = available_agents # progressive fallback this tick
0.03). Tunable platform-wide via DIALER_MAX_ABANDON_RATE.The live abandon_rate and abandoned count are reported by the stats endpoint, so you can
watch the guard work on the wallboard. For non-predictive campaigns these are always zero.
Many jurisdictions legally cap predictive-dialer abandonment (often at 3% of live answers, measured over a defined period). The guard helps you stay compliant, but it is your responsibility to set lines per agent sensibly and to confirm the ceiling matches your local rules. When in doubt, dial progressive — it cannot abandon a call.
True predictive dialers also try to route detected voicemail away from agents so a person only ever joins a person. Answering-machine detection is a capability of the underlying voice platform / carrier rather than something CloudCX can infer on its own, so the dialer does not fabricate an AMD result: there is a clearly-documented, idle-safe hook where machine-leg routing will gate once AMD is provisioned. Until then every answer is treated as a live human.
Lines per agent is the single biggest lever on predictive throughput. Higher ratios reach more customers
per agent-hour but push the abandon rate up; the guard then claws pacing back. A common starting point is
1.5–2.0 for a list with a moderate connect rate. Raise it gradually and watch Connect rate and
abandon rate together on the wallboard.
Clicking a campaign row opens its detail view: a header with the run controls, a live dialer stats panel, and the contacts tools (§ 11.6). While the campaign is running the stats panel polls every few seconds and the progress bar tracks connected / dialed. The status pill, the controls and the polling all stay in sync with the authoritative server state — if the dialer auto-completes the campaign, the page reflects it without a refresh.
predictive dialing · queue: Renewals AU · caller ID: +61 2 5550 0100 · lines/agent: 1.8 · max attempts: 3 · retry: 60m
Pending
Dialing
No answer
Busy
Failed
DNC
The wallboard is driven by the live-stats feed. Every counter, and what it means:
| Field | API key | Meaning |
|---|---|---|
| Pending | pending | Not yet dialed, or cooling down for a retry. |
| Dialing | dialing | A call is in flight right now (claimed, originating or ringing). |
| Connected | connected | Answered and bridged to an agent. Terminal success. |
| No answer | no_answer | Rang out / no user response (also where an abandoned predictive call lands for retry). |
| Busy | busy | Line busy or the call was rejected. |
| Failed | failed | The originate failed (bad number / gateway error) after exhausting attempts. |
| DNC | dnc | Number is on the Do-Not-Call list; never dialed. |
| Completed | completed | Worked to the attempt limit without connecting, or dispositioned done by an agent. |
| Dialed (total) | dialed_total | Everything that has left the pending pool — the connect-rate denominator. |
| Connect rate | connect_rate | connected ÷ dialed_total (0 when nothing dialed yet). |
| Abandoned | abandoned | Predictive only: answered-but-unstaffed calls in the live window. |
| Abandon rate | abandon_rate | Predictive only: abandoned ÷ answered over the window (feeds the guard). |
To save bandwidth the wallboard stops polling when you switch away from the browser tab and resumes (with an immediate refresh) when you return — provided the campaign is still running. The numbers you see on return are always the authoritative server counters, recomputed from the database, so they are correct even after a long gap.
Contacts are the leads a campaign dials. You load them in the detail view’s Add contacts box —
one per line, either name,number or a bare number — and they appear in the paginated
Contacts table with a live status, attempt count and disposition. Numbers already on the tenant’s DNC
list are flagged dnc the moment they are imported, so the dialer skips them automatically.
| Number | Name | Status | Attempts | Disposition |
|---|---|---|---|---|
| +61 411 000 001 | Jane Doe | Connected | 1 | Renewed |
| +61 411 000 002 | John Roe | No answer | 2 | — |
| +61 411 000 003 | — | Pending | 0 | — |
| +61 411 000 044 | Opted-out Pty | DNC | 0 | — |
| +61 411 000 077 | Late Co | Pending | 1 | Callback 14:30 |
name,number or a bare number. The last comma-field is the number; the rest is the name.The importer treats the last comma-separated field as the number and everything before it as the name, so
Doe, Jane, +61 411 000 001 imports cleanly as name “Doe, Jane” with that number.
Formatting of the number itself doesn’t matter — DNC and dialing both work on digits only.
Every dial outcome runs through the same retry policy. A contact that didn’t connect is retried
— left pending with a cooldown — until its attempts reach max attempts, at which point
it is finalised. A connected contact is terminal. Agents (in preview mode) can also record a disposition and,
optionally, schedule a callback.
pending; the contact becomes eligible again once retry minutes have elapsed.In preview mode an agent works one contact at a time: a screen-pop offers the next contact, the agent commits the dial, and after the call records a disposition (a short outcome code such as Renewed, Not interested or Wrong number). If the agent sets a callback time, the contact is re-queued so it surfaces again at that moment; otherwise it is marked completed. The disposition is shown in the contacts table and flows into reporting.
Within a campaign exactly one runner selects contacts, and a contact is atomically flipped from
pending to dialing before any call is placed. A preview commit uses the same
state-guard, so two agents (or an agent and the auto-dialer) can never grab the same contact — the second
attempt simply finds it is no longer dialable.
The Do-Not-Call list is the set of numbers the dialer must never call for a tenant. It is the
platform’s compliance backstop: the dialer checks it before every call, contact import flags matches on
arrival, and matching is done on digits only — so +61 411 000 044 and
0411000044 collide regardless of formatting. Open it from the Do-Not-Call tab.
| Number | Reason | Added | |
|---|---|---|---|
| +61 411 000 044 | customer opted out | 15 Jun 2026, 14:02 | Remove |
| 0400 123 999 | imported suppression list | 12 Jun 2026, 09:30 | Remove |
| +61 2 5550 7777 | — | 10 Jun 2026, 16:48 | Remove |
For an imported opt-out or suppression list, use Bulk add: paste one number per line. Numbers already on the tenant’s list (compared on digits) are skipped, and the result tells you how many were added versus skipped — so re-running the same import is safe and idempotent.
DNC is enforced at three points, all tenant-scoped and digits-matched, so a listed number cannot slip through:
When you add contacts, any number already on the DNC list is inserted with status dnc — it is never queued for dialing.
The runner re-checks the DNC list immediately before placing any call. A match is marked dnc and skipped.
An agent’s manual dial is DNC-checked at the moment of commit; a listed number is refused with a clear conflict.
If the DNC lookup itself cannot complete (for example a transient database error), the dialer treats the number as if it were listed and refuses to dial it. This is deliberate: it is always safer to skip a call than to risk dialing a number that may be suppressed. A skipped contact is simply retried on a later tick once the lookup recovers.
Each tenant has its own DNC list; one tenant’s suppressions never affect another’s campaigns. If you operate several tenants, maintain (or import) the appropriate suppression list into each one. Platform admins must pick the target tenant in the top bar before adding numbers.
Let’s run a real campaign end-to-end. The retention team has 1,000 Australian customers up for renewal. We’ll dial them predictive through the Renewals AU queue, only during Sydney business hours, retrying misses up to three times. Before we start we load the team’s opt-out list into DNC so nobody who has unsubscribed is ever called.
+61 2 5550 0100Australia/SydneyQ3 opt-outs, and click Bulk add. Confirm the added / skipped toast.Q3 Renewals, mode Predictive, queue Renewals AU, caller ID +61 2 5550 0100. Set Lines per agent 1.8, Max attempts 3, Retry minutes 60.Australia/Sydney, 09:00–18:00, days Mon–Fri. Click Create campaign.name,number) into Add contacts, click Add contacts. The toast reports e.g. “Added 1000 contacts (14 flagged DNC)” — those 14 opt-outs are already excluded.Every console action is also an API call. Here is the campaign created, contacts added and the run started, using an admin token. Status is read from the live-stats endpoint.
# 1) Create the campaign (returns the new campaign id). curl -X POST https://cloudcx.app/api/v1/campaigns \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "name": "Q3 Renewals", "dial_mode": "predictive", "queue_id": "<renewals-au-queue-id>", "caller_id": "+61255500100", "lines_per_agent": 1.8, "max_attempts": 3, "retry_minutes": 60, "schedule": { "timezone":"Australia/Sydney", "days":["mon","tue","wed","thu","fri"], "start":"09:00", "end":"18:00" } }' # 2) Bulk-add contacts (numbers already on DNC come back flagged in `dnc`). curl -X POST https://cloudcx.app/api/v1/campaigns/$CID/contacts \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "contacts": [ {"name":"Jane Doe", "number":"+61411000001"}, {"name":"John Roe", "number":"+61411000002"} ] }' # → { "added": 2, "dnc": 0 } # 3) Start the runner, then poll live stats. curl -X POST https://cloudcx.app/api/v1/campaigns/$CID/start \ -H "Authorization: Bearer $ADMIN_TOKEN" curl https://cloudcx.app/api/v1/campaigns/$CID/stats \ -H "Authorization: Bearer $ADMIN_TOKEN" # → { "status":"running", "connected":128, "dialed_total":540, # "connect_rate":0.2370, "abandon_rate":0.018, "remaining":460, ... }
The dialer normalises numbers to digits (keeping a leading +) and applies the tenant’s outbound
dial prefix if one is configured, so you can pass numbers in whatever format your CRM exports. Set a real, dialable
caller ID the customer will recognise — it materially affects answer rates and is often a regulatory
requirement.
In a non-production environment, run a small campaign end-to-end and prove each control:
Success looks like: DNC numbers never dialed, retries honouring the cooldown, the abandon guard throttling predictive pacing, and the lifecycle controls behaving exactly as in § 11.1.1.
For every campaign, in order: (1) load/refresh the tenant’s DNC list, (2) create the campaign with the right mode, queue and caller ID, (3) set a sensible pacing and retry policy, (4) add a calling window in the customer’s timezone, (5) import contacts and confirm the DNC-flag count, (6) start with agents available and watch connect & abandon rates together.
dial_mode — preview (agent dials), progressive (one line per agent), or predictive (ratio over-dial).name,number or bare numbers; misses are retried after retry minutes up to max attempts, then finalised; agents record dispositions and can schedule callbacks.Outbound campaigns staff answered calls into ACD queues, so the natural companions are the inbound voice chapters: queues and routing, agent presence and the live supervisor wallboard. Outbound calls also produce CDRs and dispositions that feed the reporting and analytics covered later in the manual.
Voice is the oldest channel in the contact centre and still the one customers reach for when something matters. In CloudCX a phone call is not a black box: it is a sequence of stages you configure and can reason about — the number the caller dialled (the DID), the IVR flow that greets and triages them, the ACD queue that holds them while it hunts for the right agent, and finally the agent whose softphone rings and bridges to the caller. This chapter walks that path end to end, then covers the two directions of traffic (inbound and outbound), call recording with AI transcription, and the screen-pop that gives the agent context the instant their phone rings.
Inbound numbers, queues and outbound trunks live in the admin console at admin.cloudcx.app under . Call flows have their own visual builder at . The agent-side experience — the ringing softphone, screen-pop and outbound dialer — lives in the agent desktop at cloudcx.app/agent; supervisors monitor live calls from cloudcx.app/supervisor.
Every inbound call follows the same skeleton. The carrier delivers the call over a SIP trunk to the CloudCX session-border controller (the CloudCX SBC edge + CloudCX Switch media engine); CloudCX Switch looks up the dialled number in your DIDs and hands the call to whatever that DID points at — a flow, a queue, an extension or a mailbox. Most production numbers point at a flow: an IVR graph that greets the caller, checks business hours, offers a menu, and routes the chosen branch into an ACD queue. The queue parks the caller on hold music and rings the best available agent; on answer the two legs are bridged and the conversation begins. When either side hangs up, CloudCX writes a CDR (call detail record) and — if recording was on — files the audio and queues it for transcription.
The same machinery runs outbound, only in reverse: the agent (or the predictive dialer) originates a leg to the customer through a carrier trunk chosen by least-cost routing, and on answer it is bridged to the agent's softphone. Inbound and outbound therefore share the same engine, the same recording pipeline and the same CDR table — the only real difference is who places the first leg.
| Object | What it is | Where you manage it |
|---|---|---|
| DID | An inbound phone number you own, with a routing destination. | Call Routing › Inbound DIDs |
| IVR flow | A node graph that greets and triages a caller; published before it goes live. | Voice › IVR / Call Flows |
| Queue | An ACD waiting line with a distribution strategy and required skills. | Call Routing › ACD queues |
| SIP trunk | A carrier gateway that carries calls to/from the PSTN. | Call Routing › SIP carrier trunks |
| LCR rule | Per-prefix rule choosing the cheapest/best trunk for outbound. | Call Routing › Least-cost routing |
| CDR | The immutable record of a finished call (timings, cause, recording path). | Reports / GET /calls/cdrs |
A DID can only route to something that already exists. Build (and publish) your IVR flow, or create your queue, before you point a number at it — the number picker only lists destinations that are live. The recommended order is: queue → flow → publish → DID.
A DID (Direct Inward Dialing number) is a phone number your tenant owns. Each DID carries one destination: the thing an inbound call to that number is handed to. There are exactly four destination types, and choosing the right one is the single most important inbound decision you make:
flowqueueextension1010). A personal direct line.voicemailThe Inbound DIDs panel lives inside Call Routing alongside the ACD queues and the SIP trunks, so you can see the whole inbound picture on one screen.
Numbers you own and where each one routes.
| Number | Label | Routes to | Type | Status |
|---|---|---|---|---|
| +65 3159 1234 | Main line — Sales | Sales & Support IVR | flow | Active |
| +65 3159 1240 | Support hotline | Support queue | queue | Active |
| +65 3159 1255 | Jane Tan — direct | ext. 1010 | extension | Active |
| +65 3159 1299 | After-hours | mailbox: main | voicemail | Inactive |
flow, queue, extension, voicemail) that decides how the call is handled.+6531591234) and a human label such as “Main line — Sales”.flow, queue, extension or voicemail. The field below changes to match.1010 or main).If a DID's destination is a flow that is not yet published, or a queue/extension that was deleted, the inbound engine answers and plays “Sorry, this menu is unavailable” (or “this number is not configured”) rather than dropping the call. Always confirm the destination is live before you point a real number at it, and re-check after deleting a queue or flow.
A tenant administrator sees and edits only their own tenant's DIDs. As a platform admin you see every DID and may assign a number to any tenant by selecting the tenant when you create it. Creating, editing and deleting numbers is an admin-gated action; reading the list is available to any authenticated user in scope.
An IVR flow (Interactive Voice Response) is a graph of nodes with a single start node. At
call time the inbound engine walks the graph from start, executing one node at a time and following
the edge each node returns. You build the graph visually in the IVR / Call Flows builder: drag a node from
the palette onto the canvas, wire its outputs to other nodes, edit each node in the inspector, then Save and
Publish. There are seven node types:
| Node | What it does | Outputs (edges) |
|---|---|---|
play | Play a prompt (TTS text or an audio file), then continue. | next |
menu | Play a prompt and gather DTMF digits; route on the pressed key. Barge-in friendly. | one per key in options, plus invalid and timeout |
hours | Branch on business hours, evaluated in a named timezone. | open, closed |
queue | Enqueue the caller into an ACD queue for up to timeout_sec (hold music + ring). | on_timeout (if no agent answers) |
dial | Bridge the caller to a single extension/number for timeout_sec. | on_no_answer |
voicemail | Play a greeting and record a message into a mailbox. | next (optional; else hang up) |
hangup | Terminate the call. | — (none) |
A prompt — what the caller actually hears — is one of three kinds: tts (synthesize
the supplied text to speech), say (identical to tts, an explicit verb), or
audio (play a pre-recorded file from a url). The builder validates that every TTS prompt
has text and every audio prompt has a URL.
play, menu, queue and so on. Click a node to open the inspector and set its prompt, options and routing.menu grows one output per key you add to options, plus the invalid and timeout fall-backs.hours or play) to mark it as Start.Give every menu an invalid and a timeout edge, and every queue an
on_timeout. Without them a caller who presses a wrong key, says nothing, or waits past the timeout simply
falls off the end of the graph and is hung up on. Pointing invalid back at the same menu (to
re-prompt) and timeout / on_timeout at a voicemail node is the safe default.
Understanding the runtime behaviour helps you design flows that behave well under pressure:
hours node routes to open — it is better to let a caller through than to wrongly tell them you are closed.queue bridges an agent or a dial reaches its extension, the call belongs to that conversation; the graph walk is finished.The hours node is worth a second look because it is the most common source of “why did my
after-hours routing not fire” tickets. Each range names a day (0 = Monday … 6 = Sunday), an
open time and a close time as HH:MM, all evaluated in the node's timezone. Ranges
may span midnight (e.g. 22:00–02:00). A caller is “open” if the current time
in that timezone falls inside any range; otherwise the call follows the closed edge.
Always set the hours node's timezone to the tenant's local zone (e.g.
Asia/Singapore). If you leave it unset the node evaluates in UTC, which will open and
close the line at the wrong wall-clock time for everyone but UTC callers.
A queue is an ACD waiting line for one channel. When a flow reaches a queue node (or a DID
routes straight to a queue), CloudCX answers, parks the caller on hold music, and asks the ACD to pick the
best available agent. It then originates a leg to that agent's softphone through the SBC edge and, the instant they
answer, bridges the two legs. If the agent doesn't answer within the timeout, the engine releases them and
rings the next eligible agent — up to several attempts — before giving up and following
on_timeout.
How the queue chooses who rings is its strategy, combined with the skills each agent must hold:
| Strategy | Picks… | Use when |
|---|---|---|
longest_idle | the available agent who has waited longest since their last call. | You want fair load-sharing (the default). |
round_robin | agents in rotation, in turn. | You want a predictable, even spread. |
fewest_calls | the agent who has handled the fewest calls. | You want to equalise call counts over a shift. |
priority | by the queue's priority ordering of members. | Some agents should always be tried first. |
Skills are the second filter. A queue can require one or more skills at a minimum level (e.g. “Spanish ≥ 3”), and each agent is granted skills at a proficiency level. The ACD only rings agents who hold every required skill at or above the queue's minimum, then orders the eligible pool by the strategy. The queue's stats view shows how many agents are members, how many are available right now, and which skills it requires.
The moment an agent is chosen the ACD flips their presence to a non-routable state, so a second simultaneous call can never be handed the same agent.
If the agent doesn't pick up in time they are returned to available and the next eligible agent is tried, up to the attempt limit.
The agent who answers becomes on-call and flips back to available through the normal presence API when they finish their wrap-up.
This chapter covers queues only as far as the voice path needs them. Creating queues, defining the skill
catalogue, assigning agents and tuning routing in depth is the subject of the dedicated ACD &
skills-based routing chapter. From the voice side, the key idea is simply: a queue node names a
queue, and the queue's strategy + required skills decide which agent's phone rings.
Outbound calls leave CloudCX through a SIP carrier trunk: a gateway to a PSTN carrier, registered (or
IP-authenticated) with the carrier's host. When a call is placed to a phone number, CloudCX runs least-cost
routing (LCR) to pick which trunk carries it: it matches the dialled number's longest destination prefix
against your LCR rules and selects the matching trunk; if no rule matches it falls back to the default outbound
gateway, so a tenant with no trunks provisioned still places calls. The number is normalised to digits (a leading
+ is preserved) and the dial-string byondswitch/gateway/<trunk>/<number> is built
for CloudCX Switch.
Trunks are platform-admin objects (you decide which carriers the platform routes through). A trunk's SIP password is write-only: you supply it on create/update, CloudCX stores it encrypted at rest, and no read endpoint ever returns it — the list shows only whether a password is set.
+65 → the SG trunk, '' as the catch-all), give it a priority and an optional per-minute cost. Rules are matched most-specific (longest prefix) first.Saving a trunk or generating its config never writes to or reloads the running CloudCX Switch/CloudCX SBC configuration. The generated text is for an operator to install on the SBC deliberately. Do not assume a newly saved trunk carries traffic until its gateway has actually been applied and is registered on the edge.
Every outbound originate passes a billing gate first. A prepaid tenant whose balance is exhausted, or a postpaid tenant past an explicit credit limit, is blocked with a clear “Call blocked” message (HTTP 402). A default postpaid tenant with no limit passes straight through. The gate fails open on any internal error, so it can never accidentally stop all calling.
Agents place campaign calls from a dedicated Campaign work-mode tab in the agent desktop, kept clearly separate from their inbound inbox. The agent picks a campaign, and the dialer behaves according to the campaign's dial mode: in preview mode the agent sees the next contact and presses Dial when ready; in progressive/predictive modes the server auto-dials and the agent simply works the connected contact. Either way, after the call the agent records a disposition — an outcome code, free-text and optional CRM notes — and may schedule a callback.
| MKMaria Kohattempt 1 | +65 9123 4567 | Skip Dial now |
If a contact is on the Do-Not-Call list (or otherwise not dialable), the dial is refused and the dialer simply advances to the next lead — the agent never has to check a list manually. Campaign creation, contact lists and dial-mode configuration are covered in the Outbound campaigns chapter.
CloudCX can record calls and, when speech-to-text is configured, transcribe them automatically for AI insights, QA scoring and search. Recording is governed at the platform level by a “Recording on by default” switch and a recording retention window (in days) under ; new tenants then record calls unless overridden.
When a call ends, the engine writes a CDR that includes the path of any recording it captured. That same event fires a best-effort transcription: a background job runs speech-to-text over the recording and stores the transcript for the call. The job is fully self-gating — if STT is not configured it is a clean no-op — and it never blocks or delays the call itself. Voicemail messages are recorded and transcribed the same way.
A CDR is the durable record auditors and analysts rely on. Each row carries the call's unique id, caller and destination numbers, direction, start / answer / end timestamps, the total duration and the billable seconds (billsec), the hangup cause, the codecs used and the recording path. CDRs are tenant-scoped: a tenant sees only their own calls; a platform admin sees all.
| Field | Meaning |
|---|---|
call_uuid | The unique id of the call leg (stable across the platform). |
direction | inbound or outbound. |
answer_stamp | When the call was answered (empty for an unanswered call). |
billsec | Billable seconds — the answered talk time used for rating. |
hangup_cause | Why the call ended (e.g. NORMAL_CLEARING, NO_ANSWER, USER_BUSY). |
recording_path | Where the audio was filed, if recording was on. Drives transcription & playback. |
Recording calls (and storing transcripts) carries legal obligations that vary by jurisdiction — consent
announcements, retention limits and data-subject rights. Set the retention window to your policy, make sure your
IVR plays any required “this call may be recorded” notice (a play node at the top of the
flow), and confirm with your compliance team before enabling recording for a new tenant.
When the ACD rings an agent, CloudCX pushes an incoming-call screen-pop to that agent's desktop at the same moment their softphone starts ringing. The pop shows the caller number, the matched contact (looked up by phone number in the CRM, if found) and the queue the call came from, with Accept and Decline buttons. Accepting answers the call on the softphone and brings up the voice workspace; declining hangs up the ringing leg. If the call is answered elsewhere or times out, the pop clears itself.
The softphone itself gives the agent full in-call control: Hold (parks the customer on hold music), Mute, Transfer (warm or blind, to another agent or extension), Conference, and a keypad for sending DTMF (useful when navigating an external IVR). These are standard call controls and behave as you would expect; the screen-pop is the CloudCX-specific addition that gives them context before they answer.
The screen-pop is a convenience layered over the call, not the call itself. If the pop ever fails to appear (for example during a transient connection blip) the softphone still rings and the agent can still answer — no call is ever lost because a pop was missed. Supervisors can also listen in on live calls from the supervisor console; live-call monitoring is covered in the supervision chapter.
Let us put the whole chapter together. Acme Pte Ltd has bought the number +65 3159 1234 and wants:
during business hours, a menu offering 1 for Sales and 2 for Support, each routed to its own queue;
outside hours (and when no agent answers within two minutes), a voicemail. Here is the end-to-end build.
Sales and Support, both with the longest_idle strategy and channel voice. Add the relevant agents as members, and (optionally) require a “Sales” / “Support” skill on each.hours start node (Asia/Singapore, Mon–Fri 09:00–18:00) → open to the greeting, closed to the voicemail;
play greeting (“Welcome to Acme. This call may be recorded.”) → the menu;
menu (“Press 1 for sales, 2 for support”) with 1→Sales queue, 2→Support queue, invalid→re-prompt the menu, timeout→voicemail;
queue nodes (sales, support, 120s) each with on_timeout→voicemail;
voicemail node (mailbox acme-main).+6531591234, label it “Main line — Sales”, set destination type flow and pick the published flow. Leave Active on.What happens at runtime, in order: the carrier delivers the call to the SBC; the DID resolves to the flow; the
engine answers and runs hours → (open) play → menu; the caller presses
1; the queue node parks them on hold music and asks the ACD for the longest-idle available
Sales agent; that agent's phone rings with a screen-pop; on Accept the legs bridge; on hang-up a CDR is
written and (recording on) the audio is filed and queued for transcription. If no agent answered within 120s, the
call would have followed on_timeout to the voicemail node instead.
Using a test tenant, extend the worked-example flow so that callers are never simply hung up on:
hours node's timezone is your tenant's local zone, then temporarily set the open window to a time that is not now, so the closed branch fires.queue nodes' on_timeout and the menu's timeout all point at the voicemail node.play node before the voicemail that says “Our office is closed; please leave a message”, wired into the voicemail node.0 that dials the reception extension, with on_no_answer falling back to the voicemail.| Symptom | Likely cause & fix |
|---|---|
| Caller hears “this menu is unavailable” | The DID points at a flow that was never published (or was deleted). Publish the flow or re-point the DID. |
| After-hours routing fires at the wrong time | The hours node's timezone is unset (defaults to UTC). Set it to the tenant's local zone. |
| Caller waits on hold then gets voicemail | No eligible agent was available within the queue timeout. Check queue membership, required skills and live presence in the queue stats. |
| Outbound call blocked with HTTP 402 | Prepaid balance exhausted or postpaid credit limit reached. Top up or raise the limit (Revenue → Billing). |
| Outbound call fails on a new trunk | The generated gateway config was never applied to the SBC, or the carrier rejected registration. Re-apply the config; verify the trunk registers. |
| No recording / transcript on a call | Recording is off for the tenant, or STT is not configured. Toggle recording; transcription is a no-op without STT. |
You now have the full voice path from number to agent and back. The neighbouring chapters go deeper on the pieces this one touched: ACD & skills-based routing for queue and skill design, Outbound campaigns for dialer modes and contact lists, Supervision & quality for live monitoring and QA scorecards, and AI insights for what the transcripts unlock.
Web chat is the first — and simplest — of the CloudCX digital channels. A single
<script> tag drops a branded chat bubble onto any website; when a visitor opens it, CloudCX starts
an omnichannel conversation, routes it to an available agent through the ACD, and streams the exchange live over a
WebSocket. This chapter shows you how the widget works end-to-end, how to customise and embed it, how
chats are routed to agents, and how the optional AI auto-responder can field the first messages. It
closes with a fully worked embed and a Try-it exercise.
You enable the channel platform-wide under
at
admin.cloudcx.app; you tune the AI deflection bot under
. The widget
asset itself (widget.js) is served from your static host/CDN, and the embed snippet lives on the
customer’s own website. Agents handle the resulting chats from the Agent Desktop inbox (Chapter 14).
Web chat is “omnichannel” by design: a chat is just an OmniThread whose channel is
webchat, and each turn is an OmniMessage with a direction of inbound (from
the visitor) or outbound (from the agent or the AI). The very same tables and routing power WhatsApp, SMS
and email later — only the transport differs. Three moving parts make a live chat:
widget.js injects a floating chat bubble (bottom-right) and all of its
own CSS — it has zero dependencies and touches none of the host page’s styles.POSTs
/api/v1/chat/threads with { channel: "webchat", tenant_id }. CloudCX creates the
thread and routes it (§13.5).id, the widget opens a WebSocket to
/api/v1/chat/ws/<thread_id> — wss:// on HTTPS sites,
ws:// on HTTP — and shows “Connected. How can we help?”inbound; the agent (or AI
bot) replies outbound. Every message is persisted and broadcast to all connected sockets on the thread,
so the visitor and the agent see each other in real time.Because chat threads are channel-agnostic, everything you learn here — routing through the ACD, claiming, the AI deflection bot, the inbox — carries straight over to WhatsApp, SMS and email. Web chat is the easiest place to learn the omnichannel model before you wire up provider credentials for the other channels.
Before the widget can do anything useful, Web chat must be switched on for the platform. Open . Each omnichannel surface has a master switch in the Platform channel availability panel, and a card describing what it does. Web chat needs no shared provider credentials (it has no external carrier), so flipping the switch is all that is required.
Surfaces the platform supports and which shared credentials are configured.
Switching Web chat on at the platform makes the channel and its API available; it does not place the bubble on anyone’s site. The widget appears only where the embed snippet (§13.4) is installed. Likewise, disabling the channel here is the platform-wide kill switch — do it only during a planned change window.
The visitor-facing widget is intentionally small: a circular bubble that, when clicked, opens a 340×460
pixel panel with a branded header, a scrolling message area, and a single-line composer. It carries the CloudCX
magenta-to-orange gradient out of the box and renders every message body with textContent, so visitor or
agent text is never interpreted as HTML — it is XSS-safe by construction.
z-index very high so it floats above page content). Click toggles the panel.title option (default “Chat with us”). The × closes the panel.inbound bubble, left-aligned, light background.outbound bubble, right-aligned, gradient, with the sender’s name above it.Internally the widget keeps a tiny state machine: it is idle until the visitor first opens the panel, at
which point it starts a thread and then connects the socket. Subsequent opens reuse the same thread and
socket. If the socket is not yet ready when the visitor hits send, the widget transparently falls back to a REST
POST so no message is lost.
The widget does not ask the visitor to log in. The thread id returned by
POST /chat/threads is the capability used to open the socket and post messages. This is
perfect for a public “contact us” chat. If you need to bind a chat to a known, authenticated customer,
pass identifying details (see visitor_name / visitor_contact in §13.6) and treat the
thread id as a short-lived secret on the page.
You install the widget by adding two lines of HTML just before </body> on the customer’s
site. The widget reads its configuration from a global window.CloudCX Chat object set before the
script, or from data-* attributes on the <script> tag — whichever you prefer.
When both are present, the data-* attributes win, which is handy for per-page overrides.
apiBasedata-api-base/api/v1 (the widget appends that itself). Defaults to the page’s own origin. The WebSocket
scheme is derived automatically: https:// → wss://, http:// →
ws://.tenantIddata-tenant-idnull)
for an anonymous, platform-level thread that only platform admins can see. Set it so the chat lands in the
correct tenant’s inbox — this is what makes the widget multi-tenant.titledata-title| Global key | Data-attribute | Default | Meaning |
|---|---|---|---|
| apiBase | data-api-base | page origin | API origin; /api/v1 appended automatically. |
| tenantId | data-tenant-id | null | Tenant UUID to route the thread to. |
| title | data-title | Chat with us | Header text of the chat panel. |
Almost every misrouted-chat support ticket comes down to a missing or wrong tenantId. Always set it
to the UUID of the tenant whose agents should answer. You will find that UUID under
— open the tenant and
copy its ID from the detail header.
widget.js. Publish the widget asset on a static host or CDN you control, e.g.
https://cdn.yourdomain.com/widget.js. CloudCX ships the file under web/widget/./api/v1.</body> on every page that should show the
bubble, filling in the three values.<!-- CloudCX Chat — paste just before </body> --> <script> window.CloudCX Chat = { apiBase: "https://api.yourdomain.com", // API origin — no /api/v1 tenantId: "8f3c1e0a-2b44-4c7d-9a10-7e2f0b9c5d31", // tenant UUID title: "Chat with Acme Support" // header text }; </script> <script src="https://cdn.yourdomain.com/widget.js"></script>
<script src="https://cdn.yourdomain.com/widget.js" data-api-base="https://api.yourdomain.com" data-tenant-id="8f3c1e0a-2b44-4c7d-9a10-7e2f0b9c5d31" data-title="Chat with Acme Support"></script>
The visitor’s browser calls your API cross-origin (the site and the API are on different hosts), so
the API’s cors_origins must allow the site’s origin. The prototype defaults to ["*"];
for production, restrict it to the real customer domains. Also serve both the site and the API over HTTPS —
a secure page cannot open an insecure ws:// socket, which would silently break the live stream.
When a chat thread is created, CloudCX tries to put it in front of a live agent automatically. It uses the same ACD machinery as voice (Chapter 10): the thread is routed through the default web-chat queue, applying that queue’s skills-based distribution strategy. The result is one of two outcomes:
If the ACD finds an eligible, available agent, the thread is assigned to them and its status becomes assigned. It appears in that agent’s inbox immediately.
If no agent is eligible or available, the thread stays open for any qualified agent in the tenant to claim from the inbox.
The selection itself happens in two tiers, and — critically — routing never raises: it sits on the visitor’s request path, so any hiccup (a cold cache, no agents online) degrades gracefully to “leave it open” rather than failing the chat.
webchat queue’s available members.Agents work chats from the omnichannel inbox on the Agent Desktop. The inbox polls for active threads (those
whose status is open or assigned) and filters by channel. Selecting a chat claims it
(assigning it to the agent), loads the message history, and opens the live socket so new visitor turns stream in. The
agent types a reply (sent outbound), and closes the conversation when it is resolved.
closed and removes it from the active inbox.outbound; it streams to the visitor’s widget.An agent only ever sees live threads for their own tenant. A thread created with no tenant_id
(anonymous) is visible only to platform admins — another reason to always set tenantId in the
embed so chats reach the right team.
Web chat has an optional, opt-in AI auto-responder. When you enable it for a tenant and name the
webchat channel, CloudCX leaves a new chat open (it skips the
human pre-assignment) so the bot can field the visitor’s first message. The bot replies as
“AI Assistant” and either resolves the question or hands off to a human — routing the
thread through the very same ACD queue and posting a warm holding message so the visitor is never left silent.
You configure it under . Every setting maps to one field on the tenant’s bot configuration:
enabled); off by default, so behaviour is unchanged until you turn it on.channels); webchat is selected here.persona): tone, scope and escalation rules prepended to the model.max_turns, default 5).The auto-responder can only reply when the platform’s shared AI credentials are configured. The screen shows an AI configured badge when they are; if it reads not configured, an enabled bot simply won’t answer — chats fall through to normal human routing until you add the key under .
The bot is engineered to be invisible when it should be: if it is disabled, the channel isn’t listed, the thread already belongs to a human, the turn budget is spent, AI isn’t configured, or anything goes wrong, it changes nothing and the chat is routed to a person exactly as in §13.5. An agent reply on a bot-handled thread naturally ends the bot’s involvement.
Acme Telecom (a tenant on the platform) wants a “Chat with Support” bubble on its marketing site acme.example. Its agents will answer from the Agent Desktop, and Acme wants the AI assistant to handle simple questions first, handing billing issues to a person. Here is the end-to-end build.
8f3c1e0a-2b44-4c7d-9a10-7e2f0b9c5d31.webchat queue has members and they are
set available (Chapter 10).webchat, set the persona, leave Max turns at 5, and confirm the
AI configured badge (§13.6). Save.</body> on every Acme
page. Reload and click the bubble.<script> window.CloudCX Chat = { apiBase: "https://api.cloudcx.app", tenantId: "8f3c1e0a-2b44-4c7d-9a10-7e2f0b9c5d31", // Acme Telecom title: "Chat with Acme Support" }; </script> <script src="https://cdn.cloudcx.app/widget.js"></script>
/chat/threads with Acme’s tenant_id; socket opens; “Connected.”webchat ⇒ thread left open; AI Assistant answers the first question.closed and leaves the active inbox.Goal: stand up a working web chat for a test tenant and watch a message flow end-to-end.
webchat queue and set yourself available.apiBase to your API origin and
tenantId to your test tenant, and load the page.Stretch: enable the AI Assistant for webchat, set Max turns to 1, and watch the bot
answer once, then hand off to you on the second message.
You will rarely call these directly — the widget and the Agent Desktop do — but knowing the endpoints makes troubleshooting precise. Visitor endpoints are anonymous; agent endpoints require authentication and are scoped to the agent’s tenant.
| Method & path | Who | Purpose |
|---|---|---|
| POST /chat/threads | Visitor | Start a thread; returns its id; routes via the ACD. |
| WS /chat/ws/<thread_id> | Visitor / Agent | Live bidirectional stream of messages on the thread. |
| POST /chat/threads/<id>/messages | Either | Persist + broadcast a message (REST fallback to the socket). |
| GET /chat/threads | Agent | Active (open + assigned) threads for the agent’s tenant. |
| GET /chat/threads/<id>/messages | Agent | Full message history for one thread. |
| POST /chat/threads/<id>/claim | Agent | Assign the thread to the calling agent (status → assigned). |
| POST /chat/threads/<id>/close | Agent | Mark the thread closed. |
| GET / PUT /bot/config | Admin / Reseller | Read or upsert the AI auto-responder configuration. |
channel (webchat), status
(open / assigned / closed), tenant_id, assigned_user_id, the optional
visitor_name / visitor_contact / subject, and last_message_at.direction (inbound from the visitor, outbound from the agent
or AI), sender_name, and the body. AI replies carry the sender name “AI
Assistant”.enabled, channels,
persona, max_turns and handoff_message.| Symptom | Likely cause | Fix |
|---|---|---|
| Bubble never appears | Snippet missing, blocked, or widget.js 404 | Confirm both <script> lines are before </body> and the asset URL loads. |
| “Couldn’t connect” | Wrong apiBase, or CORS blocks the origin | Set apiBase to the API origin (no /api/v1); allow the site origin in cors_origins. |
| Connects but no live updates | Mixed content: HTTPS page, ws:// socket | Serve the API over HTTPS so the widget uses wss://. |
| Chat never reaches an agent | No tenantId, or empty/idle queue | Set tenantId; staff the webchat queue and set agents available (§13.5). |
| Lands in the wrong inbox | Wrong tenantId in the embed | Copy the exact UUID from Tenancy › Tenants. |
| AI never replies | Bot off, channel not listed, or AI key missing | Enable the bot for webchat; confirm AI configured (§13.6). |
Because visitors are anonymous, the thread id is the only thing needed to read and post to a chat. Do
not log it, expose it in shareable URLs, or embed it server-side in cached pages. For higher-assurance deployments,
front the chat with a signed, short-lived visitor token rather than the raw thread id.
webchat channel; turns are OmniMessages tagged inbound or outbound.<script> lines; configure via window.CloudCX Chat or data-* — always set tenantId.outbound, then close.The chat machinery here is shared by every digital channel. Chapter 14 — WhatsApp, SMS, email and social reuses these exact threads and queues, adding the carrier credentials and webhooks. The agent experience — the inbox, claiming, replies and wrap-up — is covered in the Agent Desktop chapter, and live agent presence that drives routing is in Chapter 16 — Supervision & quality.
Voice and web chat are not the whole story. CloudCX is an omnichannel platform, and three more conversational surfaces — WhatsApp (the Meta Cloud API), SMS (Twilio), and email (any IMAP/SMTP mailbox) — all land in the same agent desktop, routed by the same ACD, threaded by the same OmniThread model as a chat. This chapter is the administrator’s guide to turning each of them on: where the credentials live (the encrypted vault under Platform Credentials), how the inbound webhooks and pollers work, how an agent’s reply goes back out, and a fully worked, step-by-step setup for all three. By the end you will be able to connect a brand-new tenant’s WhatsApp number, SMS line and shared mailbox from a cold start.
WhatsApp, SMS and email each have their own transport adapter (how a message is parsed inbound and pushed outbound), but everything above the transport is shared: an inbound message becomes an OmniThread + OmniMessage, is offered to the optional AI bot, then routed through the ACD’s default per-channel queue (with a presence-picker fallback) and broadcast live to the agent over the same WebSocket as web chat. An agent therefore handles a WhatsApp, an SMS and an email exactly the way they handle a chat — one inbox, one reply box.
Before the individual providers, it helps to hold the same mental model for all three. Every channel is made of four parts, and you configure them in this order:
whatsapp / sms / email).| Channel | Provider | Inbound | Outbound | Reseller BYO? |
|---|---|---|---|---|
Meta Cloud API (Graph v19.0) | Webhook POST /webhooks/whatsapp | Graph /{phone_id}/messages | Yes | |
| SMS | Twilio Programmable Messaging | Webhook POST /webhooks/sms | Twilio REST Messages.json | Yes |
| Any IMAP + SMTP mailbox | Background IMAP poll (30 s) | SMTP submission (587 / 465) | Platform only |
WhatsApp and SMS support a bring-your-own (BYO) model: a white-label reseller you have entitled can store its own Twilio / Meta keys, so its tenants send from the reseller’s own numbers and bills land on the reseller’s own provider account. When a reseller is not entitled (or hasn’t configured BYO) its tenants bind to CloudCX — they fall back to the shared platform credentials you set here. Email is platform-only — there is no per-reseller email BYO (the inbound poller is one shared mailbox). Reseller entitlement toggles are covered in the Resellers chapter; this chapter is the platform-credential side.
All shared connectivity keys live in one place: the view in the dark sidebar (it sits in the Platform group). Each provider is a card with the exact fields that provider needs, a Save credentials / Remove footer, and a status badge — Configured ✓ when a row exists, Not set when it does not. The card list is driven by the server: GET /api/v1/admin/platform/credentials returns, per provider, whether it is configured and the ordered fields it expects (names only — secret values are never sent back).
CloudCX’s shared connectivity credentials, used for tenants that don’t bring their own. Stored encrypted.
platform_credentials table. The master key (BYOND_CREDS_KEY) is the one secret that stays in the server environment, outside the database.{"disabled": true} rather than raising). The platform deploys and runs cleanly with zero credentials; you light each channel up at runtime.If the server has no encryption key set, the credential cards refuse to save (and the read endpoint reports the store as unavailable). The banner at the top of the page will say the master key is not configured. This is a server-deploy setting — set BYOND_CREDS_KEY in the API environment and refresh; never attempt to work around it by storing secrets elsewhere.
WhatsApp uses Meta’s WhatsApp Cloud API (the Graph API, pinned at v19.0). You configure four credential fields, point Meta’s webhook at your platform once, and from then on inbound messages flow into the ACD and agent replies push back out as Cloud API text messages.
tokenAuthorization: Bearer… on every outbound send. Secret.phone_id/{phone_id}/messages.verify_tokenapp_secret optional but required for inboundX-Hub-Signature-256); CloudCX recomputes and compares the HMAC. If this is unset, inbound webhooks are rejected (fail-closed). Set it to accept inbound messages.The verify token only governs the one-time GET handshake. The app secret governs every real inbound message: CloudCX verifies the X-Hub-Signature-256 HMAC over the raw request body and fails closed if the secret is unconfigured or the signature doesn’t match. A common “the handshake worked but no messages arrive” symptom is a saved verify token with a missing app secret. Set both.
Your platform exposes one public WhatsApp webhook URL. It serves two methods, both platform-level (one Meta app backs the shared webhook):
Meta calls GET /webhooks/whatsapp once when you subscribe, sending hub.mode, hub.verify_token, hub.challenge. CloudCX echoes the challenge back only when the mode is subscribe and the token equals your stored verify token; otherwise 403.
Meta delivers messages to POST /webhooks/whatsapp. CloudCX verifies the X-Hub-Signature-256 HMAC, then parses, threads, routes and broadcasts. It always returns 200 {"status":"ok"} after a valid signature, so Meta never retry-loops on a transient parse/DB hiccup.
The inbound parser walks Meta’s envelope (entry[].changes[].value.messages[]) and handles text messages, pulling the sender’s WhatsApp number, profile name, the message body and Meta’s message id (wamid). Non-text events (images, statuses, reactions) are ignored gracefully. A conversation is keyed by (channel='whatsapp', visitor_contact=<the WhatsApp number>): the first still-open/assigned thread for that number is continued, otherwise a new one opens.
When an agent replies on a WhatsApp thread, the desktop calls POST /api/v1/channels/whatsapp/threads/{thread_id}/reply. CloudCX POSTs the text to the Cloud API /{phone_id}/messages endpoint using the resolved credentials (the reseller’s own if entitled, else the shared platform creds), then persists and broadcasts the outbound turn. If creds are unset, the channel is disabled, or the tenant is out of credit, the network send is skipped but the message is still saved and shown — the transcript and agent UI never desynchronise.
messages field for the WhatsApp Business account so Meta starts delivering inbound messages to your POST webhook.A frequent setup slip is pasting the display number (+65…) into Phone number ID. The field wants the numeric ID Meta assigns the number (e.g. 109738561234567), shown next to the number in the WhatsApp → API setup panel. The display number is what customers message; the ID is what you send from.
SMS uses Twilio Programmable Messaging. It is the simplest of the three to wire: three credential fields, one webhook URL configured on the Twilio number, done. Outbound replies go via Twilio’s REST API; inbound messages arrive as a form-encoded webhook that CloudCX verifies and acknowledges with empty TwiML.
account_sidAC…). The HTTP Basic auth username on the REST send, and part of the send URL (/Accounts/{SID}/Messages.json).auth_tokenX-Twilio-Signature). Secret.from_number+15551234567). This is also the number your customers text.Twilio delivers an inbound SMS as an application/x-www-form-urlencoded POST to POST /webhooks/sms — not JSON. CloudCX reads the form (From, Body, MessageSid…), verifies Twilio’s X-Twilio-Signature against your stored auth token (fail-closed if the token is unset or the signature doesn’t match), then threads, routes and broadcasts exactly like the other channels. It always replies with empty TwiML — <Response></Response> — so Twilio treats the message as handled and never retries a poison callback.
Twilio signs the request over the exact URL it called. Behind a reverse proxy CloudCX reconstructs that public URL from the X-Forwarded-Proto / X-Forwarded-Host headers the proxy sets, so the HMAC matches. If you ever see signature mismatches, the usual cause is a proxy that isn’t forwarding those headers, or a Twilio webhook URL that doesn’t match your real public hostname.
An agent reply calls POST /api/v1/channels/sms/threads/{thread_id}/reply. CloudCX delivers it through the Twilio REST API (POST /Accounts/{SID}/Messages.json with the SID/token as Basic auth and a From/To/Body form), then saves and broadcasts the turn. As with WhatsApp, an unset-creds / disabled-channel / out-of-credit case skips only the network send — the message is still recorded so the transcript stays complete.
Email is the one channel with no SDK and no webhook: it works with any standards-compliant mailbox over plain IMAP (inbound) and SMTP (outbound). CloudCX polls the inbox in the background, turns each new message into a routed thread, and sends agent replies back out over SMTP as Re: <subject>.
imap_hostimap.example.com. This is the on/off switch: with no IMAP host the inbound poller stays idle.imap_port optional993 (IMAPS / implicit TLS).userpasssmtp_hostsmtp.example.com. With no SMTP host, agent replies are disabled (the API returns a clear “not configured” error).smtp_port optional587 (submission + STARTTLS). Use 465 for implicit-TLS SMTPS.from optionalInbound IMAP always uses implicit TLS (IMAPS) on the IMAP port. Outbound SMTP picks its mode from the port: port 465 → SMTPS (implicit TLS); any other port (the default 587) → plain connect then STARTTLS upgrade. Authentication happens when a username is configured. You don’t toggle TLS anywhere — choose the right port and CloudCX does the rest.
A background task polls the mailbox every 30 seconds. Each cycle it re-reads the email credentials from the vault, connects over IMAPS, fetches UNSEEN messages from INBOX, and immediately flags each one \Seen so a crash mid-cycle can’t reprocess it. Every fetched message is parsed (stdlib email — decoded subject, the plain-text body, the sender address and display name) and ingested as an inbound thread.
\SeenA conversation is keyed by sender: the first open/assigned email thread whose visitor_contact equals the sender address is continued, so an ongoing back-and-forth with one person stays a single thread until it is closed (mirroring how a mail client threads by sender). A brand-new sender opens a new thread seeded with their name, address and the email subject.
The poller re-reads the vault at the top of every cycle. So the moment you press Save credentials on the Email card, polling activates on the next cycle — no service restart. Remove the IMAP host and it returns to idle just as smoothly. You can stand up the email channel on a running production system with zero downtime.
An agent reply calls POST /api/v1/channels/email/threads/{thread_id}/reply. CloudCX sends it over SMTP to the thread’s sender with subject Re: <original subject>, then records and broadcasts the turn. Email’s reply path is stricter than WhatsApp/SMS: if SMTP isn’t configured or the channel is disabled the API returns 503 and does not persist the message (so the agent can retry once it’s set up); a hard SMTP send failure returns 502. There is no “silent no-op” for email replies.
[email protected]) and note its IMAP host, SMTP host, login username and password. If the provider needs an app-password (common with 2FA), generate one for CloudCX.993 / 587. Press Save credentials.Re:… email from your From address.The inbound poller fetches UNSEEN messages and flags them \Seen as it ingests them. Use a dedicated mailbox for the channel — don’t point it at a human’s personal inbox, or their unread mail will be consumed and marked read. One mailbox in, agent replies out.
Separate from credentials is availability. The view carries a master on/off switch for every channel — Voice, Web chat, WhatsApp, SMS, Email, Social. Every switch is on by default: a channel is enabled unless an admin deliberately turns it off. The flag is purely additive, so a fresh platform behaves as if everything is on, and a database hiccup can never accidentally disable a live channel.
When you disable a channel, CloudCX does the safe thing on both sides: inbound messages are acknowledged and dropped (so providers don’t retry-loop), and outbound sends are skipped — but for WhatsApp/SMS the message is still recorded and shown to the agent. Email is stricter: a reply while email is disabled returns 503 and isn’t saved. The same view also shows the per-channel credential status as a quick “creds set / creds not set” badge, read live from the vault.
The omnichannel surfaces the platform supports, and which shared provider credentials are configured for each.
/admin/platform/channels. Flipping a switch PUTs the whole map.For WhatsApp and SMS, a reseller can use its own provider account instead of CloudCX’s shared one. Two things must both be true: the reseller’s entitlement toggle is on (Allow own WhatsApp / Allow own SMS, set on the reseller drawer — see the Resellers chapter), and the reseller has stored its own credentials in its portal. When both hold, that reseller’s tenants send with the reseller’s keys; otherwise they bind to CloudCX and use the shared platform creds you configured here. The resolution is per-message and silent — agents never see which credential set was used.
allow_own_whatsapp + reseller credsallow_own_sms + reseller credsA new direct tenant, Acme Retail, wants WhatsApp, SMS and an email queue, all on CloudCX’s shared infrastructure (no reseller BYO). You have Acme’s Meta and Twilio details and a dedicated mailbox. Here is the end-to-end runbook.
109738561234567, a verify token acme-wa-7Q2x, and the App secret. Save → badge Configured ✓.acme-wa-7Q2x; verify, then subscribe to messages.+6531590100. Save.imap.yourco.com, Username [email protected], Password (app-password), SMTP host smtp.yourco.com, From [email protected]. Ports left blank. Save — the poller activates within 30 s.# GET /api/v1/admin/platform/credentials (secret VALUES are never returned) [ { "provider": "whatsapp", "configured": true, "fields": ["token", "phone_id", "verify_token", "app_secret"] }, { "provider": "sms", "configured": true, "fields": ["account_sid", "auth_token", "from_number"] }, { "provider": "email", "configured": true, "fields": ["imap_host", "user", "pass", "smtp_host", "imap_port", "smtp_port", "from"] } ]
In a non-production environment, prove you understand the credential / availability split and the fail-safe behaviour:
Success looks like: all three channels round-tripping a message, plus a clear, demonstrated grasp of the difference between disabled, no-creds, and the stricter email reply path.
X-Hub-Signature-256 HMAC (no app secret → no inbound).X-Twilio-Signature, acked with empty TwiML.Re: replies; the reply path is strict (503 / 502, not saved on failure).The agent experience of working these threads — the unified inbox, replying, transferring — is in the agent desktop chapter. The optional AI auto-responder that gets first crack at every inbound message is covered in the bot chapter. Per-reseller BYO entitlements and the reseller portal where resellers store their own keys are in the Resellers chapter.
When a customer messages your brand on Facebook Messenger, sends an Instagram DM, or opens a Direct Message on X (formerly Twitter), CloudCX treats that conversation exactly like a WhatsApp chat or an inbound web-chat: it lands in the omnichannel queue, the ACD routes it to an available agent, and the agent replies from the same workspace they use for every other channel. This chapter shows you how to connect all three social providers, store their credentials safely, verify the inbound webhooks each platform requires, and confirm that replies route back to the right person — with a fully worked Messenger connection and a hands-on Try-it exercise at the end.
The three providers collapse into two integration patterns. Facebook Messenger and Instagram DM both ride the Meta Graph API and share a single signed webhook surface — the same shape WhatsApp uses (Chapter 14). X uses its own Account Activity API with a CRC challenge and a different signature header. CloudCX hides that difference behind one uniform contract: every inbound message becomes an OmniThread + OmniMessage, and every agent reply is one POST /channels/<platform>/threads/{id}/reply call. Configure the credentials, point the webhook, and the rest of the platform behaves identically across channels.
Each social platform has two halves inside CloudCX, and it helps to keep them straight before you touch any settings:
Crucially, the conversation model is channel-agnostic. A Messenger thread and an Instagram thread are the same kind of object as a WhatsApp or web-chat thread — only the channel field and the transport differ. That is why social conversations show up in the same agent inbox, count toward the same handle-time analytics, and obey the same routing rules you configured in Chapter 10. The diagram below traces a single inbound message from the customer’s tap to the agent’s screen.
The reply travels the same path in reverse: the agent’s message is persisted, broadcast to any open sockets, and pushed to the provider so the customer sees it in their app. We cover routing in detail in § 15.7.
Messenger and Instagram are deliberately built as siblings of the WhatsApp adapter. If you have already connected WhatsApp, the Meta side of this chapter will feel familiar — the same App Secret, the same hub.challenge verification handshake, the same X-Hub-Signature-256 body signature. The differences are which IDs you store and which webhook fields you subscribe to.
Before configuring anything, know what each provider needs from you. CloudCX advertises the exact credential fields per provider; the table below is the authoritative set (it mirrors the server’s PROVIDER_FIELDS). Every field marked secret is write-only — once saved it is encrypted at rest and never shown again.
| Provider | Transport | Fields | Recipient ID |
|---|---|---|---|
| Facebook Messenger | Meta Graph Send API | page_id, page_access_token (secret), app_secret (secret), verify_token |
PSID (Page-scoped ID) |
| Instagram DM | Meta Graph Send API (linked Page) | ig_id, page_access_token (secret), app_secret (secret), verify_token |
IGSID (IG-scoped ID) |
| X (Twitter) DM | X API v2 · OAuth 1.0a | api_key, api_secret (secret), access_token (secret), access_secret (secret), bearer_token (secret) |
X user id |
Three points are worth internalising, because they explain almost every “why isn’t it working” question:
page_access_token you store for Instagram is the linked Page’s token, not an Instagram-specific secret. Instagram DM and Messenger share the Graph Send API shape; only the originating ID (ig_id vs page_id) differs.app_secret guards inbound webhooks. CloudCX verifies the X-Hub-Signature-256 HMAC on every inbound event against this secret and fails closed if it is unset — a Meta channel with no app_secret will reject all inbound messages. The verify_token is the string echoed back during the one-time webhook handshake.bearer_token is stored for the Account Activity subscription and app-only reads, and the api_secret doubles as the key that verifies the webhook CRC challenge and event signatures.CloudCX ships with no social credentials. Until you configure a provider, every outbound send for that channel is a silent no-op that returns {"ok": false, "disabled": true} rather than raising, and inbound webhooks for an unconfigured Meta channel are rejected because the App Secret check fails closed. This is by design: a half-configured tenant degrades gracefully instead of throwing errors into the agent UI. The flip side is that a missing field looks like “nothing happens” — so verify every required field is set.
Two admin screens own social configuration, and you will move between them:
Open the console at admin.cloudcx.app, sign in as a platform administrator, and select Channels from the dark sidebar. The screen below is what you land on.
The surfaces the platform supports and which shared credentials are configured for each.
A social platform is live only when both its per-platform flag and the umbrella social flag are enabled. Both default to ON, so a freshly configured channel works without touching these toggles. But either one set to off by an admin turns the channel off: the inbound webhook drops the message (logged as inbound_skipped_channel_disabled) and an agent reply is persisted but not sent. Use the umbrella Social switch as a single kill-switch during an incident.
Messenger is the canonical Meta channel; once you have done it, Instagram is nearly identical. You will gather four things from the Meta side, then store them in CloudCX and point Meta’s webhook at your callback URL.
page_idpage_access_token secretapp_secret secretX-Hub-Signature-256 HMAC on every inbound event against it. Required — webhooks fail closed without it.verify_tokenGo to . You will find a card for Facebook Messenger among the provider cards. Fill it in and save.
Meta Graph Send API. Stored encrypted; never shown again.
In the Meta App dashboard, under Messenger → Webhooks (or Webhooks for the Page product), configure the callback. CloudCX exposes a shared Meta endpoint and per-platform aliases — use whichever your setup prefers; they forward to the same handlers.
messages (Page messaging events)GET to your URL with hub.mode=subscribe, your hub.verify_token, and a hub.challenge nonce.verify_token (and, on the shared endpoint, the Instagram one too), and on a match replies with the raw hub.challenge value — which is exactly what Meta needs to mark the webhook Verified. A mismatch returns 403 and Meta shows the verification as failed.messages field. Without this subscription Meta verifies the URL but never actually delivers messages.social.meta_webhook_verified with channel=facebook.The shared /webhooks/meta endpoint accepts either platform’s verify token (it tries Facebook then Instagram), so a single callback URL can verify for both Messenger and Instagram. At delivery time CloudCX reads the event’s top-level object field — "instagram" routes to the Instagram channel, anything else ("page") to Facebook — so the right App Secret is used to check the signature. If you prefer one URL per product, use the /webhooks/facebook and /webhooks/instagram aliases instead.
Instagram messaging is authorised through the linked Facebook Page, so most of what you gathered for Messenger applies. The only differences from § 15.4 are the originating ID and the webhook field you subscribe to.
ig_id) — the professional account’s ID. The page access token, app secret and verify token are the linked Page’s values; in most setups they are identical to the ones you used for Messenger.ig_id, page_access_token, app_secret and verify_token. Save.messages field. Use the shared /webhooks/meta URL or the /webhooks/instagram alias.social.meta_webhook_verified with channel=instagram; inbound DMs then arrive on the Instagram channel.Meta Graph Send API via the linked Facebook Page.
ig_id) — the only field that differs from Messenger.X is the one provider that does not use the Meta pattern. DMs ride the X API v2, writes are authenticated with OAuth 1.0a user-context, and the inbound webhook uses the Account Activity API with a CRC (Challenge-Response Check) handshake. You need five values, all entered on a single card.
api_keyapi_secret secretaccess_token secretaccess_secret secretbearer_token secretGETs your URL with a crc_token. CloudCX replies with {"response_token": "sha256=" + base64(HMAC-SHA256(crc_token, api_secret))}. A correct response registers the webhook; an unset api_secret fails closed with 403.X API v2 DM endpoint, signed with OAuth 1.0a user context.
A common misconfiguration is storing only the bearer token and expecting replies to work. CloudCX builds the DM’s Authorization: OAuth … header (HMAC-SHA1, RFC 5849) from the four OAuth 1.0a values. If any of api_key, api_secret, access_token or access_secret is missing, the send is a no-op (disabled: true) — inbound may still verify (it only needs api_secret), so you can receive DMs but silently fail to reply. Always provide all five.
This is the heart of the chapter: what happens when a message actually arrives, and how a reply gets back. The behaviour matches WhatsApp exactly — a valid signature is required, but once verified everything is processed defensively and the provider always gets a 200 so it doesn’t retry-loop on a transient error.
Every inbound event runs the same gauntlet:
X-Hub-Signature-256 HMAC over the raw body, keyed by that channel’s app_secret; X events must carry a valid X-Twitter-Webhooks-Signature keyed by api_secret. A missing, malformed or mismatched signature — or an unconfigured secret — returns 403 and the message is dropped.social flag must both be enabled, or the message is skipped.(channel, sender id), else opens a new one. The sender’s scoped ID (PSID / IGSID / X user id) is stored as the thread’s visitor_contact — that is the address replies are sent to.social.thread_auto_assigned.200 is returned to the provider.CloudCX’s parsers are deliberately defensive. Messenger/Instagram echoes of your own outbound messages (is_echo), delivery/read receipts, reactions and non-text events are skipped. For X, an event whose sender_id equals the account the webhook is for (for_user_id) is treated as your own outbound and dropped. Anything unparseable is skipped rather than raising — so a malformed event never wedges the webhook.
When an agent sends a reply, the workspace calls one endpoint per platform. All three share the same implementation:
The reply path enforces, in order: tenant isolation (an agent may only reply on their own tenant’s thread), the channel kill-switch, and the prepaid / credit-limit gate. If the channel is disabled or the tenant is out of credit, the network send is skipped — but the outbound message is still persisted and broadcast so the transcript and the agent UI stay consistent. The send itself uses the resolved credentials; a missing-cred no-op is logged as reply_send_skipped and never surfaces an error to the agent.
Channel facebook · status assigned
inbound Hi — is the downtown store open on Sunday?
Yes! We’re open 10am–6pm Sunday. Anything else? outbound · you
channel=facebook.OmniMessage.Let’s connect a Page called Acme Retail and prove the round trip. We will store the credentials, verify the webhook, send ourselves a test message, and confirm the agent reply reaches Messenger.
102837465120938, a long-lived Page access token, the App Secret, and a verify token we invent: byondcx-msgr-7f3a91.byondcx-msgr-7f3a91, subscribe to messages. Click Verify and save; Meta shows Verified and the CloudCX log shows social.meta_webhook_verified · channel=facebook.social.inbound · channel=facebook · new_thread=true.RESPONSE-type message to your PSID.If inbound works but your reply doesn’t arrive, check in this order: (1) the Social umbrella switch and the Facebook switch are both ON; (2) the tenant is not out of prepaid credit (a blocked send logs reply_send_skipped_billing_blocked); (3) the page_access_token is valid and unexpired — an expired token returns an http_4xx in the social.send_http_error log; (4) the Messenger 24-hour standard messaging window has not closed for that conversation. CloudCX sends as a RESPONSE message, which is valid only within Meta’s allowed window after the customer’s last message.
Everything so far used CloudCX’s shared platform credentials, which serve every tenant whose reseller has not brought its own. A white-label reseller can instead supply its own social credentials — gated by the per-reseller Allow own social entitlement. When a reseller is both entitled and has stored credentials, its tenants’ social traffic uses those; otherwise it falls back to CloudCX’s shared creds, and finally to a disabled no-op if neither is set.
allow_own_social) — enabling it lets the reseller bring Facebook, Instagram and X creds.Even when a reseller brings its own send credentials, the inbound webhook handshake and signature verification read the platform verify_token / app_secret / api_secret. In practice this means a BYO reseller’s inbound social messages flow through the platform’s registered webhook and App, while replies are sent from the reseller’s own Page/account — plan the App and Page ownership accordingly.
Goal: connect a test Page, pass the webhook handshake, and observe an inbound message route to an agent — without spending real credit or risking production.
page_id, page_access_token, app_secret, and a verify token you choose. Save and confirm the badge reads Configured.messages, and verify. Confirm you see social.meta_webhook_verified in the logs.social.inbound · new_thread=true.social.meta_signature_mismatch — proving the fail-closed signature check. Restore the correct secret afterward.Done when: you can describe, from the logs, the exact path a Messenger message takes from Meta’s POST to the agent’s screen, and you have seen the signature check both pass and reject.
social flag are enabled. Inbound webhooks fail closed without a valid signature.Every customer conversation — a phone call, a web chat, a WhatsApp thread, an SMS, an email, a Messenger or Instagram DM, an X (Twitter) mention — lands in one place: the agent’s unified inbox on the Agent Desktop at cloudcx.app/agent. This chapter explains how that single pane is fed, how an interaction is routed to the right person, and how presence — whether an agent is Available, on Break, in Wrap-up or Offline — gates the whole loop. By the end you will be able to trace one chat message all the way from “visitor hits send” to “the right agent’s inbox lights up,” and explain every decision the platform made along the way.
How the channel-agnostic thread model lets six channels share one inbox; the four agent presence states and how to set them; the end-to-end routing loop (queue → skill check → live presence → strategy → auto-assign); the four queue distribution strategies and when each picks whom; the difference between auto-assignment and an agent claiming a thread; and a fully worked example you can reproduce in a test tenant.
This chapter sits on top of two you have already met. Chapter 14 — ACD & skills-based routing taught you to build queues, define skills and staff agents; Chapter 15 — Digital channels connected WhatsApp, SMS, email and the rest. Here we join those two: we show how a configured queue and a connected channel produce the live experience an agent actually works. You administer this loop, but you do not babysit it — once queues, skills and presence are right, routing runs on its own on the request path, every time a conversation starts.
The defining promise of an omnichannel contact centre is that an agent does not juggle a phone, a chat window, an email client and three social apps. They work a single, prioritised list of interactions, and the workspace reshapes itself around whichever one they open. CloudCX delivers this with three columns that never change position:
The trick that makes this possible is that every digital conversation is the same shape underneath. CloudCX does not have a “chats table,” a “WhatsApp table” and an “email table.” It has one channel-agnostic pair of tables, and a channel discriminator on each conversation. Web chat was the first channel built on it; email, WhatsApp, SMS and social reuse the exact same machinery and differ only in their transport adapter. Voice is the one exception — it rides the SIP softphone and arrives as a screen-pop rather than a thread row — but it shares the same presence, the same context panel and the same disposition.
A single OmniThread row represents one conversation on one channel; OmniMessage rows are the inbound (from the customer) and outbound (from the agent) turns within it. New channels add an adapter, not a new table — which is why the inbox can show them all in one list.
You will meet these fields in the inbox, in analytics and in any API you script against. They are worth learning by name because every channel uses them identically.
webchat · whatsapp · sms · email · facebook · instagram · twittervoice exists as a channel for queues but does not create a thread row — calls arrive via the softphone.open · assigned · closedopen + assigned.A thread flips to assigned the moment routing picks an owner — before the agent has typed a word. Treat assigned as “has an owner,” not “is being actively handled.” The agent’s first outbound message is what the customer experiences as “answered,” and your analytics measure that separately.
Here is the workspace an agent lives in all day. Read it left to right: the dark topbar carries identity, the agent-state selector and live counters; the three columns below it are the inbox, the active interaction and the customer context. The figure shows a WhatsApp conversation open in the centre stage.
The desktop polls the platform for the agent’s active threads roughly every 5 seconds and refreshes the headline counters every 30 seconds. While a conversation is open, its messages also stream over a live WebSocket, so new turns appear instantly rather than waiting for the next poll. You do not configure any of this — it is the desktop’s built-in behaviour — but it explains why a newly routed chat can take a second or two to surface in an idle agent’s list.
Routing only ever sends work to an agent who is Available. Presence is therefore the most important live signal in the whole loop — it is the agent’s own declaration of whether they can take the next interaction. CloudCX models exactly four states, set from the pill at the top-left of the desktop:
| State | Internal value | Routable? | What it means on the desktop |
|---|---|---|---|
| Available | available | Yes | Ready · routing on. The agent is eligible to receive new interactions for every queue they staff. |
| On Break | break | No | Paused · no new work. The agent keeps any interaction already open but receives nothing new. |
| Wrap-up (ACW) | acw | No | After-call work. The post-interaction window for notes and disposition; no new work arrives until they go Available again. |
| Offline | offline | No | Logged out of the queue. The agent is present in the app but excluded from all routing. |
Only available is routable; the other three all mean “don’t send me anything new.” That is the entire contract. Presence is ephemeral, live state — it is not stored in the database. It lives in a fast in-memory store (CloudCX Cache) as a single roster that every part of the platform reads consistently, so a routing decision made on any worker sees the same picture. Each entry carries the agent’s id, name, current state and a since timestamp that is rewritten on every state change — a detail that matters a great deal for fairness, as § 16.5 shows.
Going On Break, Wrap-up or Offline stops new work; it does not drop an interaction the agent already owns. A chat assigned to an agent who then goes Offline stays assigned to them until it is closed or reassigned by a supervisor. Coach agents to finish or hand off open work before logging out — otherwise a customer can be left waiting on an absent owner.
Now the heart of the chapter. When a digital conversation starts — a visitor sends the first chat, a WhatsApp arrives at your number — the platform runs a short, deterministic routing decision on the spot, before the thread is even returned. That decision joins three sources and either auto-assigns an owner or leaves the thread open for someone to claim. Here is the whole loop:
OmniThread is created (status open) for a channel — e.g. webchat. If an AI bot is enabled for this tenant+channel, the thread is left open for the bot and human routing is skipped.createstrategy and any required skills.queuemin_level. A queue with no required skills keeps all members.skillsavailable right now. Everyone on Break / ACW / Offline drops out here.presenceassigned with that assigned_user_id — it appears in their inbox. If nobody was eligible/available, the thread stays open for any agent to claim later.assignTwo design choices in this loop are worth calling out because they shape day-two behaviour:
open — the customer still gets their conversation, an agent claims it a moment later. A routing hiccup is never a dropped chat.If you have enabled an AI auto-responder for a tenant+channel (Chapter 17), step 1 short-circuits: the thread is left open so the bot can field the opening message, and human routing only runs if and when the bot hands off. Where no bot is configured, the loop above runs exactly as shown — this is the default.
An inbound call follows the same queue → skills → presence → strategy logic, but the result is delivered as a screen-pop on the chosen agent’s desktop and a ringing SIP softphone, not a thread row. The voice path is covered in Chapter 12; this chapter focuses on the digital channels that produce inbox threads.
Step 5 of the loop — choosing one agent from the eligible, available set — is governed by the queue’s strategy. You set it when you build the queue (Chapter 14); here is what each one actually does and when to reach for it. In every case the candidate pool is identical (members who hold the skills and are Available now); only the tie-break differs.
| Strategy | Picks… | Best for |
|---|---|---|
| Longest idle default | The agent who has been Available longest — the oldest since timestamp. Because since is rewritten every time an agent changes state (including flipping back to Available after wrap-up), “longest idle” naturally rotates work evenly. |
Almost everything. Fair by default, needs no extra state, and is the sensible starting point for any new queue. |
| Round robin | The next agent in a fixed rotation, advanced by a per-queue cursor over a stable ordering. Each new interaction steps to the next member, wrapping around. | Teams that want a strictly predictable “take turns” rotation rather than idle-time fairness. |
| Fewest calls | The least-loaded agent — the one currently handling the fewest live (open/assigned) interactions. Ties fall back to longest-idle. | Blended workloads where you want to even out concurrent conversations, not just turns. |
| Priority | The most proficient agent first — ordered by their highest skill level (descending). Ties fall back to longest-idle. | Queues where seniority should answer first — e.g. escalations or VIP lines staffed by mixed-tier agents. |
Longest-idle gives you fair, deterministic rotation for free — it reads the same since timestamp that presence already maintains, with no separate counter to drift out of sync when agents log in and out mid-shift. Round-robin’s cursor and fewest-calls’ live counts are more specialised; reach for them only when you have a concrete reason. If in doubt, leave a queue on longest-idle.
Suppose three Available agents staff a queue at the moment a chat arrives. Watch how each strategy picks differently from the identical pool:
since (5:00)In this snapshot three of the four strategies happen to agree on Ana; round-robin alone ignores the live signals and simply takes turns. Change one number — give Ben skill level 6 — and Priority now picks Ben while the others still pick Ana. This is exactly the kind of behaviour you should reason about when you choose a strategy for a real queue.
No strategy can conjure an agent. If every member is on Break, or none holds the queue’s required skill at the demanded level, the eligible-and-available pool is empty and the thread is simply left open. When chats pile up unrouted, the cause is almost always staffing or skills, not the strategy — check the queue’s live available-now count first (Chapter 14).
A thread reaches an agent by one of two routes, and it is important to know which is which because they behave differently in the inbox.
The routing loop chose an owner at creation. The thread is already assigned to that agent and lands directly in their inbox — they did nothing to receive it. This is the normal path whenever a suitable agent is Available.
No one was eligible/available at creation, so the thread is open and waiting. An agent picks it from the inbox and claims it — assigning it to themselves and flipping it to assigned. This is the pull model for overflow and for queues with intermittent staffing.
Both routes converge on the same end state — an assigned thread with an assigned_user_id — and from there the agent handles and then closes it identically. Selecting a row in the inbox triggers a claim automatically if the thread is still open, so in practice an agent simply clicks the conversation they want and the platform sorts out ownership.
open, this claims it to you (status → assigned); its history loads in the centre stage and a live socket opens for new turns.closed, it leaves the live inbox), sets you back to Available and pulls the next interaction.Save & next is the one-click loop that closes the current conversation, returns the agent to Available and immediately surfaces the next one. It is what keeps a high-volume queue flowing — teach it as the default closing action. Plain Save (which parks the agent in Wrap-up) is for when they genuinely need after-call time before taking more.
An agent can only see, claim, message or close a thread in their own tenant. Attempting to act on another tenant’s conversation is refused exactly as if the thread did not exist. Anonymous, not-yet-routed threads (tenant NULL) are visible and claimable only by platform admins. This is not a UI convenience — it is enforced server-side on every read and write, and you cannot configure it away.
Let us follow a single WhatsApp conversation through the entire loop, naming every decision. Setup: tenant Northwind Retail runs a Support webchat/WhatsApp queue on the longest-idle strategy, requiring the skill billing at min_level 2. Three agents staff it; here is the live picture the instant a customer sends “Is my refund processed yet?”
| Agent | Presence | billing skill | Available since | Eligible & available? |
|---|---|---|---|---|
| Maya | Available | level 3 | 09:02 (4m ago) | Yes |
| Devin | Available | level 1 | 09:05 (1m ago) | No — skill too low |
| Sofia | On Break | level 4 | — | No — not available |
Now the loop runs, step by step:
OmniThread is created, channel=whatsapp, status=open, visitor_name="Rosa Tan". No bot is enabled for this tenant+channel, so human routing proceeds.longest_idle, required skill billing ≥ 2.billing ≥ 2 survive: Maya (3) and Sofia (4). Devin (1) is filtered out for insufficient skill.since among survivors. With one survivor, the answer is Maya.assigned with assigned_user_id = Maya. Within a poll cycle (~5 s) the WhatsApp row appears at the top of Maya’s inbox with a Live badge; Rosa’s CRM record loads in the context column.Variation A — Maya goes on Break first. If Maya had stepped away a second earlier, the eligible-and-available pool would be empty (Devin lacks the skill, Sofia and Maya are on Break). The thread would stay open; the next agent to become Available with billing ≥ 2 could claim it, or it would sit in the inbox as a waiting interaction.
Variation B — the queue had no required skill. Drop the billing requirement and Devin re-enters the pool. With Maya idle 4 minutes and Devin 1 minute, longest-idle still picks Maya. Switch the queue to round-robin and the cursor decides instead — it might hand this one to Devin and the next to Maya, taking turns regardless of idle time.
Every routing outcome reduces to three levers you control: who is staffed (membership), what they can do (skills vs. the queue’s requirement), and who is Available (presence) — with the strategy breaking the final tie. When work routes “wrong,” walk those four in order; the answer is always in one of them.
When conversations are not reaching agents the way you expect, this table maps the symptom to the lever to check. Work it top to bottom — the common causes are first.
| Symptom | Most likely cause | Where to check / fix |
|---|---|---|
Chats arrive but no one is assigned (all stay open) | No eligible, Available agent at creation time | Queue’s live available-now count (Ch. 14). Confirm members are set to Available; confirm at least one meets every required skill. |
| One agent gets everything; others get nothing | Skills or membership gap — only that agent is eligible | Agent skills vs. the queue’s required skill & level; queue membership of the others. |
| Work is not rotating fairly | Strategy mismatch, or agents not returning to Available | The queue’s strategy (longest-idle is fairest); coach agents to use Save & next so they flip back to Available promptly. |
| An agent on Break still “has” a chat | It was assigned before they changed state | Expected — presence stops new work only. Reassign open work via Supervision (Ch. 18) if the agent is gone. |
| Agent sees nothing in their inbox | Wrong tenant, or simply no active threads | Confirm the agent’s tenant matches the conversations; remember the inbox shows only open+assigned. |
| A brand-new tenant routes oddly before queues exist | The fallback picker (longest-idle anywhere) is in play | Build the proper queue, skills and membership; the skills-based path takes over as soon as a queue is configured. |
In a test tenant with a webchat queue and two agent logins (call them A and B), prove to yourself how each lever moves the outcome:
open (unassigned) — nobody is eligible and Available.assigned to B the moment they open it.Debrief: You have now exercised all four levers — presence, membership, skills and the claim path — and seen the thread move through open → assigned → closed. This is the exact loop every live conversation runs through.
available, break, acw or offline — held in a fast in-memory roster. Only available is routable.open thread from the inbox, flipping it to assigned to themselves.You can now trace any conversation from arrival to assignment and explain every decision the platform made. The neighbouring chapters build on this loop: Chapter 17 — AI assist & automation shows the bot that can field the opening message (and the AI Assist panel beside every thread); Chapter 18 — Supervision & quality shows how a supervisor watches this inbox live, reassigns stuck work and scores it; and Chapter 19 — Analytics & reporting turns the handled, closed threads into the numbers on the topbar and beyond.
The Agent Desktop is the single screen where a contact-centre agent lives all day. From one workspace they set their availability, take voice calls, and handle web chat, WhatsApp, SMS, email and social conversations — with the customer’s history, an AI assistant and a wrap-up disposition always within reach. This chapter is the hands-on training track: it reproduces every panel of the live desktop, walks each task step-by-step, and finishes with a fully worked shift and a guided Try it exercise.
Agents sign in at cloudcx.app/agent with their CloudCX username and password — the same identity an administrator provisions in (Chapter 15). The desktop is a browser application; it needs microphone permission for voice and works best in a current Chrome or Edge tab kept open for the whole shift. Supervisors monitor the same conversations from the Supervisor console (Chapter 13).
After sign-in the desktop opens on a fixed three-column layout that never changes shape, so an agent always knows where to look. The top bar carries identity, the availability state and the live shift counters. Below it, the Inbox (left) lists every interaction waiting or in progress; the Stage (centre) is where the selected interaction is handled; and the Customer panel (right) shows who you are talking to, the AI assistant and the wrap-up form.
Pick a conversation from the inbox to handle it here.
Channel filter chips across the top; below them a scrolling list of interactions. Each row shows the channel icon, the contact, a one-line snippet, the wait/live state and an unread badge. A pulsing green dot marks a live conversation.
Switches automatically to match the selected channel: a softphone for voice, a chat thread for messaging, or an email reader for email. Only one interaction is on stage at a time.
Profile card, AI Assist, the CRM record, recent interaction history, private internal notes, and the disposition (wrap-up) panel.
Identity, the availability selector with its timer, SIP status, and the three live shift counters. Always visible.
Your availability state tells the routing engine whether to send you new work. It is the single most important control on the desktop: forget to go Available and the queue will skip you; forget to go On Break and a call will arrive while you are away from your desk. CloudCX records exactly four states, and the timer next to the pill shows how long you have held the current one.
| State | Dot | New work routed? | Use it when… |
|---|---|---|---|
| Available | green | Yes | You are at your desk and ready to take the next interaction. |
| On Break | amber | No | Lunch, comfort break, coaching — you have stepped away on purpose. |
| Wrap-up (ACW) | blue | No | Finishing notes and disposition after a call before taking the next one. |
| Offline | grey | No | End of shift, or signed in but not yet on the floor. |
00:00 and your presence is published to the routing engine and the Supervisor console.The state timer is exactly what your supervisor sees in their real-time wallboard. A long Wrap-up timer, or repeated short Break spells, are the patterns adherence reports flag. Keep an eye on it — not to game it, but because it is the honest record of your shift.
Closing the browser tab signs you out, but the router can take a moment to notice. A call may already be ringing your softphone when you close it, which records as a missed interaction against you. Always set On Break or Offline first, then close the tab.
Every conversation you can work appears in the Inbox, regardless of channel. Web chat, WhatsApp and SMS threads arrive automatically as customers reach out; voice calls ring through the softphone and pop a card (§17.4). The channel filter chips let you focus on one medium at a time, and the count in the column header tells you how many interactions match the current filter.
When you claim a thread it is assigned to you and removed from the unassigned pool, so two agents never type over each other. You can only ever see and claim conversations belonging to your own tenant — the desktop enforces that boundary for you.
When the ACD routes a call to you, your softphone rings and a prominent screen-pop slides in from the top-right with the caller’s number, the matched contact (if CloudCX recognises the number) and the queue the call came from. You answer or decline from the card; once connected, the Stage becomes a full softphone with hold, transfer, conference and a keypad.
Answering replaces the Stage with the softphone: the caller’s identity, a live call timer, a waveform while audio flows, and a grid of call controls. The same controls drive an outbound call you place from the keypad.
A conference then drop is a warm transfer — you introduce the customer to your colleague before you leave. A straight transfer is a cold hand-off. Warm transfers feel better to the customer for anything sensitive; cold transfers are faster for simple routing.
Chat, WhatsApp and SMS share one messaging Stage: a threaded conversation with inbound bubbles on the left, your outbound replies on the right, day dividers, and a composer at the bottom. Canned responses (quick replies) sit above the composer for one-tap answers, and the right-hand AI Assist panel reads the conversation to give you a sentiment read, a summary and a suggested reply you can drop straight into the composer.
AI Assist is powered by CloudCX’s integration with CloudCX AI. When you click Analyze the platform sends the conversation to the AI service and returns a sentiment score from −1.0 (negative) to +1.0 (positive), a short summary, up to five topic tags and a suggested next reply. It is an aid, not an author: always read and edit a suggested reply before sending it, and never let the sentiment label replace your own judgement. If AI Assist shows not configured, your administrator has not yet enabled the AI feature for the tenant (Chapter 12) — the rest of the desktop works exactly the same without it.
Email opens a reader on the Stage — subject, the thread of messages, and a reply box at the bottom — rather than a chat bubble view, because email is longer-form and asynchronous. Throughout, the right-hand Customer panel stays in place so the contact’s record, history and your private notes follow you across every channel.
The subject line, sender, recipients and date head the thread; each message is a card you can read in full. Compose your reply in the box at the bottom and click Send reply. Your signature is appended automatically.
Name, contact handle and a VIP badge for priority customers. The initials avatar carries the brand gradient.
Company, tier and account fields from the CRM, plus a Recent interactions list showing prior contacts and how each was resolved (resolved, callback, escalated, missed).
A private scratch-pad visible only to agents and supervisors — never to the customer. Use it for context the next agent will need.
Every interaction ends with a disposition: an outcome, optional tags, and wrap-up notes. This is how the contact centre knows what happened, feeds reporting, and triggers any follow-up. While you complete it your state is Wrap-up (ACW) so no new work interrupts you. The disposition panel sits at the foot of the Customer column and is styled as a dark console card so it stands out as the final step.
| Outcome | Means | Typical follow-up |
|---|---|---|
| Resolved | The customer’s issue was fully handled. | None. |
| Callback scheduled | You agreed to call the customer back. | A callback task is created. |
| Escalated to Tier-3 | Passed to a specialist team. | Tier-3 picks it up from the notes. |
| Pending customer | Waiting on the customer for information. | Re-opens when they reply. |
| Sale / upsell | A sale or upgrade was made. | Feeds revenue reporting. |
| No interest | Outbound contact declined the offer. | Recorded against the campaign. |
| Wrong number | The contact was not reachable / not the right party. | Number flagged on the lead. |
The outcome is mandatory — the desktop will refuse to save and prompt you to select one. A missing or sloppy disposition breaks reporting and leaves the next agent blind, so make the note specific (“Reversed duplicate charge on INV-2207”), not generic (“sorted”).
This is the full arc of one digital interaction, end to end, the way you will run it dozens of times a shift.
shipping; note “Order SO-4471 shipped, tracking shared, customer satisfied.”Goal: handle one interaction on each of three channels and record a clean disposition for every one.
Check: your Handled counter should read at least 3, and your supervisor’s wallboard should show every interaction with a recorded outcome and no missed entries.
| Symptom | Likely cause | What to do |
|---|---|---|
| SIP status shows SIP offline | Microphone permission denied, or the softphone failed to register. | Allow microphone access in the browser, then reload the tab. If it persists, tell your supervisor — you can still take digital work. |
| No calls arriving | You are not Available, or no agent has gone Available for the queue. | Check the state pill is green and the timer is running. |
| AI Assist says not configured | The AI feature is not enabled for your tenant. | Work without it; ask your administrator to enable AI insights (Ch. 12). |
| Inbox is empty but customers are waiting | The live connection dropped. | Reload the tab; claimed conversations re-appear automatically. |
| Can’t save a disposition | No outcome selected. | Choose an outcome from the dropdown, then save. |
Supervisors monitor these same interactions, listen in and coach from the Supervisor console — Chapter 13. Outbound campaign dialling (the Campaign work mode in the Inbox) is covered in Chapter 18, and the AI features behind AI Assist are configured in Chapter 12.
The supervisor wallboard is the real-time pane of glass over a contact centre. It answers the three questions a floor supervisor asks every minute — What is happening right now? Who is free, who is busy, who is struggling? And can I step in to help this call? This chapter walks the live wallboard end to end: the KPI strip, the agents grid, live interactions, service levels, live voice queues and the outbound campaign monitor, then the live-coaching trio — monitor, whisper and barge — that let a supervisor join a call silently, coach the agent privately, or take part in three-way.
The wallboard is served at supervisor.cloudcx.app (the /supervisor app), separate from the admin console at admin.cloudcx.app. It is a dedicated, distraction-free surface meant to run full-screen on a team-leader’s monitor or a wall display. It reads from the same control plane through the shared API under /api/v1, and every figure on it is live — nothing on the wallboard is sample or demo data. Where a metric cannot be derived from real telemetry, the wallboard shows n/a rather than inventing a number.
Access to the wallboard requires a sign-in with a supervisor or platform-administrator account. The control actions (monitor / whisper / barge) are admin-gated on the server — an ordinary agent token cannot originate an eavesdrop even if it reaches the page. Sign-in posts to /api/v1/auth/token; the resulting bearer token is held in the browser and attached to every poll. A 401 at any point clears the token and returns the user to the sign-in gate.
Tenant isolation. What a user sees on the wallboard is scoped to their place in the hierarchy, exactly as everywhere else in CloudCX:
The wallboard polls every 3 seconds and automatically pauses when its browser tab is hidden (and resumes with an immediate refresh when it becomes visible again). It is designed to stay open all shift on a dedicated display; you do not need to refresh it by hand.
The wallboard is one continuously-scrolling page. From the top: a sticky topbar with the live clock and connection state; a six-tile KPI strip; the main grid pairing the Agents panel with a right-hand stack of Live interactions and Service levels; then a Voice contact-centre row (live queues + campaign monitor); and finally the Quality tooling (scorecards and evaluations, covered in Chapter 19). Figure 18.1 reproduces the top of the wallboard with the key regions marked.
all queues, scoped to your tenant).n/a.The six tiles across the top give the at-a-glance health of the operation. They refresh on every 3-second poll of /supervisor/overview (the answer-rate figures come from /analytics/summary and refresh every 30 seconds). Each tile names its data source in its small caption so you always know what the number means.
| Tile | What it counts | Source |
|---|---|---|
| Live calls | Active voice channels right now | CloudCX Cache byond:live_channels (maintained by the telephony control socket consumer) |
| Agents online | Signed-in agents not in the offline state, over total staffed | agent:presence |
| On call | Agents currently in the on call state | agent:presence |
| Open chats | Omnichannel threads in open or assigned status | DB — open_chats |
| Calls today | CDR rows created since 00:00 UTC | DB — cdr_today |
| Answer rate | Answered ÷ total calls today, with the count beneath | /analytics/summary (voice) |
The Calls today and Answer rate tiles count from 00:00 UTC, not your local midnight. On a wallboard read in another time zone the day boundary will appear to roll over off-hours; this is expected and keeps every tenant’s “today” on one consistent clock. Use Chapter 20 (Analytics) for time-zone-aware reporting.
The Agents panel renders one card per agent present in agent:presence. Each card shows the agent’s initials avatar, name, a short identity tag, a state pill, a one-line “now” summary of what they are doing, and the three live-coaching buttons. The card’s state pill is colour-coded so the floor is readable at a glance from across the room.
| State | Pill | Meaning & “now” line |
|---|---|---|
| Available | available | Signed in and ready — “ready · waiting”. Counts toward Agents available on queues. |
| On call | on call | In a live conversation — the caller’s number is shown. The only state where coaching is enabled. |
| ACW | acw | After-call work (wrap-up / disposition). Not taking new contacts yet. |
| Break | break | On a break / away / paused. “on break”. |
| Offline | offline | Signed in but not ready (or just signed out). Not counted as online. |
The presence layer reports a free-form state string and the wallboard normalises common synonyms onto these five buckets — for example ready/online map to available; busy/talking map to on call; wrapup/after_call map to ACW; away/pause/lunch/dnd map to break.
A floor where most cards are available and a few are on call is healthy. A wall of break or offline during peak hours is your first signal that staffing or adherence needs attention — before the queue starts backing up.
To the right of the agents grid sits a two-panel stack. The dark Live interactions panel lists every active call leg from the same overview snapshot: a channel chip (VOICE today), the caller (caller-ID number, falling back to caller-ID name), the direction, a short channel-UUID, and a duration that ticks up live from the channel’s start timestamp. Bridged/answered legs carry a green ● live tag.
Below it, Service levels shows three calculated figures for today: average handle time, answer rate (answered ÷ calls) and abandon rate (unanswered ÷ calls). The abandon cell is tinted green at ≤ 5 %, amber at ≤ 10 % and red above that.
The wallboard does not fabricate metrics it cannot measure. CloudCX does not yet expose per-call queue-wait telemetry, so the Avg wait service-level cell honestly shows n/a. Do not read a blank or n/a wait figure as “zero wait” — it means the data source is not available. The same honesty principle applies across the wallboard: a metric is either live or clearly flagged unavailable.
The Voice queues panel lists each ACD voice queue your tenant owns, with its routing strategy badge and three live numbers. The queue list is read from /acd/queues (filtered to voice/any channels) and each queue’s figures from /acd/queues/{id}/stats; the list is re-read about every 30 seconds so a newly-created queue appears on its own, while the per-queue stats refresh every poll.
longest_idle, round_robin or fewest_calls).The live-channel snapshot is not tagged by queue, so a precise per-queue waiting count is not available. The wallboard therefore shows a platform-wide, best-effort indicator — the count of inbound legs in a ringing/early state that are not yet answered — on each voice queue. Treat it as a directional “is anyone waiting right now?” signal, and use Chapter 20 analytics for exact queue-level wait statistics.
Beside the queues, the Campaign monitor surfaces active outbound dialer work. It lists running and paused campaigns (falling back to all campaigns if none are active), each showing its dial mode — preview, progressive or predictive — a status pill, and four live figures from /campaigns/{id}/stats: Connected, Dialed, Connect rate and Remaining, with a progress bar of records worked against the campaign total. Campaign creation and dialer configuration are covered in the outbound campaigns chapter; here the focus is purely live observation.
The three live-coaching actions are the heart of supervision. Each one rings the supervisor’s own endpoint and bridges that answered leg into the target call — under the hood, a CloudCX Switch eavesdrop against the live channel. They differ only in who can hear the supervisor.
Listen only. A silent spy: the supervisor hears both the agent and the customer, but neither party hears the supervisor. Used for quiet quality checks and live coaching prep.
Coach the agent. The supervisor’s audio is injected into the agent’s ear only — the customer cannot hear it. Used to guide a live answer without the customer knowing.
Three-way. The supervisor becomes audible to both parties and fully joins the conversation. Used to rescue or take over a difficult call.
The flow is the same in all three cases: pick a live call → CloudCX rings the supervisor leg → on answer it is bridged into the call in the chosen mode.
On each agent card, the three buttons are only enabled when that agent is on call and the wallboard has matched a live channel UUID to them. For an available, ACW, break or offline agent the buttons are greyed out — there is no live call to join. If you click an action when there is no live channel, the wallboard tells you so rather than failing silently.
requireddefault loopback/echouser/1001. Defaults to a self-contained loopback/echo so the control is testable without a registered supervisor phone. Validated against the allowed-endpoint pattern.Monitor, whisper and barge are restricted to supervisor/administrator tokens on the server — an agent account cannot originate them. Every action is logged with its target channel. Because barge makes you audible to the customer, treat it as a deliberate intervention, not a casual click: announce yourself when you join.
eavesdrop_enable_dtmf is armed (whisper/barge pre-arm it), press 1, 2 or 3 on your phone keypad to switch between listen, whisper-style and three-way at runtime — without redialling.Because whisper and barge pre-arm DTMF control on the spying leg, you can start on Monitor, listen for a minute, then press 2 to whisper a hint to the agent and 3 to fully barge in if the call is going sideways — all on the one connection. Press 1 to drop back to silent listening. This is the smoothest path from observing to intervening.
It is 14:30 on a busy afternoon. You are the floor supervisor for the Sales — Inbound tenant. A new hire, Priya T., has been live for two days. You want to keep an eye on her first few complex calls and step in only if she gets stuck.
+44 7700 900 821, and a duration ticking past 4:12 in Live interactions — longer than her usual handle time.You moved from silent monitor → private whisper → brief barge — the least-intrusive intervention at every step — and saved a sale without undermining the agent in front of the customer. That escalation ladder is the supervisor’s craft, and the wallboard is built around it.
On a training tenant with at least one agent signed in and a test call in progress (use the default loopback/echo supervisor endpoint if you have no SIP phone registered):
n/a.Check yourself: if you can defend every number on the board (or correctly say “that one is unavailable”), and you escalated monitor → whisper → barge in order, you are ready to supervise a live floor.
| Symptom | Likely cause / fix |
|---|---|
| Topbar shows RECONNECTING | The overview poll failed. The wallboard retries automatically every 3 s; check API reachability and your session if it persists. |
| “no agents signed in” | The agent:presence hash is empty — no agents are signed in (or presence isn’t being projected). Have an agent sign in. |
| “no live channels” | byond:live_channels is empty — there are genuinely no active calls right now. |
| Coaching buttons greyed out | The agent is not on call, or the wallboard hasn’t matched a live channel UUID to them. Only on-call agents can be monitored. |
| Action toast: “… failed” | A 422 means a bad channel UUID or supervisor dial string; a 502 means the eavesdrop couldn’t be originated — check the supervisor endpoint is reachable. |
Service levels read — everywhere | Analytics is unavailable; the panel degrades to dashes rather than guessing. Live calls/agents still update independently. |
| Sent back to the sign-in gate | Your token expired (401). Sign in again; polling resumes automatically. |
Quality Management lets you turn a vague sense of “good service” into a repeatable, weighted score. You build a scorecard — a reusable rubric of criteria — then evaluate individual interactions against it, either by hand or with CloudCX AI doing a first pass. This chapter walks through building a scorecard, scoring a conversation, letting the AI auto-score, and a fully worked QA review you can repeat on the training track.
Quality tooling is part of the Supervisor workspace, not the admin console — it is the work of team leads and QA analysts who can also see live calls and monitor agents. Sign in at supervisor.cloudcx.app; the Quality scorecards, Evaluate interaction and Past evaluations panels sit at the bottom of the wallboard, beneath the live agents, service levels and voice queues.
Three things work together. A scorecard is the rubric. An evaluation is one scoring of one interaction against a scorecard. The interaction being scored is normally an omnichannel thread (a chat, WhatsApp, SMS or email conversation); a voice call can instead be referenced by its call UUID. The relationship is one rubric → many evaluations.
Two properties make the model durable. First, every evaluation stores its own weighted total_score and max_score at the moment you save it — so editing or retiring a scorecard later never silently rewrites history. Second, scorecards and evaluations are tenant-scoped: an analyst only ever sees the rubrics and scores belonging to their own tenant (platform administrators can additionally publish shared, tenant-less scorecards).
Anyone signed into Supervisor can read scorecards and score interactions. Creating, editing or deleting a scorecard is an admin-gated write. If the + New scorecard form rejects your save with a permission error, ask a platform or tenant admin to publish the rubric — analysts then evaluate against it freely.
A scorecard has a name, an optional description, an active flag, and a list of criteria. Each criterion is the unit you score against, and carries four fields:
keygreeting. Lower-case, 1–64 chars. If you leave it blank the UI auto-derives it from the label (“Warm greeting” → warm_greeting). The key is what the AI scorer and the saved scores map are keyed by, so keep it short and unique within the card.labelmax_points[0, max_points] server-side, so an over-generous entry can never inflate the score.weight2 makes the criterion count double; a weight of 0 keeps it on the card for guidance but excludes it from the total.The weighted maths is the heart of it. For each criterion, the points you award are clamped to its maximum and multiplied by its weight; that product is added to the total, while max_points × weight is added to the maximum. The percentage you see everywhere in the UI is simply total ÷ max × 100, rounded.
# for every criterion on the scorecard points = clamp(awarded, 0, max_points) # can't go below 0 or above the max total += points * weight # your earned, weighted points max += max_points * weight # the full weighted denominator percentage = round(total / max * 100) # the headline % score
The denominator is always the whole rubric. Every criterion contributes its weighted maximum to max_score regardless of whether you scored it — so a criterion you leave at 0 (or simply forget) lowers the percentage. If a criterion genuinely does not apply to a channel, build a separate scorecard for that channel rather than zeroing it.
Open the Quality scorecards panel on the left. Existing rubrics appear as cards, each showing its name, an active / retired badge, a criteria count, and chips previewing the first few criteria with their maximums. Below the list is the collapsible + New scorecard form.
// 3 scorecards).Expanding + New scorecard reveals a name field, a description field, and the criterion builder — a small grid with one row per criterion under the headers Key · Label · Max · Weight. A fresh form seeds one empty row; the + Criterion button adds more, and the × at the end of each row removes it.
Name such as Voice — Sales QA. Add an optional Description to remind reviewers what the card is for.Label (e.g. Warm greeting). Leave Key blank to auto-slug it, or type your own short key.5) and the weight (default 1). Both accept half-point steps.Keep cards to 4–8 criteria so a review takes minutes, not an afternoon. Use weight to express what your business actually cares about — weight compliance and resolution heavily, weight pleasantries lightly. Reuse the same key names (greeting, resolution, compliance) across cards so reports line up channel-to-channel.
2 and 3 here make discovery and compliance count more.Scorecards are partially updatable: an admin can change the name, description, criteria or active flag without touching the rest. Rather than deleting a rubric you have outgrown, switch its active flag off — it becomes a retired card that still renders for historical evaluations but signals it should no longer be used for new scoring. Deleting a scorecard outright is allowed, and is safe: existing evaluations keep their numbers because the link is severed rather than cascaded.
Because every evaluation snapshots its own total and maximum, changing a live scorecard’s weights does not re-score past evaluations — old rows keep the numbers they were saved with. That is intentional, but it means two evaluations on the “same” card can use different weightings. When you materially change a rubric, prefer retiring it and creating a new version so reports stay comparable within a version.
The Evaluate interaction panel is where scoring happens. It has two inputs at the top — a picker and a manual field for choosing which interaction — and, once a scorecard is selected, a row of scoring inputs with a live running total.
Refund request · whatsapp · 7f3a9c20. Closed threads are included because QA usually scores completed conversations.Selecting a scorecard on the left and an interaction on the right is all it takes to reveal the scoring inputs. Until both are chosen the panel shows the hint // select a scorecard above and an interaction to begin scoring.
The percentage that turns green / amber / red as you type is computed in the browser from the same clamp-and-weight rule. The authoritative numbers are recomputed on the server when you press Save, so the saved score will match what you see — even if a criterion’s max changed underneath you.
Reviewer notes box to record coaching points, e.g. “Strong discovery; missed the upsell at close.”The agent being scored is resolved automatically: if you do not name one, CloudCX attributes the score to the thread’s assigned agent. The reviewer is always the signed-in user. Both are stored on the evaluation so a coaching report can be filtered by agent or by who reviewed them.
Percentages are colour-coded identically everywhere — on the running total, on the AI toast, and on the score tiles in Past evaluations — so a glance tells you the verdict:
| Band | Range | Colour | Reading |
|---|---|---|---|
| Pass | ≥ 80% | Green | Meets the quality bar; light-touch or no coaching. |
| Watch | 60–79% | Amber | Acceptable but with clear gaps; schedule coaching. |
| Fail | < 60% | Red | Below standard; prioritise review and follow-up. |
| No score | max = 0 | Grey | Nothing to score (empty rubric); percentage is blank. |
The bands are fixed in the UI, but your definition of an 80% is set by how you weight criteria. Before rolling QA out, have two or three analysts score the same five interactions and compare — if their percentages diverge by more than ~10 points, your criteria need sharper definitions or your weights need rebalancing. This calibration step is the single biggest driver of fair, trusted scores.
The Supervisor UI scores omnichannel threads, but the underlying model can also pin an evaluation to a voice call by its CloudCX Switch call_uuid (the same identifier you see on a CDR). This lets a future call-recording review attach a score to a specific call leg. For voice today, score the omni thread the call belongs to, or note the call UUID in the reviewer notes for traceability.
Manual review is thorough but slow. The ✨ AI score button hands the interaction to CloudCX AI, which reads the conversation and proposes a score for every criterion on the selected scorecard, with a one-line rationale for each. You stay in control: the AI’s scores land in the same editable inputs, so you review, adjust and save them as a human-reviewed evaluation.
Customer:, outbound become Agent:.OmniMessage{key: {points, rationale}} for every criterion.CloudCX AIai_generated = true; the per-criterion rationales come back to the UI (they are shown, not stored).EvaluationThe result: pressing AI score immediately writes an ✨ AI-badged evaluation to Past evaluations, and fills the scoring inputs with the AI’s numbers plus a ✨ rationale under each criterion. A note appears reminding you that this was saved as a draft — edit the scores if you disagree and press Save evaluation to record your own reviewed version alongside it.
| Outcome | What you see | What to do |
|---|---|---|
| Scored OK | Inputs fill, ✨ rationales appear, toast shows the % | Review, edit, Save. |
| AI not configured | Toast: ✨ AI not configured — set the platform AI key in admin | Ask a platform admin to set the AI credentials. |
| Empty thread / rubric | Error toast (the interaction has no messages, or the card no criteria) | Pick a thread with messages; add criteria to the card. |
| Model unusable | Toast: AI score failed | Retry; if it persists, score by hand and flag the AI config. |
An AI score is a first draft, not a verdict. It can miss sarcasm, business context, or a policy your team enforces. Never let an unreviewed AI evaluation drive coaching, pay or discipline — always open it, sanity-check the rationales against the transcript, correct the points, and save your own reviewed score. The ✨ AI badge in Past evaluations exists precisely so reviewed and unreviewed scores never get confused.
Every saved score — manual or AI — appears in the Past evaluations panel beneath the QA grid, newest first. When an interaction is selected the list filters to that thread; otherwise it shows the recent evaluations across your tenant. Each row carries a colour-coded percentage tile, the scorecard name, an ✨ AI badge for auto-scored rows, the timestamp and short thread id, any reviewer notes, and the raw total / max.
// recent otherwise).Let’s score one interaction end-to-end against the Voice — Sales QA card built earlier. That card has three criteria: Warm greeting (max 5, weight 1), Needs discovery (max 10, weight 2), and Disclosure read (max 5, weight 3).
Refund request · whatsapp · 7f3a9c20 from the dropdown.Note how weighting changes the verdict. Disclosure read (weight 3) contributes the most to both the earned total and the maximum — a compliance miss there would sink the score far faster than fumbling the greeting. The weighted denominator here is 5×1 + 10×2 + 5×3 = 40, so 34 earned points lands at 85% — comfortably in the green band. Tune your weights so the criteria you cannot afford to fail dominate the score.
In a non-production tenant, complete the full QA loop:
greeting (max 5, weight 1), understanding (max 10, weight 2), resolution (max 10, weight 3) and tone (max 5, weight 1). Leave one Key blank and confirm it auto-slugs from the label.Check yourself: Compute the maximum by hand — 5×1 + 10×2 + 10×3 + 5×1 = 65. Does the / max on your running total read / 65? If not, re-check a weight.
Sample consistently rather than only reviewing complaints — e.g. two interactions per agent per week, plus any flagged by a low CSAT survey (Chapter 20). Use AI to triage volume, then human-review the borderline and the failing ones. Over a month, the trend per agent matters more than any single score.
clamp(points,0,max) × weight summed over the rubric, shown as a percentage banded green (≥80) / amber (60–79) / red (<60).Quality scoring turns interactions into a measurable, coachable signal. Pair it with the customer-survey results in the next chapter to see both sides of the conversation — how well the agent handled it, and how the customer felt about it.
A conversation does not end when an agent clicks Resolve. The last word belongs to the customer — “how did we do?” CloudCX answers that with a lightweight post-interaction survey engine: you author a reusable template (a CSAT star rating, an NPS 0–10 question, or a custom mix), send an invite for a finished interaction, the customer fills in a clean branded page at cloudcx.app/survey, and the score flows straight into the Reports dashboard. This chapter takes you from an empty template list to a live CSAT number, end to end.
The three moving parts — template, invite and response — and how they relate; how to author a CSAT and an NPS template, question by question; how an invite mints an unguessable token and turns it into a public link you hand to the customer; how the public survey page behaves in each of its states; how the platform derives the single score from a response; how CSAT average and NPS are calculated and read on the Reports dashboard; plus a fully worked CSAT survey and a Try-it exercise you can reproduce in a test tenant.
Surveys sit downstream of everything you built in the channel and routing chapters. Chapter 16 — The unified inbox gave every conversation a single channel-agnostic thread; a survey invite simply points at one of those threads (optionally) and asks its customer for a rating once the work is done. The result lands on the same Reports view introduced in Chapter 19 — Analytics & reporting, alongside handle time and volume. You administer the templates and the sending policy; the scoring and aggregation run for you on the server.
The whole engine is three records that mirror the ACD and omni patterns you already know. Keeping them straight is the key to everything that follows, so meet them once, clearly:
name, a kind (csat, nps or custom), an ordered list of questions, an active flag and an optional thanks_message. One template is sent to many customers. Author it once.token (the only key the customer needs), the channel it was delivered over, an optional thread_id, and a status of pending → completed.{question key: value} map, plus a derived numeric score (the one CSAT/NPS number used for reporting) and a submitted_at timestamp.Two design choices shape how you work with these. First, submission is anonymous and token-only: the customer never logs in — the token in the link is the entire capability, exactly the same trust model as the web-chat thread_id from Chapter 17. Second, the score is derived, not asked: CloudCX inspects the answers and picks one number to report on, so your dashboard always has a single comparable figure no matter how many questions a template carries. Section 20.6 explains precisely which number it picks.
There is no top-level “Surveys” nav item. Template authoring and sending sit alongside the conversation tooling, and the results surface as the CSAT widget on . Throughout this chapter we show the Reports view as the home for survey outcomes; the public page is a standalone site at cloudcx.app/survey that customers reach by link, never through the admin console.
A template is the questionnaire you reuse across thousands of interactions. Get it right once and every invite inherits it. The template list is your starting point — it shows each questionnaire you (or, for a platform admin, every tenant) own, its kind, whether it is active, and when it was created. Inactive templates are kept for history but should not be sent.
Reusable CSAT, NPS and custom questionnaires.
| Name | Kind | Questions | Status | Created |
|---|---|---|---|---|
| CSPost-chat CSAT | csat | 3 | Active | 12 Jun 2026 |
| RNRelationship NPS | nps | 2 | Active | 02 Jun 2026 |
| VCVoice callback CSAT | csat | 2 | Inactive | 20 May 2026 |
csat, nps or custom. This drives how the score is read and whether NPS is computed.Templates are tenant-scoped. A tenant administrator sees and creates only their own tenant’s templates; a platform administrator (no tenant) can create a template with no owner — a platform-default questionnaire — or stamp it onto a specific tenant. This mirrors the queues-and-skills ownership model from Chapter 14: leave the owner blank for a shared default, or pin it to a tenant for that tenant alone.
A template is little more than a name, a kind, and an ordered list of questions. Each question is a small record — a stable key, a human prompt, a type, and (for ratings) a scale. The three question types are the whole vocabulary:
| Type | Renders as | Answer value | scale | Counts toward score? |
|---|---|---|---|---|
rating | A row of stars, 1…scale | Integer 1–scale | 2–10, default 5 | Yes — fallback score |
nps | An 0–10 button grid | Integer 0–10 | n/a (fixed 0–10) | Yes — primary score |
text | A free-form comment box | String (optional) | n/a | No — verbatim only |
The key (for example overall or recommend) is how an answer is stored and how the score is found — it is not shown to the customer; the prompt is. Keep keys short, lowercase and stable: changing a key on a live template orphans the answers already collected under the old key. Change the prompt freely; leave the key alone.
csat / nps / custom. Pick nps to make the Reports view compute a Net Promoter Score.namekindcsat (default), nps or custom. Only nps templates yield an NPS figure in results; CSAT average is computed for all kinds.questions[]{key, prompt, type, scale?}. type is one of rating/nps/text; scale (2–10, default 5) applies to rating only and is dropped for other types.activetrue. Inactive templates are kept but should not be sent.thanks_messagetenant_idcsat for a satisfaction rating or nps for a 0–10 recommendation question. Use custom for a mixed questionnaire where the first numeric question still becomes the score.key (e.g. overall), choose its type, set the scale if it is a rating, and write the customer-facing prompt.text question (key comment) so customers can explain a low score. Text answers never affect the number.Order matters for the derived score. The platform reads the first NPS question, or failing that the first rating question, as the number to report (Section 20.6). Lead with the question you want to track — the headline rating or recommendation — and place follow-up ratings and comments after it.
A template is inert until you send it. Sending creates exactly one invite: the platform mints a fresh, unguessable token — a 32-character hex string, collision-safe and impossible to enumerate — records which channel it was delivered over and (optionally) which interaction it belongs to, and hands you back a ready-made public link of the form:
# the survey_url returned by POST /surveys/send https://cloudcx.app/survey/?t=8f1c2e6a9b7d4f03a1e5c8b20d6f4a91
That URL is everything the customer needs. Where it goes next is your call. The channel field on the invite records how you delivered it — webchat (the default), email, sms, and so on — so you can later compare response rates by channel. The link itself is channel-neutral; channel is just a label on the invite.
Pass a thread_id when sending so the invite (and its eventual response) is attached to the exact conversation that prompted it. This is the normal post-interaction case — the agent resolves a chat, and a survey for that thread goes out.
Omit thread_id to send a survey not bound to any single conversation — a relationship NPS blast, or a manual one-off. The flow is identical; the invite simply has no thread to point at.
thread_id. Pre-filled when you send from an open conversation; blank for a standalone survey.channel stamped on the invite (default webchat); a label for reporting, not the transport.webchat, email, sms…). This only labels the invite.survey_url; post it into the chat, email it, or text it. The invite is now pending.Each send creates a new token. Sending the same template to the same customer twice issues two independent invites — and two links. Don’t loop the send action expecting it to be idempotent; one resolved interaction normally warrants exactly one invite. A token can be submitted only once (Section 20.5).
When the customer opens the link, they land on a small, fast, fully branded page at cloudcx.app/survey — no login, no account, no console. The page reads the token from the ?t= query parameter, fetches the template’s questions, and renders them. It has four states, and you should know all four because they map directly to what a customer can experience:
A brief spinner while the page fetches the survey for its token.
The questionnaire: each question with stars, an NPS grid or a comment box, and a Submit feedback button.
The confirmation, showing your thanks message. Also shown if the link was already used.
A friendly “this link isn’t available” when the token is missing, wrong or no longer active.
text question; always optional.An NPS question renders differently: a row of eleven buttons numbered 0 to 10, captioned Not likely on the left and Very likely on the right. The customer taps one; that integer is the answer. Everything else — the branded card, the gradient bar, the thanks state — is identical.
Your feedback helps us take every conversation beyond.
The link may have expired or already been used.
Two behaviours are worth committing to memory because customers will hit them:
?t= at all, yields the friendly “isn’t available” card rather than an error page.The page enforces one simple rule before it will submit: every non-text question needs an answer. Ratings and NPS questions are required; text comment questions are always optional. If the customer taps Submit with a rating unanswered, the page points them at the first missing question (“Please answer question 1 before submitting”) and does not send. This keeps your score data complete — you never get a “response” with no rating in it.
The page is keyboard- and screen-reader-friendly: stars and NPS buttons carry aria-labels (“4 of 5”, “9 out of 10”) and the comment box is labelled by its prompt. It also honours prefers-reduced-motion, dropping the hover/transition animations. You don’t configure any of this — it is built into the public page — but it is good to be able to tell a customer the survey meets their needs.
When the customer presses Submit feedback, the page posts their answers for the token. Server-side, three things happen atomically: a Response is recorded with the full answer map, the platform derives and stores the score (Section 20.6), and the invite flips from pending to completed. The page then shows the thanks state.
Submission is protected and single-use:
Every response stores the customer’s full answers, but reporting needs one comparable number per response. CloudCX derives that score with a deliberately simple, predictable rule applied to the template’s question list:
nps question, its answer (0–10) becomes the score.primaryrating question’s answer (1–scale) becomes the score.fallbackscore = null.nullThis is why question order matters and why the “scoring question first” tip in Section 20.2.4 is more than style. A CSAT template’s headline star rating becomes the score; an NPS template’s 0–10 question becomes the score — even if a rating question also exists, NPS wins because it is checked first. The value is coerced to a number defensively (a numeric string still counts; a non-numeric answer yields no score), so the dashboard is never polluted by junk.
You can ask a customer three questions, but exactly one of them drives CSAT/NPS. Use additional ratings for diagnostic detail (they are stored in answers and visible in the histogram only if they share the same value buckets) and text for verbatims. Keep the metric you actually report on as the first numeric question.
Aggregate results are computed on demand for one template over an optional date window, and surfaced as the CSAT widget on . The results payload has a small, stable shape:
| Field | Meaning | When present |
|---|---|---|
csat_avg | Mean of all numeric scores in the window (2 dp) | Any kind, when there is at least one scored response; else null. |
nps | Net Promoter Score, −100…+100 | Only when the template kind is nps and there are scores; else null. |
responses | Count of responses counted in the window | Always (0 if none). |
breakdown | Count of responses per integer score bucket | Always; e.g. {"5":12,"4":6,"3":2}. |
For an NPS template, CloudCX applies the standard rule to the 0–10 scores: respondents scoring 9–10 are promoters, 0–6 are detractors, and 7–8 are passives (counted in the base but neither for nor against). The score is the percentage of promoters minus the percentage of detractors:
# NPS, computed only for kind == nps NPS = (%promoters − %detractors) # range −100 … +100 # worked: 50 responses → 30 promoters (9–10), 5 detractors (0–6), 15 passives (7–8) NPS = (30/50 − 5/50) × 100 = (60% − 10%) = +50.0
CSAT, by contrast, is simply the mean of the stored scores — for a 5-star template that is an average like 4.6 out of 5. The Reports widget normalises whatever it receives to a single ring: a 0–5 average is shown as a one-decimal figure, and where present the NPS and per-score breakdown are listed beside it. If there are no responses in the window yet, the widget shows a friendly empty note rather than a zero.
Post-chat CSAT · 1–30 Jun 2026
| Score | Responses | Share |
|---|---|---|
| 5 ★ | 78 | 61% |
| 4 ★ | 32 | 25% |
| 3 ★ | 11 | 9% |
| 2 ★ | 5 | 4% |
| 1 ★ | 2 | 1% |
from/to date range; here, the last 30 days.csat_avg), shown out of 5 for a star template.nps template.breakdown), one row per integer bucket, newest counts live.from/to range (e.g. this month) to scope the figures.text comments (stored on each response) to find the “why” behind the number.Let’s run the whole lifecycle once, concretely. Acme Pte Ltd wants a 5-star CSAT survey after every resolved web chat, with a comment box for context. We will author it, send one for a real thread, watch the customer answer, and read the result.
From we create a csat template named Post-chat CSAT with two questions and a thanks message. Conceptually it is:
# POST /api/v1/surveys/templates (what the form sends) { "name": "Post-chat CSAT", "kind": "csat", "active": true, "thanks_message": "Thank you! Your feedback helps us take every conversation beyond.", "questions": [ { "key": "overall", "type": "rating", "scale": 5, "prompt": "How would you rate the support you received?" }, { "key": "comment", "type": "text", "prompt": "Anything we could have done better?" } ] }
The first numeric question is the rating keyed overall — that will become the score. The comment question is text, so it never affects the number.
An agent resolves chat Thread #A7F3 with a customer at Acme. From the wrap-up actions they Send survey, pick Post-chat CSAT, keep the pre-filled thread, leave the channel as webchat, and confirm. The platform mints an invite and returns:
# POST /surveys/send → 201 Created { "invite_id": "b2a1…e9", "token": "8f1c2e6a9b7d4f03a1e5c8b20d6f4a91", "status": "pending", "survey_url": "https://cloudcx.app/survey/?t=8f1c2e6a9b7d4f03a1e5c8b20d6f4a91" }
The agent drops that survey_url into the chat as a closing line: “Glad we could help — would you mind rating this chat? cloudcx.app/survey/?t=8f1c…”.
The customer opens the link. The page fetches the questions for the token and renders the star row and comment box (exactly the page in Figure 20.5). They tap 4 stars — the live label reads Good — and type “Quick and friendly, but I waited a while to connect.” into the comment box, then press Submit feedback. The page posts:
# POST /surveys/public/8f1c…4a91 { "answers": { "overall": 4, "comment": "Quick and friendly, but I waited a while to connect." } }
Server-side, a response is recorded, the score is derived from the first numeric question — overall = 4 — so score = 4.0, and the invite flips to completed. The customer sees “Thank you!” with the template’s thanks message. If they click the link again later, they get the thanks state, not a second form.
On , this response now contributes to Post-chat CSAT. After a month of chats the widget shows a csat_avg of 4.6 / 5 across 128 responses, with a breakdown of {"5":78, "4":32, "3":11, "2":5, "1":2} — our 4-star answer is one of the 32 in the “4” bucket. The comment is stored on the response, so a supervisor reviewing low-and-middling scores can read “I waited a while to connect” and tie a CSAT dip back to queue wait time from Chapter 14.
To track loyalty instead of episode satisfaction, clone this template, set kind to nps, and make the first question an nps type keyed recommend (“How likely are you to recommend Acme to a friend or colleague?”). Send it on a cadence (say quarterly) rather than per chat, and the Reports widget will now populate the NPS figure as well as the average.
Goal: author a CSAT template, send one invite, submit a response as the “customer”, and watch the number move on Reports. Use a test tenant.
csat. Add a rating question (key overall, scale 5, prompt “How did we do today?”) and a text question (key comment). Write a thanks message. Create it.webchat. Copy the returned survey_url.csat_avg = 5.0, responses = 1, and a breakdown of {"5":1}.Stretch: clone the template as nps with a single nps question keyed recommend. Send three invites; submit a 10, a 9 and a 5. Predict the NPS before you look: two promoters, one detractor over three responses → (2/3 − 1/3) × 100 = +33.3. Confirm Reports agrees.
| Symptom | Likely cause | Resolution |
|---|---|---|
| Reports CSAT shows an empty note | No scored responses in the window yet, or the survey service returned no data | Expected before responses arrive. Widen the date window; confirm invites are actually being submitted. |
| A response has no score | The template has only text questions (no rating/nps) | Add a numeric question. Text-only templates collect verbatims but produce score = null. |
NPS stays — on Reports | The template kind is csat/custom, not nps | NPS is computed only for nps templates. Set the kind to nps (and use an nps question). |
| Customer sees “link isn’t available” | Token mistyped/truncated, or no ?t= on the URL | Re-send to mint a fresh token; deliver the full link without trimming the query string. |
| “Already submitted” on a first attempt | The same token was opened/submitted twice (e.g. double-click) | Expected — tokens are single-use. Send a new invite if a genuine re-survey is needed. |
| Wrong question drives the score | A rating question precedes the intended NPS, or questions are mis-ordered | Put the metric you report on first; NPS always wins over rating when both exist. |
| Old answers “disappear” after an edit | A question key was renamed on a live template | Never rename a live key. Edit the prompt instead; renaming orphans previously collected answers. |
csat/nps/custom) with an ordered list of {key, prompt, type, scale?} questions, an active flag and a thanks message.token that is the entire capability for the public link; status is pending then completed.{key: value} answer map plus the derived numeric score and a submit timestamp.nps answer, else the first rating answer, else null.csat_avg); for a 5-star template, an average out of 5.nps templates.You can now author, send, collect and read a survey end to end — turning a finished conversation into a CSAT or NPS number on the dashboard. The next chapters widen the lens: Chapter 21 takes survey scores, handle time and volume into deeper analytics and scheduled exports, and the operations chapters cover retention and data-governance for the responses you collect. Keep the “one numeric question, asked first” discipline and your CSAT trend will stay clean and comparable across every channel.
CloudCX layers a set of optional, opt-in AI features over the omnichannel platform you have already learned to run. On text conversations, an insights engine reads a thread and returns sentiment, a summary, topics and a suggested reply; agents see this in the AI Assist panel. A per-tenant chatbot can auto-answer inbound web chat, WhatsApp, SMS and email and hand off to a human when it should. On voice calls, a transcription pipeline turns a recording into searchable text and then runs the same insight model over it for an after-call sentiment and summary, and a text-to-speech backend synthesises announcements. Every one of these is dark until you turn it on by storing a provider key in the encrypted credential vault. This chapter shows exactly where each feature lives, what it does, and how to enable it safely.
Which AI features exist and which provider powers each; how to store the shared AI key (and the STT/TTS keys) in ; how the agent AI Assist panel produces sentiment, summary, topics and a suggested reply, and how that result is cached; how to enable, scope and tune the opt-in chatbot per tenant, and how it decides to reply or hand off; how call transcription runs automatically on hang-up and on demand, and where the transcript and its AI fields surface; and the platform’s fail-safe contract — with no key set, nothing breaks and every conversation routes to a human exactly as before.
A single principle ties the whole chapter together, so internalise it now: AI is additive and fail-safe. With no credentials configured, the insight routes return a clean 503, the chatbot never touches an inbound message, and transcription is a silent no-op — the platform deploys and runs identically to one with no AI at all. You enable a feature by giving it a key; you disable it by removing the key. There is no big red switch and no half-on state that can drop a customer.
It helps to see all five capabilities, the provider each one uses, and the credential it reads, before drilling in. CloudCX deliberately splits the providers: text intelligence is CloudCX’s CloudCX AI; speech is a dedicated speech vendor (CloudCX Speech by default). They are configured independently — you can run insights without ever touching voice, or transcription without a chatbot.
| Feature | What it does | Provider | Vault credential | Surfaces in |
|---|---|---|---|---|
| Insights & sentiment | Sentiment, score, summary, topics, suggested reply over a text thread | CloudCX AI | ai | Agent AI Assist panel |
| Suggested replies | One drafted next agent reply, in a chosen tone | CloudCX AI | ai | Agent AI Assist panel |
| Chatbot | Auto-answers inbound text and hands off to a human (opt-in per tenant) | CloudCX AI | ai | Digital channels · |
| Call transcription | Speech-to-text of a recording, then CloudCX AI sentiment + summary | CloudCX Speech + CloudCX AI | stt (+ ai) | Call record / transcript |
| Text-to-speech (TTS) | Synthesises spoken audio for announcements / testing | CloudCX Speech | tts | Voice flows · API |
Three of the five — insights, suggested replies and the chatbot — share the single ai credential (one CloudCX AI key powers all text intelligence). Transcription needs stt to do the speech part and optionally uses the same ai key for the after-call insight. TTS is fully independent under tts. This matters operationally: store the one CloudCX AI key and three features light up at once.
ai · stt · ttsText analysis (sentiment, summarisation, drafting) is exactly what a large language model excels at, so CloudCX uses CloudCX’s CloudCX AI through the official Messages API. Speech-to-text and text-to-speech are a different problem with different vendors, so they run through a small, pluggable speech layer defaulting to CloudCX Speech. Keeping them separate means a voice key and a text key are managed and rotated independently — and a problem with one never disables the other.
None of these features read a key from an environment variable or a config file. Every provider secret lives in the Platform Credentials store — the same encrypted, admin-only vault you met in the platform-configuration chapter — as Fernet-encrypted JSON in the platform_credentials table. One row per provider; the field values are secret and are never returned by any endpoint, only a configured flag and the field names. The one root secret that stays outside the database is the encryption master key, BYOND_CREDS_KEY, held in the server environment.
You manage these from in the admin console. The page renders one card per provider, each showing a Configured ✓ badge when a row exists and the fields that provider expects. The three cards that concern this chapter are ai, stt and tts.
CloudCX’s shared connectivity & AI keys. Stored encrypted; values are never shown.
BYOND_CREDS_KEY, the save is refused with a clear error rather than storing plaintext.)configured: true). You are ready for §21.3.The ai credential is CloudCX’s shared key — one key for the whole platform, used by every tenant’s AI features. It is not per-reseller or per-tenant. Treat it as a high-value secret: rotating it (save a new value) takes effect immediately with no restart, and removing it disables every AI feature at once. Because the value is write-only, keep your own record of which key is in use.
When the CloudCX AI key is set, agents gain an AI Assist panel in the right-hand context column of the Agent Desktop, on any text interaction (web chat, WhatsApp, SMS, email). It is a dark, brand-gradient card with a spark mark and a small status indicator. From it an agent can, with one click, get a read on how the conversation is going and a head-start on the next reply.
Two actions drive the panel, each mapping to a backend route that reads the open thread’s messages in order, builds a labelled transcript (Customer / Agent), and asks CloudCX AI for a specific output:
A third route, POST /ai/threads/{id}/suggest-reply, produces a single drafted reply on its own and accepts an optional tone (default professional) — it is the same drafting that the Analyze bundle includes, exposed separately for callers that want only a reply in a chosen tone.
When an agent clicks Analyze, the platform does the following, all guarded so a failure never breaks the desktop:
ai key is not set, the route returns 503 and the panel shows “AI assist not enabled” — not an error.404; a thread with no messages yet is 422 (“no messages to analyze”).Re-analysing the same unchanged thread would waste a model call (and money) and return the same answer. By folding the thread’s last-message time into the cache key, a new customer or agent turn automatically produces a fresh key — so analysis is always current after a reply, but free to click repeatedly in between. This is invisible to the agent; they just see fast, consistent results.
The insight routes analyse text threads. Voice calls are handled separately: a call is transcribed first (§21.6), and the resulting transcript text is then run through the same insight model for an after-call sentiment and summary. There is no “analyse a live call” button — the call must end and be transcribed first.
The chatbot lets a tenant have inbound text messages answered automatically by CloudCX AI, with a clean hand-off to a human when the bot should not, or cannot, help. It is the most powerful AI feature and the one with the strongest safety rails, because it can reply to customers on its own. The single most important fact about it is this:
There is no platform-wide default bot. With the table empty (the state after install), every tenant’s inbound behaviour is completely unchanged — every message routes to a human exactly as before. The bot acts only for a tenant that has explicitly created an enabled configuration naming the channels it should handle. Enabling it is a deliberate, per-tenant decision.
A tenant’s bot is governed by one bot configuration with these fields:
enabledchannelswebchat, whatsapp, sms, email. A thread on a channel not in the list falls through to normal human routing untouched.personamax_turns5; range 0–50. Setting it to 0 hands off on the first inbound (a pure “greet-then-route” bot).handoff_messageAuto-answer inbound text and hand off to a human when appropriate.
Behind this screen are two routes. GET /bot/config returns the configuration (a platform admin sees the full list across tenants; a tenant-scoped user sees their own, with a default disabled config when none exists yet, so the screen always has something to render). PUT /bot/config creates or replaces a tenant’s config and is restricted to platform admins and resellers; a tenant-scoped user may only write their own tenant. Both responses carry an ai_configured flag so the UI can warn that an enabled bot will not reply until the CloudCX AI key is set.
On every inbound text message, after the message is stored and before the normal human routing, the platform asks the bot whether it wants to take the turn. The bot returns “handled” only when it has fully dealt with the message (sent a reply, or routed to a human with a hand-off note); otherwise the message falls through to the usual human routing — completely unchanged. It falls through, deliberately, in all of these cases:
max_turns) is spent;When the bot does take a turn, it builds the labelled transcript and asks CloudCX AI for a single strict-JSON object with two fields: a reply (the message to the customer) and a handoff boolean. The persona instructs CloudCX AI to set handoff: true when the customer asks for a human, when the request needs an account action or authentication, when the customer is upset or the issue is sensitive, or when it is simply not confident. The platform then acts on that decision:
{reply, handoff}A bot reply is stored as an ordinary outbound message authored by a sentinel sender named “AI Assistant” and delivered over the thread’s channel (a WhatsApp / SMS / email send, or the live web-chat broadcast). Counting those sentinel messages is how the platform tracks how many turns the bot has spent. Because the thread is left open after a bot reply, a human agent can always step in and claim it; the moment an agent (a different sender) replies, the bot is naturally out of the loop. When the bot hands off, it routes the thread to the channel’s default queue and the available-agent picker, then posts the hand-off message so the customer is never left waiting in silence.
ai credential (§21.2.1). You can save an enabled config without it, but the bot will stay silent until the key exists.webchat is a good first choice. Leave the others unticked.max_turns of 2–3 so the bot hands off early while you build confidence. Set a friendly hand-off message.Let us trace one inbound web-chat message for the tenant Acme Retail, whose bot is enabled on webchat with max_turns = 3 and the persona from the screen above. The CloudCX AI key is set.
webchat and the inbound message is stored.webchat is in its channels, no human owns the thread, the turn budget (3) is untouched, and the key is set — so the bot takes the turn.{"reply": "I’m sorry your order #10492 hasn’t arrived… Could you confirm the delivery postcode so I can check the courier status?", "handoff": false}.handoff is false, the reply is stored as an outbound message from AI Assistant, delivered to the chat widget, and broadcast live. Turn count is now 1. The thread stays open.{"reply": "", "handoff": true} — a refund plus an explicit request for a person.Notice what the turn budget bought you: even if the customer had kept chatting without escalating, the bot would have handed off automatically after its third reply — so a conversation never spirals indefinitely in the bot. And notice the seam between bot and human is invisible to the customer: one continuous thread, no “please start again.”
In a non-production tenant, with the CloudCX AI key set:
webchat only, set max_turns = 1, and give it a one-line persona (“greet, then connect to a human”). Send a chat and confirm it greets once, then immediately hands off with your hand-off message.max_turns = 4 and a fuller persona that says “handle delivery questions, escalate refunds.” Send a delivery question (expect a helpful reply) and then ask for a refund (expect a hand-off). Confirm the hand-off thread lands in the agent inbox.On the voice side, CloudCX can transcribe a call’s recording into searchable text and then run the same CloudCX AI insight model over that text for an after-call sentiment and summary. The speech-to-text step uses a pluggable provider — CloudCX Speech by default — configured under the stt credential. The AI fields are filled only if the ai key is also set; with STT alone you still get the transcript, just without sentiment and summary.
The result is stored as one call transcript per call, keyed by the call’s CloudCX Switch UUID — the same id the call record and the on-disk recording share. The row carries the full text, an optional language, the provider used, optional per-utterance segments (with speaker labels when the provider supplies them), and the AI-derived sentiment and summary.
There are two ways a transcript comes into being:
To read a stored transcript, GET /voice/calls/{uuid}/transcript returns the text and its AI fields (a 404 if the call has not been transcribed yet). Both routes validate that the UUID is a real UUID, and a transcript is tenant-scoped — a tenant user can only read or re-transcribe their own tenant’s calls.
UUID a1b2c3d4… · provider CloudCX Speech · language en
The customer called about a delayed delivery for order #10492. The agent confirmed the parcel was with the courier, arranged re-delivery for the next day, and offered a goodwill voucher. The customer was satisfied and the issue was resolved on the call.
stt key (a 503 otherwise) and the recording on disk.ai key is set; otherwise this tile is blank but the transcript still appears.stt card’s API key. Leave Provider blank to use the CloudCX Speech service. An optional model id is accepted.ai key present too, each transcript is enriched with a CloudCX AI sentiment and summary. Without it, you still get clean text.The whole pipeline is designed to be invisible to live telephony. It runs after the call is over, never raises into the call path, and degrades to a no-op when STT is unconfigured or a recording cannot be found. Turning STT on cannot affect call quality or routing; turning it off simply stops new transcripts being produced. Stored transcripts are analytics artefacts that survive even tenant deletion, mirroring how call records keep history.
The final piece is the inverse of transcription: text-to-speech. Configured under the tts credential (the CloudCX Speech service, with an optional voice id), it turns text into spoken audio. It powers spoken announcements in voice flows and provides a POST /voice/tts endpoint that synthesises a short piece of text and returns the audio — useful for testing a voice and prompt before wiring it into a flow. Like the others, it returns a clean 503 when no tts key is set.
This makes a complete “voice AI” pairing: STT understands what callers say (and feeds CloudCX AI for insight), and TTS speaks back to them. They are configured and billed independently, so you can run one without the other.
You do not have to enable everything at once. A common, low-risk path: (1) set the CloudCX AI key and let agents use AI Assist for a week; (2) enable the chatbot on web chat for one friendly tenant with a tight persona and low turn budget; (3) add STT so calls are transcribed and summarised; (4) finally add TTS for spoken flows. At each step the rest of the platform is unaffected, and you can stop at whichever level fits the customer.
A handful of facts will resolve almost every “why isn’t the AI working?” question:
| Symptom | Most likely cause | What to check |
|---|---|---|
| AI Assist shows “not enabled”; buttons inert | No ai key (route returns 503) | Set the AI key in Platform Credentials; the panel reads /ai/health. |
| Bot is enabled but never replies | AI not configured, or channel/turns/ownership gate | Confirm the ai key is set; confirm the thread’s channel is ticked and the turn budget is not spent. |
| “No messages to analyze” | Empty thread (422) | Analyze after at least one message exists in the thread. |
Transcript request returns 503 | No stt key | Set the STT key; this is separate from the AI key. |
| Transcript exists but no sentiment/summary | STT set, ai key not set | Add the AI key to enrich transcripts; text is still produced without it. |
Transcribe returns 404 “no recording” | Recording missing for that call | Confirm recording was enabled and the file exists for the call UUID. |
On cost: text intelligence is billed by CloudCX per token at the configured model’s rate — the platform default model is a mid-tier model chosen for a good quality-to-cost balance. Three design choices keep spend in check without you doing anything: insight results are cached per unchanged thread (§21.3.1), the chatbot has a per-thread turn budget that caps how many model calls one conversation can trigger, and transcripts are produced once per call and re-used. Speech is billed separately by your STT/TTS vendor, per minute of audio. If you need to control AI cost for a tenant, the chatbot’s max_turns and channel scope are your most direct levers.
Remember that the ai credential is platform-wide. Removing it to stop one tenant’s bot also disables AI Assist and transcript insights for every tenant. To stop a single tenant’s bot, disable that tenant’s bot config — do not pull the shared key. Reserve key rotation/removal for genuine platform-wide changes (a compromised key, a billing switch, a model migration).
CloudCX’s AI features are a coherent set built on two providers and one rule. CloudCX AI powers all text intelligence — the AI Assist insights (sentiment, summary, topics), the suggested replies agents can insert, the after-call summary/sentiment on transcripts, and the opt-in chatbot — all from one shared ai key. A dedicated speech layer (CloudCX Speech by default) powers transcription under stt and TTS under tts. Every secret lives encrypted in the admin-only Platform Credentials vault, never in the open. And the rule that makes all of it safe to adopt: AI is additive and fail-safe — nothing is on until you set a key, the chatbot is opt-in per tenant and can never break inbound, and transcription never touches a live call. Enable what serves your customers, in the order that suits them, and leave the rest dark with zero side-effects.
Behind every call, chat, WhatsApp message and email is a person. CloudCX gives that person a stable identity — a Contact — so an agent always knows who they are talking to, can read the customer’s whole history at a glance, and can leave notes the next agent will need. This chapter covers the built-in contacts directory that backs the Agent Desktop’s customer panel, how a visitor is automatically resolved to a contact, the merged interaction timeline stitched from digital threads and call records, and the optional HubSpot connector that mirrors your contacts into an external CRM of record. It closes with a fully worked contact resolve and a hands-on Try it exercise.
The CloudCX contacts directory is a lightweight CRM: just enough to give agents a customer identity, profile, notes and timeline that persist across interactions. It is not a replacement for a full sales CRM. When your organisation already runs HubSpot (or, later, Salesforce / Zoho), the connector in §22.5 keeps the two in step — CloudCX stays the system of engagement, your CRM stays the system of record.
A contact is the stable record of one customer. It is deliberately minimal — the platform never tries to be a marketing database — but it carries everything an agent needs in the moment. Two fields do the heavy lifting for matching: the loosely-normalised phone and the email. Both are indexed, so CloudCX can find-or-create a contact from an inbound interaction in a single lookup, and a search box can match on either.
namephone+ is preserved and everything else is reduced to digits. So +1 415 555 0182, (415) 555-0182 and 14155550182 all collapse to the same matchable key. Indexed.email[email protected] equals [email protected]). Indexed.companytitlenotescustom{"tier":"Enterprise","account":"AC-77120","vip":true}. Each key becomes a labelled row in the CRM record card, and a vip flag lights the VIP badge.tenant_idEvery read, search, resolve and edit is scoped to the signed-in user’s tenant. An agent can only ever see, match or attach to a contact belonging to their own tenant; resolving an interaction can never surface another tenant’s customer. A platform administrator (who has no tenant) sees the tenant-less, anonymous contacts. This boundary is enforced by the API on every call — you cannot opt out of it.
The usual path. When an agent opens an interaction that carries a visitor identity (a phone number or email), the desktop resolves it — finding the existing contact or creating a new one. Agents never have to add contacts by hand for them to appear (§22.3).
Anyone authenticated can create a contact through the API (POST /contacts) — useful for seeding a directory or for an integration that imports customers ahead of their first contact.
A contact grows over time. An agent adds a name, company or a note; the next interaction enriches it further. A profile that started as just a phone number becomes a rounded customer record.
When the HubSpot connector is configured, contacts can be pushed outward to your external CRM — one at a time or in bulk — so your CRM stays current with the customers CloudCX is talking to (§22.5).
The directory is the searchable list of every contact in your tenant, newest first. It backs both the customer panel on the Agent Desktop and any administrative listing. A single search box matches against the name, phone or email at once, so an agent can paste in a number, type part of a name, or drop in an email and find the same record either way.
Search by name, phone or email. Newest first.
| Name | Phone | Company | Added | |
|---|---|---|---|---|
| JLJordan Lee | +1 415 555 0182 | [email protected] | Northwind | 2m ago |
| MSMaría Solís | +34 612 000 210 | [email protected] | — | 1h ago |
| DODaniel Okonkwo | +44 7700 900221 | [email protected] | Acme Corp | Yesterday |
| ?Unknown caller | +1 312 555 7788 | — | — | 3d ago |
| Parameter | Effect | Default · bounds |
|---|---|---|
| q | Partial, case-insensitive match against name OR phone OR email. Omit it to list everyone. | none (lists all) |
| limit | Maximum number of rows returned. | 50 · between 1 and 500 |
| Order | Always newest contact first (by creation time). | fixed |
| Scope | Restricted to your tenant’s contacts; a platform admin sees tenant-less contacts. | enforced |
Because phone matching is on the digits-only form, you can paste a number exactly as the customer wrote it — with country code, spaces, dashes or brackets — and still land on the right contact. There is no “correct” format to remember.
The single most important behaviour in the contacts system is resolve — a find-or-create. When an agent opens an interaction whose visitor has an identity (a phone number for a call or SMS, an email for an email thread, a number or address for chat/WhatsApp), the Agent Desktop hands that identity to the platform, which either returns the matching contact or creates a fresh one on the spot. The desktop therefore always has a stable CRM record to attach the conversation to — even for a first-time, never-seen caller.
The identity passed in is either an email or a phone, and CloudCX decides which by a simple rule: if it contains an @ it is treated as an email, otherwise as a phone.
[email protected] resolves to the same record as [email protected].Resolve only ever adds. When it finds a contact it returns it untouched, except for backfilling a name that was previously missing. It never overwrites a name, company or note you have already set. Enriching the rest of the profile is a deliberate edit (§22.4), not a side-effect of an interaction arriving.
Resolve is invisible in the normal flow: the agent claims a conversation and the customer panel simply fills in. Under the hood the desktop resolves the visitor, renders the profile, loads the history, and binds the notes box — all before the agent types a word. If anything fails, the panel degrades gracefully and the rest of the desktop is unaffected; the conversation is never blocked on the CRM.
| Visitor identity | Directory state | Result |
|---|---|---|
| Known email or phone | A matching contact exists | That contact is returned and shown; a missing name is backfilled if one was supplied. |
| New email or phone | No match | A new contact is created (with name if known) and shown. |
| Anonymous visitor | No identity to match | No resolve happens; the panel shows the demo / placeholder context and no contact is created. |
Re-opening the same interaction, or a different conversation from the same customer, resolves to the same contact — the platform caches the resolution per interaction and matching is deterministic. You will not end up with duplicate records for one customer just because they reached out twice.
Everything in this chapter comes together in the right-hand Customer column of the Agent Desktop (Chapter 17). When a contact is resolved, the panel shows four stacked cards: the profile, the CRM record, Recent interactions, and private Internal notes. The same panel travels with the agent across every channel, so the customer’s context never disappears when they switch from chat to a call.
custom map carries a vip flag.custom map (here Tier and Account). The CR-… id is a short, stable handle for the contact.The Recent interactions card is a single merged timeline stitched from two sources and sorted newest-first:
The two streams are interleaved by time into one list, so the agent reads the customer’s whole relationship — voice and digital together — in one place, capped at the most recent items.
Each row in the timeline is colour-coded by channel and tagged with an outcome so the agent can scan a customer’s history in seconds. The dot follows the channel palette used everywhere in the platform; the outcome pill maps the underlying status to a clear colour.
| Element | Voice (call) | Digital (thread) |
|---|---|---|
| Kind | call | omni |
| Channel dot | Voice (magenta) | Chat blue · WhatsApp green · SMS amber · Email violet · Social pink |
| Title | Inbound call or Outbound call | The thread subject, or a channel label (e.g. Web chat, WhatsApp) |
| Outcome pill | answered or missed | closed/resolved, open, escalated… |
| Age | Relative time of the interaction (e.g. 2m, 1h, Yesterday), newest first. | |
The timeline matches a call when the contact’s number is either the caller or the destination. That is deliberate: a customer who phones in (they are the caller) and a customer you dialled out to (they are the destination) are both their calls. Matching both directions means a contact’s history is complete regardless of who placed each call.
A contact is enriched by editing. An agent who learns the customer’s name, company or role updates the profile; the Internal notes box captures context for whoever handles the customer next. Edits are partial — only the fields you change are written, so updating a note never disturbs the name, and clearing a phone removes just that field.
Internal notes are never shown to the customer, but they are visible to every agent and supervisor who opens the contact, and they persist across interactions. Write them professionally: useful operational context (“prefers email; account on Enterprise tier”), never personal opinion or anything you would not want the customer—or an auditor—to read.
When you record a disposition at the end of a chat (Chapter 17), CloudCX can append the outcome, tags and your wrap-up note to this same contact’s notes field. Over time the notes become a running log of how the customer’s issues were handled — which is exactly what the next agent wants to read first.
CloudCX can mirror its built-in contacts — and log interactions against them — into an external CRM of record. HubSpot ships today (Salesforce, Zoho and others can be added later behind the same interface). The connector is an administrator feature: it is configured once with a HubSpot token, after which contacts can be pushed out one at a time or in bulk. Until it is configured, the whole feature is a graceful no-op — it ships dark and an admin turns it on at runtime.
Create-or-update a HubSpot contact, keyed on email (or, failing that, phone). The connector searches HubSpot for an existing record; if found it updates it, otherwise it creates a new one. Your name is split into HubSpot’s firstname/lastname, and company/title map to company/jobtitle.
Ensure the contact exists, then attach a HubSpot Note activity carrying a short interaction summary (and a channel/direction tag), associated to that contact — so a call or chat in CloudCX leaves a trail in your CRM.
| CloudCX field | HubSpot property | Notes |
|---|---|---|
| name | firstname + lastname | Split on the first space: first token is the first name, the rest the last name. |
| Primary de-duplication key. | ||
| phone | phone | Fallback de-duplication key when there is no email. |
| company | company | — |
| title | jobtitle | — |
| custom | not auto-written | Custom keys are not pushed (they would need matching custom properties in your portal). |
The connector only ever sends the values it actually has — empty fields are omitted from the request. So syncing a CloudCX contact that has no company will not wipe a company an operator set directly in HubSpot. The sync is additive, and it is safe to run repeatedly.
The connector reads CloudCX’s encrypted platform credentials under the crm provider. An administrator stores two fields — the provider (hubspot) and the token (a HubSpot private-app access token) — in the platform integration credentials, the same screen used for channel providers (Chapter 12). The token is write-only and is never returned by any status call.
Mirror contacts into your CRM of record.
With the connector configured, an administrator can push contacts to HubSpot two ways: a single sync of one contact, or a bulk sync of the whole tenant’s directory. Both report exactly what happened — created, updated or failed — and neither ever throws: a problem with one contact is counted, not allowed to abort the run.
| Contact | Action | HubSpot id | Result |
|---|---|---|---|
| JLJordan Lee | update | 301-552-118 | synced |
| MSMaría Solís | create | 301-552-940 | synced |
| ?Unknown caller | — | — | no identifier |
| Operation | Scope | Reports |
|---|---|---|
| Single sync | One contact by id (admin). | action = create / update / noop, and the external crm_id; or an error string. |
| Bulk sync | Your tenant’s contacts, newest first, up to a hard cap of 500 per run, pushed in small concurrent batches. | Totals: considered, synced, created, updated, failed, and truncated when more contacts exist than the cap. |
| Status | Whether a CRM is configured and which provider. | {configured, provider} — never the token. |
A bulk sync only ever pushes the calling admin’s tenant’s contacts. A platform administrator (with no tenant) syncs the tenant-less, anonymous contacts — not every tenant’s directory. If you intend to mirror a specific customer’s contacts to HubSpot, run the sync signed in as an administrator of that tenant.
If HubSpot is slow or unreachable, the connector times out quickly and reports the failure for the affected contacts rather than wedging the request or the batch. If the crm credentials are removed, every sync simply becomes a no-op again. Nothing in the contacts directory depends on HubSpot being reachable.
This follows one caller from the moment the phone rings to a record mirrored in HubSpot — the full arc the contacts system runs, automatically, many times an hour.
(415) 555-0182. The ACD routes it to agent Ada, whose desktop pops the screen-pop with the caller’s number.@), so it is treated as a phone: reduced to the digits 4155550182 and looked up in Ada’s tenant.+14155550182 already exists — Jordan Lee, from a previous chat. The same digits match despite the different formatting, so the existing record is returned. No duplicate is created.custom map has vip: true). The CRM card shows Company Northwind, Title Ops Lead, plus the custom Tier and Account rows.Goal: see find-or-create, the timeline and a HubSpot sync work end to end, using the same tools agents and admins use.
+1 415 555 0199).14155550199). Confirm you get back the contact you just created, not a second one.tier field, and save a private note. Re-open it and confirm the CRM card now shows the tier row and the note is retained.Check: one contact — not two — for the reused number; the custom tier visible on the card; both interactions in the timeline; and a HubSpot id returned by the sync with the action flipping create → update on the second run.
+ (if present) plus digits only, so every way of writing a number matches the same contact.The customer panel lives inside the Agent Desktop — Chapter 17 covers handling the interactions whose context this chapter supplies. The CRM connector’s credentials sit alongside the channel providers configured in Chapter 12. Outbound campaign dialling, which also reads and dispositions contacts, is covered in Chapter 18.
Every minute of voice, every SMS segment and every WhatsApp message that flows through CloudCX can carry a price. This chapter is the operator’s guide to how that money is modelled and moved: the immutable ledger and the per-party accounts that sit on it, the wholesale and retail rate cards that turn raw usage into priced charges, the period-close that groups those charges into invoices, and the two ways an invoice gets paid — online through a bring-your-own Stripe account, or manually by recording a wire or a credit. It closes with a full worked billing cycle and a Try-it exercise.
This is Billing I — the core money path that turns usage into a paid invoice. Billing II (the next chapter) layers on prepaid & credit control (top-ups, credit limits, low-balance alerts), tax & FX configuration, dunning (overdue collections, reminders, suspend/unsuspend) and the finance reports (revenue, margin per reseller, receivables aging, reconciliation). You will see those panels in the console here too — note them, then turn the page for the detail. Everything in this chapter lives under admin.cloudcx.app → Billing, with the matching wholesale/retail rate-card surfaces under Billing and the partner console.
CloudCX ships with the billing engine present but dormant. With no rate cards, the rater runs to completion and charges exactly zero — unrated usage is simply left for later, and the miss is logged once. With no Stripe configured, online settlement is unavailable and manual settlement still works. You therefore turn billing on deliberately, card by card. An empty billing database has zero effect on a running platform.
Three kinds of party take part in money flows, and they map exactly onto the platform hierarchy you already know from Chapter 2: the platform (CloudCX itself), the resellers, and the tenants. Money moves across three relationships between them:
A reseller’s tenant generates two charges for the same usage: a retail charge (tenant owes the reseller, priced off the reseller’s card) and a wholesale charge (reseller owes the platform, priced off the platform card). A direct tenant — one with no reseller — has only the platform relationship: its “retail” is the platform card, and there is no second leg.
Each party has exactly one billing account — its whole money position in one place. An account is keyed by (party_type, party_id): platform with a NULL id (there is one platform account), reseller with the reseller id, or tenant with the tenant id. Accounts are created on first use: the first charge, payment or top-up that touches a party auto-creates its account, so you never have to pre-provision them.
party_typeplatform / reseller / tenant. The Billing → Billing accounts table shows every account, platform-first.currencyUSD). All of an account’s invoices are issued in this currency; a charge in a different currency is converted via FX at invoice time (Billing II).billing_modepostpaid (invoice then collect — the default, and the subject of this chapter) or prepaid (collect then spend — Billing II).balancestatusactive / suspended / closed. Suspension (a dunning action) is covered in Billing II.Behind every balance is an append-only journal: one row per money event, never updated and never deleted. Each row carries a signed amount in “what the party owes us” terms and a balance-after snapshot, so the journal alone can reproduce a running statement. The maintained balance on the account is just a cache of this journal, moved in the very same database transaction as the row that justifies it, under a row lock so two concurrent posts can never race it.
Event (txn_type) | Sign | Effect on balance | Posted by |
|---|---|---|---|
charge | positive | Increases what they owe | The rater (rating a CDR / message / seat) |
payment | negative | Decreases what they owe | A confirmed Stripe payment, or a recorded wire |
credit | negative | Decreases what they owe | A goodwill credit you post |
adjustment | negative | Decreases what they owe | A correcting adjustment you post |
refund | negative | Decreases what they owe | A refund you post |
This is the single most important idea in CloudCX billing. The balance moves when usage is rated — the moment a charge is posted. An invoice later groups charges that are already on the ledger into a presentable document; posting an invoice does not move the balance. (Doing so would double-count every charge.) Only a payment or a credit writes the negative row that pays the balance down. Keep this in mind whenever a balance and an invoice total seem to disagree — see § 23.6.
A charge is one billable line: a rated call, a rated message batch, a monthly DID rental, a seat, a one-off. Two account references make the multi-level model work: account_id is the payer (who owes) and counterparty_account_id is who is owed. A reseller-tenant’s voice charge therefore has the tenant account as payer and the reseller account as counterparty; the matching wholesale charge has the reseller as payer and the platform as counterparty. Every charge also carries a unique external_id (the CDR’s call_uuid, msg:<id> for a message, and so on) which is the idempotency key for usage — re-ingesting the same CDR never charges twice. A charge moves pending → invoiced (when a period close pulls it in) and is otherwise immutable.
Open Billing from the platform sidebar. The workspace is a stack of panels that follow the money from the bottom up: the accounts and their balances, the platform’s own Stripe configuration, the wholesale rate cards, and the invoices CloudCX issues. (Below those sit the prepaid, tax/FX, dunning and reports panels covered in Billing II.) The data-source line under the heading tells you whether you are looking at the live API or fixture data — read it before you act, exactly as on every other platform view.
Every account & balance — positive means the party owes us.
| Party | Type | Currency | Mode | Status | Balance |
|---|---|---|---|---|---|
| BYCloudCX (Platform) | platform | USD | postpaid | active | −$1,204.00 |
| ACAcme Comms | reseller | USD | postpaid | active | $842.16 |
| NWNorthwind Retail | tenant | USD | postpaid | active | $318.40 |
| HBHarbour Clinic | tenant | USD | prepaid | active | −$50.00 |
// 03 — Platform billing · live) confirms you are on the live API, not fixtures.party_type is colour-pilled (platform / reseller / tenant).The platform row shows −$1,204.00 — that is the platform account’s own position, which is in credit here from refunds/credits posted to it; it is not the platform’s revenue (that lives in the reports). Acme Comms (a reseller) owes $842.16 in accumulated wholesale. Harbour Clinic is a prepaid tenant sitting on −$50.00 — i.e. $50 of available credit. Always read the sign against the “owes us” convention from Table 23.1.
Click any account row to expand its statement: the journal rows, oldest first, each with its running balance-after. This is the authoritative history — a charge here, a payment there, a credit — and it is what you reconcile against. The statement is read-only; you change a balance only by posting a charge (via usage), a payment or a credit, never by editing the journal.
txn_type with a signed amount and the balance after it. The closing balance equals the figure in the table.A rate card is a named, scoped set of per-destination rates. Its scope decides what it prices:
Owner platform (NULL id). The rates CloudCX charges resellers and direct tenants. Managed by the platform admin under Billing → Wholesale rate cards and the /admin/rate-cards API.
Owner reseller (the reseller’s id). The rates a reseller charges its own tenants. Managed by the reseller in the partner console and the /reseller/rate-cards API — hard-scoped to that reseller.
Each card holds entries, one per channel + destination_prefix. The rater matches an event by longest prefix — so 65 (Singapore) beats 6 beats the catch-all '' (empty prefix, matches anything). Messaging entries have no dialed destination, so they use the catch-all. The scope (owner_type, owner_id) is always forced from who you are: an admin can only ever create platform cards, a reseller only its own — it is never read from the request body, and a cross-scope read returns 404 (it never even leaks that another owner’s card exists).
| Field | Meaning | Example |
|---|---|---|
channel | What it prices: voice, sms or whatsapp. | voice |
destination_prefix | Longest-prefix match key. '' (empty) is the catch-all and matches anything at lowest priority. | 65 |
rate | Per-unit price (per billed minute for voice; per segment for SMS; per message for WhatsApp). Six decimals — sub-cent rates are exact. | 0.014000 |
setup_fee | A one-off fee added per call (e.g. a connection charge). Applied even when an allowance zeroes the per-minute portion. | 0.010000 |
increment_seconds | Voice billing increment: 60 = per-minute, 1 = per-second. Ignored for messaging. | 60 |
included_units | Allowance subtracted before charging (minutes / segments). Floors at zero. | 0 |
Voice: billed minutes = ceil(billsec / increment_seconds), less included_units (floored at 0), times rate, plus setup_fee. Only outbound, normally-cleared calls bill; inbound and unanswered/failed calls are free. SMS: the segment count uses the industry GSM-7 rule (160 chars in one segment, 153 per part when concatenated) with a UCS-2 fallback (70 / 67) for non-GSM bodies. WhatsApp: one billable unit per message. The rate card is data — you never write code to price usage.
The Wholesale rate cards panel lists the platform cards; each expands to show and edit its entries. You create the card header first (name + currency), then add entries to it. Use active to park a card without deleting it — the rater only uses active cards, and it always picks the newest active card for a scope, so re-pricing is a matter of cloning a card with new rates and activating it.
Rates CloudCX charges resellers & direct tenants.
| Channel | Prefix | Rate | Setup | Incr | Incl. |
|---|---|---|---|---|---|
| voice | 65 | 0.008000 | 0.000000 | 60 | 0 |
| voice | 1 | 0.011000 | 0.000000 | 60 | 0 |
| voice | catch-all | 0.030000 | 0.000000 | 60 | 0 |
| sms | catch-all | 0.004000 | 0.000000 | — | 0 |
65 (Singapore) — longest-prefix match wins over 1 and the catch-all.Standard Wholesale 2026) and the settlement currency (default USD). Leave active on. Save — the empty card appears in the list.voice, prefix blank (the catch-all), rate your floor price, increment_seconds 60. This guarantees some voice rate matches every call.voice entry for each destination you price keenly — e.g. prefix 65 at a lower rate. The longer prefix automatically wins for matching numbers.sms and/or whatsapp entry on the catch-all prefix with the per-segment / per-message rate.If no entry (not even a catch-all) matches an event, the rater leaves the usage unrated and logs the miss once — nothing is charged, and the call/message stays free until a matching card exists. That is the idle-safe guarantee, but it also means a missing catch-all silently makes a whole class of traffic free. Add the catch-all entry first, then refine with specific prefixes.
To change a rate, you can edit the live entry (it affects only future rating passes) or, more safely, clone the card with new rates and activate the clone. Either way, charges already posted are immutable — a charge does not reference the card, so changing or even deleting a card never rewrites a balance. Editing rates does not retro-bill yesterday’s traffic.
Charges accumulate on the ledger continuously as usage is rated. Invoicing is the periodic step that closes a window and groups each account’s pending charges in that window into one invoice per account. Remember the model: this does not touch the balance — it produces a document. As it runs it:
open);invoiced so it is never grouped twice; andWho issues the invoice depends on where you run the close. As the platform admin you issue the platform’s invoices — to resellers and direct tenants. A reseller, in their own console, issues invoices to their tenants; their close is hard-scoped to their own accounts, so a reseller can never invoice outside its brand. Invoice numbers are sequential per issuer per calendar year: INV-PLATFORM-2026-000123 for a platform invoice, INV-<reseller>-2026-000045 for a reseller one.
Invoices CloudCX issues to resellers & direct tenants.
| Invoice | Account | Period | Status | Total | Paid |
|---|---|---|---|---|---|
| INV-PLATFORM-2026-000123Acme Comms | reseller | May 2026 | open | $842.16 | $0.00 |
| INV-PLATFORM-2026-000122Globex Direct | tenant | May 2026 | partial | $210.00 | $100.00 |
| INV-PLATFORM-2026-000121Initech Direct | tenant | Apr 2026 | paid | $96.40 | $96.40 |
INV-PLATFORM-2026-…) over the billed account; sequential per issuer per year.open (issued, unpaid), partial (some paid), paid, overdue or void.Clicking Generate invoices opens the period-close dialog. You supply the period (a start and an inclusive end date), an optional set of accounts to restrict the close to, and the due-days offset that sets each invoice’s due date as period_end + due_days (default 14). Leave the account list empty to close every account in scope that has pending charges in the window.
2026-05-01 to 2026-05-31 for the May close).14, or enter your terms; the due date becomes period_end + due_days.open invoice per account that had pending charges, numbers them contiguously, renders each document, and flips the pulled-in charges to invoiced.Idempotency is built in. Because each pulled-in charge is flipped to invoiced, a re-run of the same period finds no pending charges for the already-closed accounts and creates nothing for them. A close over a scope with no pending charges creates no invoices at all (an empty result). You can safely re-run a close if it was interrupted.
An issued invoice is collected one of two ways. Both ultimately write the same kind of ledger row — a negative payment — but they get there differently.
“Pay online” creates a Stripe Checkout Session for the outstanding amount and returns a hosted payment URL. The ledger is credited only when Stripe confirms the payment via the webhook — never on click.
“Record payment” logs a received wire against the invoice immediately. A credit / adjustment posts a goodwill or correcting negative row. Both work with no Stripe at all.
Stripe in CloudCX is per-entity / bring-your-own, mirroring the channel-credential pattern. An invoice collects into its issuer’s Stripe: a platform-issued invoice into the platform’s Stripe, a reseller-issued invoice into that reseller’s Stripe (falling back to the platform’s when the reseller has not brought their own). So a reseller’s tenants pay the reseller, and the platform’s customers pay the platform — into separate Stripe accounts.
You set the platform’s Stripe under Billing → Platform payment configuration. Secret material is write-only: the secret key and webhook signing secret are encrypted at rest (Fernet) and never shown again — the form shows only whether each is set. Leave a secret field blank to keep its current value.
CloudCX’s own Stripe — collects from resellers & direct tenants.
pk_live_…) and the secret key (sk_live_…). The secret is encrypted on save and never displayed again.whsec_…) from your Stripe dashboard’s webhook endpoint. This is what proves an incoming webhook really came from Stripe.With Stripe configured, collecting online is one click:
total − amount_paid) in the invoice currency, tags it with the invoice / account ids, and returns the hosted payment URL.partial or paid on its own; you do not record anything by hand.“Pay online” only initiates collection — it creates the checkout and returns a URL. Money is recorded only when Stripe confirms the payment through the webhook. This is deliberate: it keeps the ledger the single source of truth and means an unpaid (or abandoned) checkout never credits an account. If a payment doesn’t land, the invoice simply stays open — re-issue the link.
Each billing entity has its own webhook endpoint, identified in the path by owner: POST /webhooks/stripe/<owner_type>/<owner_id> (use platform for the platform’s null id). When Stripe calls it on a completed payment, CloudCX:
Stripe-Signature HMAC over the raw body — fail-closed: a missing secret, a bad signature, or a stale timestamp (older than 5 minutes) is rejected with a 400 and nothing is processed.amount_paid / status).# In your Stripe dashboard, add a webhook endpoint targeting: https://cloudcx.app/webhooks/stripe/platform/- # Subscribe to these events: checkout.session.completed payment_intent.succeeded invoice.payment_succeeded # Then paste the endpoint's signing secret (whsec_…) into # Billing → Platform payment configuration → Webhook signing secret.
Because verification is fail-closed, an endpoint with no signing secret stored rejects every webhook with a 400 — so checkouts complete on Stripe’s side but never credit your ledger. If “Pay online” works yet invoices stay open after payment, the webhook secret is the first thing to check.
Manual settlement always works, with or without Stripe. Record payment logs a received wire (or other off-line payment) against an invoice; it credits the ledger and rolls the amount onto the invoice immediately. Credit / adjustment posts a goodwill credit, a correcting adjustment, or a refund against an account or invoice — a negative ledger row that reduces what the party owes.
wire (a received bank transfer) or manual_credit (a keyed credit/opening deposit).wire for a bank transfer, manual_credit for a keyed credit.amount_paid rises and the status becomes partial or, when fully covered, paid (with the paid timestamp stamped). The ledger shows a matching negative payment row.To post a credit / adjustment instead, click Credit / adjustment, choose the type (credit / adjustment / refund), enter the amount and a reason, and target either an account or a specific invoice (exactly one). The result is a negative ledger row that reduces the party’s balance — visible immediately on their statement.
All three write the same negative row, but the type is preserved on the statement so finance can tell them apart. Use credit for goodwill (a service credit you grant), adjustment to correct a billing error, and refund when money actually went back out. The reason text you enter is what appears on the statement, so be specific.
An invoice can be settled partly online and partly by wire — record a part wire, then send the remaining balance to Stripe checkout (or vice-versa). The invoice tracks amount_paid against total regardless of how each slice arrived, flipping to paid only when fully covered.
Let us run one cycle end to end. Acme Comms is a reseller. Its tenant Northwind Retail made outbound calls in May. We will price them on both cards, close the period as the platform, and settle Acme’s wholesale bill by wire. Assume the platform Standard Wholesale 2026 card and Acme’s retail card are both active, with these voice entries:
Northwind’s May usage: 1,000 answered, normally-cleared outbound minutes to Singapore (+65…). (For clarity we use round minutes; the rater bills ceil(billsec/60) per call, so real totals are the sum of per-call rounding.)
As the CDRs land and the rater runs, each Singapore call is matched to the prefix-65 entry on both cards. Two charges are posted per relationship:
| Leg | Payer (account_id) | Counterparty | Computation | Charge |
|---|---|---|---|---|
| Retail | Northwind (tenant) | Acme (reseller) | 1,000 × $0.014 | $14.00 |
| Wholesale | Acme (reseller) | CloudCX (platform) | 1,000 × $0.008 | $8.00 |
The moment these post, the ledger already reflects them: Northwind’s balance rises by $14.00 (it owes Acme) and Acme’s balance rises by $8.00 (it owes CloudCX). No invoice exists yet — the balance moved on rating, not on invoicing. Acme’s margin on this traffic is the difference, $6.00, which the margin report (Billing II) surfaces for you automatically.
On 1 June you run Billing → Invoices → Generate invoices for 2026-05-01 to 2026-05-31, due-days 14, leaving the account list blank. CloudCX groups Acme’s pending wholesale charges (the $8.00 line, plus any other May wholesale) into INV-PLATFORM-2026-000123, status open, total $8.00 (no tax jurisdiction set), due 14 June. The pulled-in charge flips to invoiced. Acme’s balance is unchanged at $8.00 — the invoice only stated what was already there.
Separately, in Acme’s own console, Acme runs their close to issue Northwind’s retail invoice for the $14.00 — issuer Acme, numbered in Acme’s own sequence, collected into Acme’s Stripe.
Acme settles by bank transfer. You open INV-PLATFORM-2026-000123, click Record payment, enter $8.00, method wire, reference the SWIFT ref, and save. The invoice flips to paid (paid-at stamped), and a negative payment row of −$8.00 posts to Acme’s ledger — taking Acme’s balance back to $0.00. The cycle is closed.
Note the middle row: invoicing moved the charge status (pending→invoiced) but not the balance. Only the charge (up) and the payment (down) touched the balance.
In a non-production tenant, run the whole core cycle yourself:
Lab Wholesale (USD, active). Add a catch-all voice entry at 0.030/min (increment 60), and a specific voice entry for prefix 1 (North America) at 0.011/min.+1… number, plus one to an unmatched destination. Wait for the next rating pass.+1 calls are charged at the prefix-1 rate, the others at the catch-all, and the tenant’s balance has risen by the total. Note that no invoice exists yet.open invoice appears, its total equals the charges you saw, and the charges are now invoiced. Re-run the close and confirm no duplicate appears.partial), then either record the rest as a second wire or, if you have a Stripe test key configured, click Pay online and follow the URL. Confirm the invoice reaches paid and the balance returns to $0.00.What you have proven: the rate card prices usage, rating moves the balance, an invoice merely states the charges, and only payments/credits pay the balance down. That is the whole core billing path.
| Symptom | Likely cause | Fix |
|---|---|---|
| A whole class of calls/messages is free | No matching rate-card entry (often a missing catch-all), or the card is not active. | Add the catch-all entry; confirm the card is active. The rater logs the miss once per scope/channel. |
| “Pay online” returns a 409 | The issuer has no enabled Stripe config. | Configure (or enable) Stripe under Platform payment configuration — or settle manually. |
Checkout completes but the invoice stays open | Webhook secret not stored, or the endpoint URL/events are wrong — verification fails closed. | Store the whsec_… secret; point Stripe at /webhooks/stripe/platform/- with the three success events. |
| An invoice total and the balance disagree | By design — invoicing does not move the balance; only charges/payments/credits do. | Read the statement: the balance is the net of all charges and payments, not of one invoice. |
| Re-running a close worried you | It is idempotent — invoiced charges are not re-grouped. | Safe to re-run; no duplicate invoices are created. |
| Edited a rate but yesterday wasn’t re-billed | Charges are immutable; rate edits are forward-only. | Post a credit/adjustment to correct past charges; the new rate applies to future rating. |
external_id; moves the balance when rated.You now have the core path: model → rate cards → invoices → settlement. The next chapter, Billing II, covers the panels you saw lower in the workspace — prepaid & credit control, tax & FX, dunning (reminders, suspend/unsuspend) and the finance reports (revenue, margin per reseller, receivables aging, reconciliation).
Chapter 23 set up the money machine: billing accounts, the platform Stripe configuration, wholesale rate cards and the invoices CloudCX issues. This chapter is about controlling spend and getting paid. You will switch accounts between postpaid and prepaid, set credit limits and low-balance alerts, record top-ups, apply tax by jurisdiction and FX rates for cross-currency settlement, chase overdue invoices with dunning, and read the finance reports — revenue, margin, aging and a three-way reconciliation that proves the ledger is sound.
Everything in this chapter is on the platform view at admin.cloudcx.app. It is one long page; the panels we cover here — Prepaid & credit control, Tax & FX configuration, Dunning & collections and Billing reports — sit below the accounts, Stripe and invoice panels from Chapter 23. Resellers see scoped versions of the same controls for their own book in the reseller portal (§24.7).
Before any screen, internalise one invariant that the whole billing system depends on. The balance ledger is signed in “what the party owes us” terms. A positive balance means the party owes CloudCX money; a negative balance means CloudCX holds their credit (we owe them service). Every figure on every screen in this chapter follows from that one convention.
So a prepaid account that has deposited funds carries a negative balance — that is its stored credit. As the customer makes calls and sends messages, charges raise the balance back toward zero. The spendable wallet is therefore:
# available credit = how much they can still spend available_credit = max(0, -balance) balance = -50 → available_credit = 50 # deposited 50, spent nothing balance = -12 → available_credit = 12 # spent 38 of the 50 balance = 0 → available_credit = 0 # exhausted balance = +3 → available_credit = 0 # slightly overspent; clamped to 0
No screen in this chapter moves the balance directly. Setting a billing mode, a credit limit, a tax rate or an FX rate only stores policy. Recording a top-up posts a payment, and a payment is the only kind of money movement an operator initiates here — it always flows through the accounting service so the journal and the cached balance can never disagree. Reconciliation (§24.6) exists to prove exactly that.
Every billing account is in one of two modes. The default for every account — resellers, tenants, and the platform — is postpaid with no credit limit, which means “never blocked”. You opt an account into tighter control deliberately.
The customer uses the service freely; CloudCX bills the accumulated usage on an invoice and collects afterwards. Optionally cap exposure with a credit limit: once the balance reaches the limit, new billable actions are blocked until they pay down.
The customer deposits funds first (a top-up); usage draws that wallet down. When available credit reaches zero, the next billable action is blocked. A low-balance threshold warns them before they run dry.
| Mode | Credit limit | Behaviour | Blocks usage when… |
|---|---|---|---|
| Postpaid | none | Default. Use freely, billed in arrears. | Never (unconditional allow) |
| Postpaid | set, e.g. 1,000 | Use up to the ceiling, billed in arrears. | balance ≥ credit_limit |
| Prepaid | n/a | Spend deposited credit only. | available_credit < cost (wallet empty) |
Use prepaid for new resellers with no payment history, for high-risk destinations, or wherever you want a hard cap with no collections risk. Use postpaid with a credit limit for trusted partners who need uninterrupted service but a sensible exposure ceiling. Leave well-established, contracted customers on plain postpaid.
The Prepaid & credit control panel is where you set an account’s mode, limit and alert level, and record top-ups. You work one account at a time: pick it from the account selector and the panel fills in with that account’s live wallet view.
Switch mode, set a credit limit & low-balance threshold, and record a top-up.
−€420.00 is credit (they owe us nothing; we hold €420 for them).max(0, −balance)) and the warn level.If you switch an account to prepaid while its balance is zero or positive, its available credit is already zero — so the usage gate (§24.4) will block its next call or message right away. Always confirm the account has been topped up (a negative balance) before, or immediately after, moving it to prepaid. Flipping back to postpaid and clearing any limit restores the default always-allow behaviour.
A top-up deposits credit. Because a deposit is a payment, it lowers the balance (adds available credit). There are two ways to record one:
method: wire | manual_creditStripe CheckoutThe online button only starts a checkout. CloudCX never credits the ledger on a button click — that would let an unpaid session add credit. The existing per-entity Stripe webhook credits the account by its account_id the instant Stripe reports the payment succeeded. If a manual top-up returns “not configured”, the owner has no enabled Stripe; record a manual wire instead.
Credit limits and prepaid wallets are enforced by a single real-time check that sits in front of every billable action — a placed call, a sent SMS or WhatsApp message. The gate answers one question: may this tenant incur a charge of about this cost right now? It reads the balance; it never moves money. Understanding its decision table tells you exactly what each setting on the previous panel does.
balance < credit_limit → allow; balance ≥ credit_limit → block.CHECKavailable_credit ≥ cost → allow; otherwise block (“balance exhausted”).CHECKThe gate is a spend guard, not a security control. Any failure — a missing account, a transient database error — resolves to allow. The worst case is a little overspend the ledger still records and a later invoice still collects; the worst case of failing closed would be a platform-wide calling outage. CloudCX chooses fail-open deliberately.
Because a brand-new account is postpaid with no limit, wiring the gate into the call/SMS/WhatsApp paths changed nothing for the existing fleet. Only two populations are ever blocked: a prepaid account whose wallet is below the action’s cost, and a postpaid account an operator explicitly gave a limit and has now reached.
When the gate blocks, the API surfaces a 402 Payment Required with a reason — prepaid balance exhausted or credit limit reached — and the action is refused before any charge is posted. The Available credit tile and the live can spend flag on the panel show the same verdict the gate uses, so what you see is what callers get.
The low-balance threshold does not block — it warns. A periodic sweep examines prepaid accounts that have an explicit threshold set, and when available credit falls to or below the threshold it emits a prepaid.low_balance event and a best-effort email to the account’s billing address. Accounts with no threshold are skipped entirely, so the sweep is silent until you opt an account in.
Setting a low-balance threshold of 0 means “only warn me once the wallet is fully exhausted” — that is different from leaving the threshold blank (NULL), which opts the account out of the sweep altogether. Use a small positive value (for example €50) so the customer is warned with time to top up.
A new partner, Acme Comms, has no payment history, so you put them on prepaid with a €50 low-balance alert and seed their wallet with a €500 wire they have already sent.
Acme’s billing account currency is EUR. Its balance is €0.00 (brand new, default postpaid). You received a €500 wire, reference WIRE-AC-0418.
€0.00, mode Postpaid, available credit €0.00.500.00, Manual method Wire transfer, Reference WIRE-AC-0418. Click Record manual top-up. The balance moves to −€500.00 and available credit to €500.00.50.00. Click Save credit settings. (Topping up before flipping the mode means service is never interrupted.)−€500.00, Mode Prepaid, Available credit €500.00, Low-balance alert €50.00. The live can spend flag is true.Over the next weeks Acme’s usage draws the wallet down. Here is how the balance and gate verdict track:
| Event | Ledger balance | Available credit | Low-bal alert? | Gate verdict |
|---|---|---|---|---|
| Opening (postpaid) | €0.00 | €0.00 | — | allow (no limit) |
| Wire €500 recorded | −€500.00 | €500.00 | no | allow |
| Switched to prepaid | −€500.00 | €500.00 | no | allow |
| Usage €455 charged | −€45.00 | €45.00 | yes — at/below €50 | allow |
| Usage €45 more | €0.00 | €0.00 | yes | block — exhausted |
| Top-up €300 | −€300.00 | €300.00 | no | allow |
Notice the low-balance alert fired at €45 (at or below the €50 threshold) — while calls were still allowed — giving Acme time to top up. The block only happened at true exhaustion, and the next top-up restored service instantly.
Two rate tables feed the invoicing engine: tax rates (the VAT / GST / sales tax applied to an invoice subtotal, keyed by the billed account’s jurisdiction) and FX rates (admin-set exchange rates used to convert a charge whose currency differs from the invoice currency). Both are idle-safe: with no tax rows every invoice’s tax is zero, and with everything single-currency no FX conversion ever runs.
| Jurisdiction | Name | Rate % | Status | |
|---|---|---|---|---|
| SG | GST | 9.000 | Active | edit · delete |
| GB | VAT | 20.000 | Active | edit · delete |
| US-CA | Sales tax | 7.250 | Active | edit · delete |
| From | To | Rate | Updated | |
|---|---|---|---|---|
| EUR | SGD | 1.452100 | 2026-06-09 | delete |
| USD | SGD | 1.350000 | 2026-06-09 | delete |
SG, GB, US-CA…), upper-cased server-side.9% = 0.09). Range 0–100%.1 from = rate × to. The newest rate on or before a conversion date is the one applied.SG, GB, US-CA…). It is the key the invoicing path matches against the billed account’s jurisdiction.9 for 9%).An account’s jurisdiction and tax-exempt flag live on its tax profile (set on the account’s config — admins per account, resellers per customer). When an invoice is generated, the engine looks up the active tax rate for that account’s jurisdiction and applies it to the subtotal. No jurisdiction set, no matching/active rate, or tax-exempt → tax is zero. Editing or deleting a rate never changes invoices already issued — their tax is already recorded.
FX rates only matter when a charge’s currency differs from the invoice currency — for example a reseller billed in SGD whose platform charges accrued in EUR. The rate is directional: 1 base = rate × quote.
EUR → SGD. They must differ.1.4521 meaning €1 = S$1.4521. The effective date defaults to today.There is an ad-hoc convert preview that runs the exact same engine invoicing uses: give it an amount, a from-currency and a to-currency and it returns the converted amount and the rate applied. A same-currency request returns the amount unchanged at rate 1.0; if no rate is available it returns the amount unchanged and flags converted = false so you know a rate is missing rather than silently mis-billing.
Dunning is the collections lifecycle for postpaid accounts: chasing invoices that have gone past their due date and, ultimately, suspending a delinquent account’s service until it pays. A periodic sweep does the work automatically, but the panel lets you see arrears at a glance and act on any account by hand.
Each sweep does three guarded things, all idle-safe (with nothing overdue it is a pure no-op):
due_date has passed is flipped to overdue. An invoice never moves the balance — only payments do.dunning_level — once per stage, so a daily sweep never spams.Invoices past due. Send a reminder, suspend a delinquent account, or unsuspend on payment.
| Invoice | Account | Due | Days late | Outstanding | Actions |
|---|---|---|---|---|---|
| ININV-2041 | Northwind Ltd suspended | 2026-05-08 | 38 | £1,240.00 | Remind Unsuspend |
| ININV-2055 | Helios SG | 2026-05-29 | 17 | S$880.00 | Remind Suspend |
| ININV-2061 | Acme Comms | 2026-06-06 | 9 | €310.00 | Remind Suspend |
due_date as of today. This drives the escalation stage (7 / 14 / 30).total − amount paid), in the invoice currency.Suspending an account is a blunt instrument: it blocks every outbound call and message for that tenant until you unsuspend. Reserve Suspend for genuine non-payment after reminders have gone unanswered. Manual and automatic dunning share the exact same state transitions, so a manual suspend is indistinguishable from the day-30 auto-suspend — and Unsuspend only re-activates accounts that dunning suspended, never one you closed by hand.
The reports panel is the finance read-out over the ledger. Nothing here moves money — every figure is a scoped aggregate. As a platform admin you see the whole book; a reseller sees only its own account and its tenants’. Four reports, each exportable as CSV: revenue, per-reseller margin, receivables aging, and reconciliation mismatches.
| Reseller | Retail | Wholesale | Margin |
|---|---|---|---|
| ACAcme Comms | €18,400 | €11,900 | €6,500 |
| NWNorthwind | €9,250 | €6,800 | €2,450 |
| Bucket | Current | 1–30 | 31–60 | 61–90 | 90+ |
|---|---|---|---|---|---|
| Total outstanding | €2,100 | €2,050 | €890 | €310 | €250 |
Charges grouped by type/channel (voice, sms, whatsapp, seat, did_rental, setup, one_off) plus the invoiced / paid / outstanding trio. The platform view is wholesale revenue; a reseller’s view is its retail revenue.
Per reseller: retail (its tenants were charged) minus wholesale (the reseller was charged by the platform) = billed margin; the paid-side difference is cash margin. Sorted largest margin first.
Outstanding invoice balances bucketed by overdue age relative to due_date — current / 1–30 / 31–60 / 61–90 / 90+. Per account plus a grand total, as of a chosen date.
A finance audit that returns only the mismatches (§24.9). On a healthy ledger it returns ok with no issues. Admin-only — it scans the whole book.
Each report takes an optional from/to date window (reversed bounds are swapped for you), and accepts ?format=csv (or the explicit .csv button) for a streaming download with an attachment filename. Idle-safe everywhere: no data yields zeroed rollups, an empty aging report, and CSVs containing just their header row.
Retail is what a reseller’s tenants owe the reseller; wholesale is what the reseller owes CloudCX; margin is the reseller’s gross profit between the two. The platform margin report computes this for every reseller in one pass, so finance can see the whole partner book’s profitability at a glance.
Reconciliation is the proof that the money is right. It runs three independent audits that must always hold on a healthy ledger and returns only the discrepancies that exceed a small rounding tolerance (0.01). An empty issues list and ok means everything ties out.
| Check | What it asserts | Catches |
|---|---|---|
| Balance integrity | For each account, the signed journal sums to the cached balance, and the source rows reproduce it: Σ charges − Σ payments − Σ credits ≈ balance. | A source row that never produced its journal row (or vice-versa); a corrupted balance cache. |
| Invoice integrity | Per invoice: total ≈ subtotal + tax; subtotal ≈ the sum of its linked charges; amount_paid ≈ the payments recorded against it. | A mis-totalled invoice; a payment applied to the wrong invoice; a header that drifted from its lines. |
| Stripe integrity | Per account, the sum of succeeded Stripe payments equals the sum of the journal payment rows that back them. | A Stripe webhook credit that recorded a payment but never wrote its ledger row (or wrote a mismatched one). |
Each mismatch names the failed check, the offending row (an account or invoice), the expected and actual figures and their delta, so finance can drill straight to the cause. All money is computed in exact decimal — never floating point — matching the six-decimal ledger columns.
3,481 accounts & 612 invoices checked · tolerance 0.01
| Check | Ref | Expected | Actual | Delta |
|---|---|---|---|---|
| stripe_payments_vs_ledger | acct · Helios SG | S$880.00 | S$0.00 | −880.00 |
| invoice_total_vs_subtotal_plus_tax | INV-2041 | £1,240.00 | £1,239.40 | −0.60 |
The first row says a S$880 Stripe payment was recorded but no backing ledger row exists — a webhook credit that didn’t reach the journal. The second is a 60-cent invoice header drift worth a closer look.
ok=false with one issue: stripe_payments_vs_ledger on the Helios SG account, expected S$880, actual S$0, delta −880.Run reconciliation on a schedule (a month-end close at minimum) and after any incident touching payments — a Stripe outage, a manual ledger edit, a migration. A clean reconciliation is the single best signal that the billing system is trustworthy; catching a mismatch early, before it compounds across invoices, is far cheaper than unwinding it later.
Everything in this chapter has a reseller-scoped counterpart in the reseller portal. A reseller can read and set its own account’s mode, limit and threshold; manage each of its customers’ billing (mode, limit, threshold, tax jurisdiction); record top-ups to its own or a customer’s account; and pull its own revenue, usage, margin and aging reports. The scoping is strict: a reseller can only ever touch its own account and its own tenants — a request for any other party returns 404, so it cannot even probe what exists outside its book. Platform-wide reconciliation stays admin-only.
On a non-production tenant in the admin sandbox, drive an account through its whole lifecycle:
100.00 (method Manual credit, reference TRY-01), then switch the mode to Prepaid with a low-balance threshold of 20.00. Confirm Balance reads −100.00 and Available credit 100.00.TRY-01. Confirm the balance does not change — the reference made it idempotent.SG, name GST, rate 9. Add an FX rate EUR→SGD at 1.45, then use the convert preview to convert €100 to SGD and confirm you get S$145.ok=true with no issues — every top-up, charge and status change you made still reconciles.Success criteria: the wallet reflects exactly one €100 credit (not two), GST appears on a newly generated SG invoice, the overdue invoice escalated, suspend/unsuspend toggled the status cleanly, and reconciliation is green.
You can now control spend two ways — prepaid wallets and postpaid credit limits, both enforced by the fail-open usage gate — record manual and online top-ups, apply tax by jurisdiction and FX for cross-currency settlement, chase overdue accounts through the dunning ladder to suspension and back, read the revenue / margin / aging reports, and prove the ledger with a three-way reconciliation. Chapter 25 moves from money to quality: service levels, SLAs and the analytics that measure them.
This chapter is the platform-administrator’s controls for trust and global configuration: the encrypted credentials vault that holds CloudCX’s shared provider and AI keys, the IP allow/deny firewall in front of the API, the password policy and two-factor authentication, the single sign-on scaffold, the licensing pool, and the platform-wide defaults. These live in the Governance and Platform groups of the admin console. Every screen here is admin-gated, every secret is encrypted at rest, and almost every control is additive — it changes nothing until you deliberately turn it on.
Five admin views make up this chapter, plus one self-service control that lives on your own account. They are deliberately spread across two sidebar groups because they serve two different jobs — connecting the platform to the outside world (Platform) and defending and governing it (Governance).
Everything secret in this chapter — provider tokens, the SSO client secret, your 2FA seed — is encrypted with Fernet (AES-128-CBC + HMAC) before it touches the database. The encryption key itself, BYOND_CREDS_KEY, is the one root secret that lives in the server environment and never in the encrypted store. This split is what makes a database dump useless on its own.
Encrypting a new secret requires BYOND_CREDS_KEY on the server. If it is missing or malformed, the write is rejected with 503 rather than ever storing plaintext. Reading a secret never crashes: a missing/invalid key or undecryptable ciphertext quietly degrades to “not configured,” so a key problem can take a channel idle but can never 500 a live send.
The key is a urlsafe-base64 32-byte Fernet key. Generate it once and set it in the server environment:
# generate the Fernet master key (run once, store in the server env) $ python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" qZ3...redacted...nB8= $ export BYOND_CREDS_KEY=qZ3...redacted...nB8=
Changing BYOND_CREDS_KEY makes every previously-stored secret undecryptable — channels, the SSO secret and every enrolled 2FA seed silently revert to “not configured.” Treat rotation as a re-key project: plan to re-enter every credential and have users re-enrol 2FA afterwards. Keep the key in a secrets manager, never in the repo.
Open . This is where you store CloudCX’s own shared connectivity keys — the SMS, WhatsApp, email and AI credentials “bound to CloudCX” that are used by every tenant whose reseller has not brought its own credentials for that channel. Each provider is a card; cards never show stored values, only whether the provider is Configured and the fields it expects.
CloudCX’s shared connectivity credentials, used for tenants that don’t bring their own. Stored encrypted.
The field list per provider is authoritative on the server — the API validates a write against it (all required fields present and non-empty; unknown fields rejected) and advertises the field names only. The four core channels you will configure most often:
| Provider | Used for | Required fields | Optional |
|---|---|---|---|
| sms | Outbound & 2-way SMS | account_sid, auth_token, from_number | — |
| WhatsApp Business (Meta Cloud) | token, phone_id, verify_token | app_secret | |
| Inbound/omni email (IMAP + SMTP) | imap_host, user, pass, smtp_host | imap_port, smtp_port, from | |
| ai | The shared LLM / AI key | api_key | model |
The card grid also surfaces transactional email (resend), speech-to-text / text-to-speech (stt / tts), a CRM connector (crm), and the social providers (facebook, instagram, twitter). The two WhatsApp/social app_secret / verify_token fields verify inbound webhook signatures — without them, inbound webhooks fail closed. Configure them the same way as the four above.
Saving is an upsert: a write validates and replaces the whole stored row for that provider. Blank fields are dropped, so you can save a partial card to keep existing values; only the recognised, non-empty fields are kept and encrypted.
You want every reseller without its own SMS account to send through CloudCX’s shared number.
Expected result: the SMS card reads Configured ✓; the From number is never displayed again; a test SMS from a non-BYO tenant is delivered from +6531591234.
Two failures are by design and worth recognising on sight:
A required field is empty, or you sent an unexpected field. The card shows exactly which field(s); fix and re-save.
BYOND_CREDS_KEY is unset/invalid on the server. The save is refused; nothing is stored in plaintext. Set the key, then retry.
Remove is idempotent and immediate. Once the row is gone, that channel’s send (or the AI client, or the email poller) degrades to disabled for every tenant relying on the shared credential — resellers who brought their own are unaffected. Remove only when you intend the shared channel to go idle.
These are the fallback credentials. A reseller you have granted bring-your-own (see the reseller entitlements chapter) overrides the shared key per channel. The shared vault is what every other reseller’s tenants use, so keep it current.
Open . The Security view holds four live controls; the first is the password policy — a single, platform-wide rule for the minimum length and character classes a new password must contain. Defaults are deliberately permissive (minimum 8, no class requirements) so that turning the panel on changes nothing until you tighten it.
The policy is a partial update: each Save sends only the fields you changed, and omitted fields keep their stored value. Critically, it applies to new passwords only — it is checked at the user-creation and password-change call sites and never retro-invalidates existing users. Anything not a letter or digit counts as a “symbol”.
min_lengthrequire_upperrequire_lowerrequire_digitrequire_symbol422: “Password does not meet the policy: must contain …” listing every unmet requirement.Because existing passwords are never re-validated, raising the bar only affects the next password set on each account. Pair a stricter policy with a planned password-rotation campaign if you want everyone on the new standard.
The IP access rules panel (also under Governance › Security) is a CIDR allow/deny firewall enforced in front of the whole API by middleware. Each rule is a network in CIDR notation and an action (allow or deny); a matching deny answers 403 before the request reaches any route.
| CIDR | Action | Reason | |
|---|---|---|---|
| 203.0.113.0/24 | Allow | HQ office range | ✕ |
| 198.51.100.7/32 | Deny | Abusive host | ✕ |
403 for any matching IP.The behaviour depends entirely on whether any allow rule exists. This is the single most important thing to understand before you add one:
The moment a single allow rule exists, every IP that does not match an allow rule is denied — including your own browser. Before adding any allow rule, add an allow rule that covers your current public IP (and your office/VPN range). Verify you are still in, then tighten. Deletion also invalidates the cache, so removing a bad rule recovers within the cache window.
Expected result: only the two office ranges reach the API; 203.0.113.99 is denied; every other IP gets 403 Access denied from your IP address.
The enforced IP is the first X-Forwarded-For hop, then X-Real-IP, then the socket peer — the same logic as the rate limiter. CloudCX Edge overwrites those headers at the public edge, so a client cannot spoof them. Rules are cached briefly (about 30 seconds); an add/delete invalidates the cache so changes apply on the next request.
The Two-factor authentication card on the Security page manages 2FA for your own signed-in account — it is self-service, opt-in, and off by default. CloudCX uses standard RFC 6238 TOTP (the six-digit codes from Google Authenticator, Authy, 1Password, etc.). The flow is enroll → activate → backup codes, and the seed is encrypted at rest like every other secret.
Copy your backup codes to a password manager the instant they appear. There is no way to re-display them — if you lose both your authenticator and your codes, an administrator must reset your 2FA out-of-band. Enroll requires BYOND_CREDS_KEY (the seed is encrypted), so a 503 here means the key is unset.
To disable, enter a current TOTP code or an unused backup code and click Disable 2FA. On success the secret and all backup codes are cleared. The status card then reads Not enabled with a fresh Enable 2FA button.
401 mfa_required drives the code prompt at login).2FA only ever adds a factor. A user who never enrols is unaffected, and /auth/token enforces a second factor only when that user has 2FA enabled. The Security page also shows an Email sign-in codes card for one-time login codes by email — the same opt-in philosophy.
The last card on the Security page stores configuration for an external OpenID Connect identity provider (Okta, Entra ID, Google, Auth0, or a generic OIDC IdP). It records three things — the issuer URL, a client ID and a write-only client secret — behind an Enable SSO toggle.
BYOND_CREDS_KEY, else 503.)This panel stores configuration only. The interactive OIDC sign-in (redirect to the IdP, callback, token exchange, ID-token validation, claim-to-user mapping) is not yet implemented — the /sso/login and /sso/callback endpoints deliberately return 501 Not Implemented. Turning on Enable SSO does not change /auth/token and does not create an SSO session. Keep your password / 2FA login working until the flow ships.
Open . This view shows the licensed seat and customer pool the platform has allocated to its resellers, and how much each reseller has consumed. Every number is derived live from real reseller entitlements — the console never fabricates a pool.
| Reseller | Seat cap | Customer cap |
|---|---|---|
| ACAcme Comms | 500 licensed | 40 |
| NVNova Voice | Unlimited | 0 = unlimited |
Every gated feature has a per-count entitlement: an allotment (the licensed cap) plus live usage. An action is blocked when granting it would push usage past the allotment. The catalogue covers seats and customers but also channels, bots, transcripts, QA, reports, MFA, SSO and more. Two conventions matter:
The Licensing view is read-only — it reports the pool. You change a reseller’s seat / customer caps and channel entitlements from Tenancy › Resellers (the entitlements drawer). See the reseller-entitlements chapter for the full procedure.
AI-session, transcript-minute, recording-storage and dialer-port pools are metered at the service layer and are not yet exposed through a platform metering API, so the “Per-feature usage pools” panel notes they will appear with live consumption once that endpoint ships — rather than showing a fabricated number.
Open . Alongside your signed-in identity and a “where to manage what” map, the Settings view holds the singleton Platform defaults — the platform-wide values new tenants inherit, plus the global maintenance switch.
Settings is a singleton with a partial-update save — only the fields you change are written; omitted fields keep their stored value, and a GET returns sensible defaults before any save. The fields:
default_timezonedefault_regionrecording_defaultretention_daysmaintenance_modemaintenance_messageTurning on maintenance mode affects every tenant, not one. Set the maintenance message first so users see something meaningful, schedule a window, and turn it off promptly when the work is done.
BYOND_CREDS_KEY (Fernet) lives in the server environment; every other secret is encrypted with it. Writes need it (503 otherwise); reads fail safe to “not configured”; rotating it is destructive.403; any allow rule switches to default-deny allowlist mode (mind the lock-out); longest-prefix wins, deny beats allow on a tie; fail-open.501 stub.With trust and global configuration under control, the platform is both connected to the outside world and defended against it. The next chapters move into day-two operations — monitoring, audit and the routines that keep all of this healthy.
Everything in the previous chapters tells you how to configure CloudCX. This chapter tells you how to run it — calmly, on a Tuesday afternoon, and equally calmly at three in the morning. You will learn the two probes the platform exposes (/api/v1/health and /api/v1/ready), how to read the dashboard’s Service health panel honestly rather than optimistically, how the automated deploy verifies itself, and a set of step-by-step runbooks for the incidents you are most likely to meet: a degraded readiness check, a stuck deploy, CloudCX Cache pressure, CloudCX Switch dropping calls, and a flood of 429s. We close with a worked incident, a Try-it drill, and an FAQ.
You do not need shell access to most of it — the dashboard and the two probe URLs cover day-to-day monitoring. The runbooks that touch the host (Docker, logs, migrations) assume an operator with SSH access to the CloudCX box and membership of the deploy group. Where a step is host-only it is marked HOST.
CloudCX runs as a small set of containers on a single hardened host, fronted by CloudCX Edge (egress-only — there are no inbound public ports). The API is a CloudCX Core process; it talks to CloudCX DB for durable state and CloudCX Cache for ephemeral state (sessions, rate-limit counters, presence, short-lived tokens). Voice rides a separate plane: a CloudCX SBC SBC at the edge and CloudCX Switch for media, reached over the CloudCX Switch control socket. Knowing which box owns a symptom is half of every diagnosis.
/api/v1, the admin console, web-chat sockets, the voice control socket server, and the background sweeps.api :8000Liveness, then readiness, then dependencies, then the plane that owns the symptom. Voice problems live on the voice plane; login/session problems live in CloudCX Cache; data problems live in CloudCX DB. Do not restart the API to fix a CloudCX DB outage — you will just restart a healthy process.
/health and /readyCloudCX deliberately exposes two probes, not one, because “the process is up” and “the process can do useful work” are different questions. Both are unauthenticated GETs under the API prefix, and both return the same compact JSON shape.
Liveness answers one question: is the API process alive and serving HTTP? It touches no external dependency — no database, no CloudCX Cache. That is intentional: a liveness probe must not fail just because CloudCX DB is briefly slow, or an orchestrator would kill a process that was actually fine and make the outage worse. A 200 here means “the process answered”; nothing more, nothing less.
# Liveness — no dependencies touched $ curl -s https://cloudcx.app/api/v1/health | jq { "status": "ok", "service": "CloudCX CaaS API" }
Readiness answers the harder question: can the API actually reach the things it needs? It runs one trivial probe per dependency — SELECT 1 against CloudCX DB and a PING against CloudCX Cache — and reports each one individually in a checks map. The overall status is "ok" only when every check is "ok"; if any check fails the overall status becomes "degraded" and the failing check carries an error label such as error: ConnectionError.
# Readiness — probes CloudCX DB + CloudCX Cache individually $ curl -s https://cloudcx.app/api/v1/ready | jq { "status": "degraded", "service": "CloudCX CaaS API", "checks": { "byonddb": "ok", "byondcache": "error: ConnectionError" } }
Each dependency check is wrapped so that a failing dependency produces "error: <ExceptionClass>" in the map — the probe still returns HTTP 200 with a body of status: "degraded". So do not alert on the HTTP status of /ready alone; alert on the JSON status field and on individual checks. A monitor that only watches for non-200 will sleep straight through a CloudCX Cache outage.
| Probe | Question it answers | Touches | Use it for |
|---|---|---|---|
/api/v1/health | Is the process alive? | Nothing | Liveness / restart decisions; the deploy gate |
/api/v1/ready | Can it reach its dependencies? | CloudCX DB, CloudCX Cache | Traffic / load-balancer admission; dependency alerting |
jq as shown above so the body is pretty-printed and you can assert on a field (.status or .checks.byondcache)./ready, trust the JSON status and the per-dependency checks — not the HTTP code. On /health, the HTTP code is the signal (a non-200 means the process is not serving).Bookmark both probes in your browser bar. Liveness for “is it up?” and readiness for “is it healthy?” — the pair answers the first two triage questions in under five seconds, before you ever reach for SSH.
When you open the admin console, the home view is your operational glance. Two things on it are about health: the Service health panel (which polls /api/v1/health on a timer) and the sidebar footer badge ( ALL SYSTEMS OPERATIONAL) with the build/region line. Read them together — and read them sceptically.
| Service | State | Detail |
|---|---|---|
| APAPI process | ok | liveness 200 · uptime 6d |
| DBCloudCX DB | ok | SELECT 1 · 4 ms |
| CACloudCX Cache | degraded | error: ConnectionError |
| FSCloudCX Switch (control socket) | ok | reconnect armed |
v4.8.2 · eu-west · build 2026.06). Note the version + region before you raise a ticket.ok / degraded states the probe returns.error: ConnectionError, exactly the per-check label from /ready. This is the row you act on.The sidebar badge is a summary; it is fed by liveness, which is green whenever the process answers. The process can be perfectly alive while CloudCX Cache is down. So the badge being green does not mean the platform is healthy — the Service health panel (and /ready) are the authoritative source. Treat a green badge with a degraded row as “degraded”, not “operational”.
CloudCX ships by git push to main. A webhook listener on the host validates the GitHub HMAC signature, checks the branch, and fires the deploy script. The script is single-flight (it takes a lock so two deploys can never overlap), ERR-trapped, and — the part that matters for operations — it health-gates itself: it polls liveness for up to two minutes and refuses to declare success until the new container answers 200.
The health gate is literally a loop that curls liveness on the local port and breaks as soon as it sees 200; if it never does, the deploy is marked FAILED at stage=health_check and a Telegram alert fires with the last lines of the deploy log. Success is only announced when HEAD actually changed, so a no-op redeploy stays quiet.
# the gate, paraphrased from deploy/auto_deploy.sh STAGE="health_check"; HEALTH="000" for _ in $(seq 1 40); do sleep 3 HEALTH=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8200/api/v1/health) [ "$HEALTH" = "200" ] && break done [ "$HEALTH" = "200" ] || { # FAILED -> notify; false; }
main usually means the webhook never fired — check the listener, not the app.migrate, do not retry blindly — jump to the migration runbook (§ 26.5.4). A half-applied schema must be understood before it is re-run.The pipeline runs alembic upgrade head before compose up -d brings the new container into service, so the schema is current before the new code serves a single request. This ordering is why a failed migration fails the whole deploy rather than silently shipping code that expects a column that does not exist yet.
CloudCX refuses to start at all in a production-like environment if it still carries shipped development secrets — the dev JWT secret, the default CloudCX Switch control socket password, or the changeme database password. The process raises a clear error and exits, which means the deploy gate never sees a 200 and the deploy is correctly marked failed. This is a feature: a misconfigured production box fails loudly at boot instead of running with forgeable tokens.
A brand-new production container that never reaches 200 — with the log showing “Refusing to start in production with insecure development defaults” — is not a crash. It is the config guard. Set strong JWT_SECRET, FS_ESL_PASSWORD and a real database password in the environment, then redeploy. Never “fix” this by flipping ENVIRONMENT away from production.
Each runbook below is a fixed sequence: symptom → confirm → act → verify. Always finish on a verify step — an incident is not closed until a probe agrees with you. Host-only steps are marked HOST.
degradedSymptom. The Service health panel shows a red/amber row, or /ready returns status: "degraded" with one check in error.
byonddb or byondcache) and the exception class after error:.byonddb is the failing check HOST — verify the database container is up and not out of connections or disk: docker compose ps byonddb and docker compose logs --tail=100 byonddb. A full disk or exhausted connection pool is the usual cause.byondcache is the failing check — go to Runbook C (CloudCX Cache pressure). Logins and rate-limiting degrade first; the rate limiter fails open, so traffic still flows.status returns to "ok" and every check reads "ok". Only then mark the incident resolved.Symptom. /api/v1/health times out or returns a non-200; the console fails to load.
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8200/api/v1/health). If local is 200 but public is not, the fault is the edge (tunnel/CloudCX Edge), not the API.docker compose ps api and docker compose logs --tail=200 api. Look for a startup exception, an OOM kill, or the config-guard message from § 26.4.2.dmesg. If the API was OOM-killed, do not just restart it — find what grew (see Runbook C) before it happens again.docker compose up -d api. The background sweeps and the voice control socket server re-arm themselves on boot.The CloudCX stack shares its host with another product. Every container has a mem_limit and binds to loopback for exactly this reason. When you act on the host, stay inside the CloudCX compose project — never touch the host’s own CloudCX Edge/byondcore services or ports 443/22/8100–8104.
Symptom. The byondcache check is in error, or users are being logged out, or presence/live-ops looks stale. CloudCX Cache is capped (maxmemory 200mb, eviction policy allkeys-lru), so under pressure it evicts the least-recently-used keys rather than refusing writes.
docker compose exec byondcache byondcache-cli INFO memory and … INFO stats | grep evicted_keys. Rising evicted_keys with used_memory pinned at the cap confirms LRU pressure.429 storm (that is Runbook D).docker compose up -d byondcache); sessions are rebuilt as users sign in again. If it is merely under sustained pressure, that is a capacity signal — raise maxmemory deliberately, do not just keep restarting.byondcache: ok; a test login succeeds and the session persists across a page reload.429 Too Many RequestsSymptom. Clients (often an integration calling the programmable API) report 429s with a Retry-After header.
POST /auth/token and on public webhooks, a generous global default elsewhere. A 429 means a caller crossed its window.X-Forwarded-For first hop, then X-Real-IP), which CloudCX Edge overwrites at the edge so they cannot be spoofed. Find the offending IP/integration in the logs./auth/token should fix its own retry/back-off. A broad, legitimate increase in traffic is a signal to raise the relevant limit deliberately via configuration.Retry-After. Tell the integration owner the window resets after the seconds in that header; well-behaved clients simply wait and succeed.200 again.Symptom. Calls fail or drop, agents cannot be reached, IVR does not answer — while the API and database are perfectly healthy.
ok but calls fail, the fault is on the voice plane (CloudCX SBC / CloudCX Switch), not the app. Do not restart the API.docker compose ps CloudCX Switch and its logs. control socket listens on loopback (:8021); the public SIP arrives from the CloudCX SBC edge.In production the API logs structured JSON. Operationally useful event names include api.telephony_start_failed, api.voice_socket_start_failed, api.dialer_resume_failed, the billing sweep failures (api.rating_sweep_failed, api.dunning_sweep_failed), and api.insecure_jwt_secret. Grepping the log for the event name is faster than scrolling.
Symptom. A Telegram message reads CLOUDCX DEPLOY FAILED, or no message arrives at all after a push to main.
git_fetch, build, migrate, up, or health_check) and includes the last lines of the deploy log. The stage tells you which runbook applies.main.health_check? The new container built and migrated but never answered 200 within two minutes. Read the API logs (Runbook B) — a config-guard refusal (§ 26.4.2) is the classic cause on a fresh prod box.migrate? A migration errored before the new code went live, so old code is still serving. Inspect the migration, fix forward, and let the next push re-run alembic upgrade head. Never hand-edit the schema to “match” the code./ready.A worked incident, start to finish, using only the tools in this chapter.
Two agents report being logged out mid-shift. The sidebar badge still reads ALL SYSTEMS OPERATIONAL — tempting to dismiss it.
You open /api/v1/ready. status: "degraded", byonddb: ok, byondcache: error: ConnectionError. The Service health panel agrees. The badge was lying.
CloudCX Cache is ephemeral (Runbook C). Sessions and rate-limit windows are affected; no durable data is at risk. The rate limiter is failing open, so traffic still flows.
On the host: INFO memory shows used_memory pinned at the 200 MB cap with evicted_keys climbing. You restart CloudCX Cache; /ready returns to all-ok; a test login persists across a reload. Incident closed; capacity flagged for review.
# 09:15 — confirm and localise $ curl -s https://cloudcx.app/api/v1/ready | jq '.status, .checks' "degraded" { "byonddb": "ok", "byondcache": "error: ConnectionError" } # 09:18 — recover + verify (HOST) $ docker compose exec byondcache byondcache-cli INFO stats | grep evicted_keys evicted_keys:184213 $ docker compose up -d byondcache $ curl -s https://cloudcx.app/api/v1/ready | jq '.status' "ok"
The lesson. The badge said green; readiness said degraded; readiness was right. The whole incident was diagnosed from a browser before anyone touched the host, and the fix was scoped because we knew CloudCX Cache holds ephemeral state. Confirm, scope, act, verify — in that order.
A short routine catches most trouble before a user does. None of it needs the host.
ok at /api/v1/ready.v4.8.2, region, build date).Goal: read both probes correctly and prove you can tell “up” from “healthy”. (Use a non-production environment if you have one.)
status and service. What did this probe not check?status and each entry in checks.checks map? Does the sidebar badge agree — or is it summarising liveness only?status), and would users see 429s? (Answers: liveness stays 200/ok; readiness stays HTTP 200 but JSON status: "degraded" with byondcache: error: …; no 429s — the limiter fails open.)/health) means “the process is up” and touches nothing; readiness (/ready) means “it can reach CloudCX DB and CloudCX Cache”. Restart decisions watch liveness; traffic/dependency decisions watch readiness. Conflating them gets a healthy process killed during a dependency blip./ready? no200 even when degraded — the failure is in the JSON. Alert on the body’s status field and on each entry in checks.429 storm.health_check on a new prod box. config guardchangeme DB password. Set strong values and redeploy — do not weaken ENVIRONMENT./ready is all green. voice plane/ready with failing calls points squarely at the voice plane — not the API. The API self-heals its control socket link on reconnect.You can read both probes, interpret the dashboard without being fooled by a summary badge, trust the self-verifying deploy, and walk a real incident through confirm → scope → act → verify. Keep the two probe URLs bookmarked — they answer the first two questions of every incident before you ever open a terminal.
This final chapter is the manual’s quick-reference shelf — the page you keep open on a second monitor. It collects the things you look up rather than read: every keyboard shortcut in the console, a complete status and enum reference that decodes the coloured pills you see on tenants, invoices, calls and messages, the escalation paths and internal staff portals you reach when something is wrong, the support contacts and severity/response targets, and a master glossary of every term used across the preceding twenty-six chapters. Nothing here is new behaviour — it is the rest of the book, indexed for speed.
Treat §27.3 (status reference) as a lookup table: when a pill on a screen reads overdue or undelivered, find the object here to learn what the value means, what moves it, and what to do next. Each entry carries a cross-reference (e.g. § 23.4) back to the chapter that documents the full workflow. The values shown are the exact strings CloudCX stores in its data model, so they also match what you see in exports, the API and the support portal.
A handful of conventions keep these tables compact and unambiguous:
longest_idle) is what the platform stores and what appears in exports and the API; the human label (“Longest idle”) is what the console renders. Tables show both.a → b means “a normally advances to b”. A | separates terminal alternatives, e.g. delivered | undelivered | failed.§ 8.3, never a page number — printed pagination depends on your browser.From your browser’s print dialog you can usually print a page range. This chapter is designed to stand alone — pin the status reference (§27.3) and the escalation matrix (§27.4) by your desk as a one-page cheat-sheet for the operations team.
The CloudCX admin console is mouse-driven, but a small set of global keys make day-to-day navigation faster. The most important is the universal search — one box at the top of every view that finds resellers, tenants, DIDs and IP addresses — reachable from anywhere with a single chord.
Press ⌘K (Ctrl+K) anywhere to jump to search · Esc closes panels.
| Shortcut | Action | Where it works |
|---|---|---|
| ⌘K / Ctrl+K | Focus the universal search box in the topbar. | Anywhere in the admin console. |
| Esc | Close the open drawer, modal or the mobile navigation overlay. | Admin console (any open panel). |
| Enter | Submit the focused form / confirm the primary action of a dialog. | Forms and modals, console-wide. |
| Tab / Shift+Tab | Move forward / backward through form fields (standard browser focus order). | All forms. |
| Enter (dial field) | Place a call from the softphone’s number field. | Agent Desktop softphone (§17, §12). |
| Enter (composer) | Send the chat / message; Shift+Enter inserts a newline. | Agent inbox composer (§16, §17). |
| 0–9 # * | Send DTMF tones while a call is connected (dialpad keys). | Agent softphone, in-call (§12). |
CloudCX intentionally avoids a large hidden hot-key surface so that browser and screen-reader shortcuts keep working predictably. The two you need are ⌘K/Ctrl+K to search and Esc to back out. Everything else is a normal, focus-ordered web form — Tab and Enter behave exactly as they do anywhere else.
Almost every object in CloudCX carries a status — a small controlled value that drives the coloured pill on the screen, the available actions, and the way usage is rated and reported. This section is the authoritative decoder for those values. They are grouped by area; each table lists the stored value, its meaning, and where it sits in the object’s lifecycle.
| Object | Value | Meaning & lifecycle |
|---|---|---|
| User status §4, §7 | active active | The account can sign in and is counted against licence seats. |
| inactive inactive | Disabled by an administrator; cannot sign in, frees the seat. Reversible. | |
| locked locked | Locked after failed sign-ins or by an admin. Requires an unlock (§7.6) before sign-in resumes. | |
| User type / role §4.2 | admin | Platform administrator — full control plane access (this manual’s audience). |
| reseller | White-label partner operator; scoped to their own tenants and book (§5, reseller manual). | |
| tenant | Customer-side administrator for one tenant. | |
| subtenant | Administrator of a tenant’s sub-organisation (departmental split). | |
| tl | Team leader / supervisor — live monitoring and quality (§18, §19). | |
| agent | Front-line agent — the Agent Desktop only (§17). | |
| IP rule action §25 | allow allow | Requests from the matching CIDR are explicitly permitted. |
| deny deny | Requests from the matching CIDR are blocked at the edge. | |
| Agent presence §17, §18 | available available | Ready · routing on. Eligible for new interactions from any queue the agent staffs. |
| on break break | Paused · no new work routed; existing interactions continue. | |
| wrap-up acw | After-call work — finishing notes/disposition before becoming available again. | |
| offline offline | Logged out of the queue; not routable. |
A user’s status (active/inactive/locked) is a persistent property of the account record — it governs whether they may sign in at all. An agent’s presence (available/break/acw/offline) is live, in-memory routing state that changes minute to minute. A perfectly active agent can still be offline, and the wallboard shows presence, not status.
| Object | Value | Meaning |
|---|---|---|
| Queue strategy §10.3 | longest_idle | Default. Offer to the available member who has been idle the longest (fairest workload spread). |
| round_robin | Cycle through eligible members in order. | |
| fewest_calls | Offer to the member who has handled the fewest interactions so far. | |
| priority | Offer by the member’s configured priority/skill weighting first. | |
| Conversation status §13, §16 | open open | A live thread on a digital channel, not yet claimed by an agent. Lifecycle: open → assigned → closed. |
| assigned assigned | Claimed by / routed to a specific agent who is handling it. | |
| closed closed | Resolved and archived; reopening starts a fresh thread. | |
| Message direction §14, §16 | inbound | A turn from the visitor / customer. |
| outbound | A turn from the agent or the system. | |
| Channel §12–§16 | voice · webchat · whatsapp · sms · email · social · any | |
| Object | Value | Meaning |
|---|---|---|
| CDR direction §12.5 | inbound / outbound | Whether the call arrived at or left the platform. billsec (billable seconds) and duration_sec distinguish answered time from total time. |
| hangup_cause | The SIP/CloudCX Switch release reason (e.g. NORMAL_CLEARING, USER_BUSY, NO_ANSWER, CALL_REJECTED). A zero billsec with NO_ANSWER is an unanswered call. | |
| Campaign status §11.2 | draft draft | Being built; the dialer has not started. Lifecycle: draft → running → paused/completed. |
| running running | The dialer is actively placing calls to pending contacts. | |
| paused paused | Temporarily halted; resumable. completed when the list is exhausted. | |
| Campaign contact §11.3 | pending pending | Not yet dialled. |
| dialing dialing | A call is being placed to this contact right now. | |
| connected connected | Answered and bridged to an agent / flow. | |
| no_answer no_answer | Rang with no answer; eligible for retry per policy. | |
| busy busy | Engaged tone; eligible for retry. | |
| failed failed | Could not be completed (bad number, congestion, blocked). | |
| DID number §8.1 | available available | In inventory, ready to be assigned to a tenant / flow. |
| local | tollfree | mobile | The number_type — drives presentation and, with the rate card, price. | |
| DNC scope §11.5 | call · sms · whatsapp · both | What an entry on the Do-Not-Contact list suppresses for a number. |
The SMS delivery lifecycle is the most detailed status path in the platform, because a message’s fate is reported asynchronously by the carrier via a delivery receipt (DLR). Read it left to right:
| Object | Value | Meaning |
|---|---|---|
| SMS message §14.2 | queued queued | Accepted by CloudCX, awaiting submission to a carrier route. |
| submitted submitted | Handed off to the carrier / SMSC; submitted_at is stamped. | |
| sent sent | Left the SMSC toward the handset; awaiting the final DLR. | |
| delivered delivered | Terminal success. The DLR confirms handset receipt; delivered_at is stamped. | |
| undelivered undelivered | Terminal. Carrier accepted but could not deliver; see error_code / error_detail. | |
| failed failed | Terminal. No carrier accepted it (no route, blocked, bad number). | |
| received received | An inbound (MO) message landed on one of your numbers. | |
| SMS encoding §14.2 | GSM7 | Standard 7-bit alphabet; 160 chars per segment. |
| UCS2 | Unicode (emoji / non-Latin); 70 chars per segment. Drives segments and therefore price. | |
| Sender ID §14.3 | pending pending | Registration submitted, awaiting carrier/regulatory approval. Lifecycle: pending → approved | rejected. |
| approved approved | Cleared for use; only an approved sender may be presented on the send path. | |
| rejected rejected | Declined; see notes. Kinds: alphanumeric · longcode · shortcode. | |
| SMS campaign §14.4 | draft draft | Being composed. Lifecycle: draft → scheduled → sending → completed (or cancelled). |
| scheduled scheduled | Queued to start at scheduled_at. | |
| sending sending | The runner is paging through recipients now. | |
| completed completed | All recipients processed; sent/delivered/failed counters final. | |
| cancelled cancelled | Stopped before completion by an operator. | |
| Survey invite §20 | pending pending | Issued, not yet answered. Kinds: csat · nps · custom. |
| completed completed | Respondent submitted; the score is recorded. |
A common support confusion: a message stuck at sent has left the carrier but no positive DLR has arrived. That is normal for minutes, and some carriers never return a DLR at all. Only delivered is a confirmed handset receipt; undelivered/failed carry the reason in error_code. Do not bill or refund on sent alone — wait for the terminal state.
| Object | Value | Meaning |
|---|---|---|
| Billing account §23.1 | active active | Normal trading. mode is postpaid (invoice then collect) or prepaid (collect then spend). |
| suspended suspended | Service blocked — usually by dunning for non-payment, or manually. Reversible on payment (§24.4). | |
| closed closed | Account terminated; no further charges. | |
| Journal entry §23.1 | charge | Priced usage — positive, increases what the party owes. |
| invoice | A grouping document — does not move the balance. | |
| payment | Money received — negative, decreases what they owe. | |
| credit | A goodwill / promotional credit — negative. | |
| adjustment | A manual correction — signed either way. | |
| refund | Money returned — negative. | |
| Charge status §23.3 | pending pending | Rated but not yet on an invoice. Lifecycle: pending → invoiced (or void). |
| invoiced invoiced | Pulled into a numbered invoice; will not be re-grouped. | |
| void void | Cancelled before invoicing; excluded from totals. | |
| Invoice status §23.4, §24.4 | draft draft | Built but not issued; still editable. |
| open open | Issued and awaiting payment. | |
| partial partial | Part paid — amount_paid is below the total. | |
| paid paid | Terminal. Settled in full; paid_at stamped. | |
| overdue overdue | Past its due date and unpaid — the dunning workflow acts on this (§24.4). | |
| void void | Cancelled; its charges return to pending or are written off. | |
| Payment status §23.5 | pending pending | Initiated online, awaiting the gateway result. |
| succeeded succeeded | Confirmed (the credit is posted only on the verified Stripe webhook). | |
| failed failed | Declined / errored; no credit posted. | |
| refunded refunded | Reversed back to the payer. | |
| Number charge §23.2 | active active | A rented number billed monthly. |
| released released | No longer charged from the release date. |
A reseller forwards a screenshot of one of their tenants and says “something looks wrong, can you check?” Using only this reference you can read the whole situation in seconds. Here is the row they sent:
| Object | State | Detail |
|---|---|---|
| ARBilling account | suspended | mode: postpaid |
| Latest invoice INV-2026-0414 | overdue | due 14 days ago |
| Outbound campaign Spring promo | paused | auto-paused |
| Last SMS blast | sending | 1,204 / 5,000 |
| Sender ID ACME | approved | alphanumeric |
Reading it with the tables above:
suspended, the invoice is overdue. Table 27.6 tells you these two facts are causally linked: an overdue invoice triggers dunning, which suspends the account (§24.4). This is not a fault — it is the credit-control system working as designed.paused, the blast is mid-sending. A suspended account blocks new chargeable work, so the dialer auto-paused; the in-flight blast is allowed to drain. Neither is broken.approved. Messaging capability itself is fine — this rules out a registration problem.Before escalating anything, decode every pill on the screen first. The vast majority of “it’s broken” reports are a perfectly healthy system in a state the reporter did not recognise — a suspended-for-non-payment account, a sent-not-yet-delivered message, or an offline agent. The status reference turns those into a one-line answer.
When a status really does indicate trouble, CloudCX separates the work across two internal, staff-only surfaces that sit alongside the customer-facing console. Knowing which one to reach for — and the guardrails on each — is the difference between a five-minute fix and a self-inflicted outage.
Tier-1/2 support agents. Look up any customer, see their 360 (status, entitlements, balance, unpaid invoices, recent CDRs and threads, SIP registration), work tickets, and take bounded, audited, reason-required account actions (suspend/reactivate, password-reset email, toggle a feature, regenerate an invoice, record a manual payment/credit, requeue a stuck message). No impersonation, no raw secret reveal.
Operations / NOC engineers. Live platform and carrier health, a live severity-classified event stream (the “terminal”), an AI explain-only assistant for logs, and tightly-controlled engine restarts (CloudCX Switch, CloudCX SBC, CloudCX Media, SMPP) behind heavy guardrails. Reserved for the ops / admin staff roles.
Both portals have their own staff authentication (separate from customer and admin logins), three staff roles — support, ops and admin — and they audit every state-changing action (who, what, why, before/after, IP, timestamp) with a real-time alert to admins. PII is scrubbed in all staff views; secrets are never returned.
ops / admin staff roles.reload/reloadxml) drop no calls.Engine control is the one capability that can drop live traffic. Always prefer the graceful operation — CloudCX SBC reload or CloudCX Switch reloadxml drop zero calls — over a full restart. A full restart shows the live call/registration count, requires a typed reason of at least 10 characters and an explicit confirmation, is rate-limited and fully audited with a real-time admin alert, and in production may require a maintenance window and two-person approval. Restarts are ops/admin only; there is no free-form shell — only a fixed allow-list of vetted commands.
Use this matrix to route a problem to the right surface and role on the first attempt. The principle: customer-shaped problems go to Support; infrastructure-shaped problems go to Ops; and you reach for the engine controls only when health confirms a node is actually down.
| Symptom / signal | Surface & role | First action |
|---|---|---|
| One tenant suspended; an invoice is overdue (§27.3.5) | Support support | Record payment / post a credit → account reactivates. No escalation. |
| One agent “can’t take calls” | Support support | Check presence (offline?) and SIP registration in the 360 before anything else. |
A customer’s message stuck at sent | Support support | Explain the DLR lag; requeue only if the carrier confirms loss. Watch for the terminal state. |
One SMPP bind unbound for a carrier | Ops ops | From Engine control, rebind that gateway; watch the bind return on the health tile. |
| Trunk ASR/ACD collapses on one carrier | Ops ops | Use the carrier NOC view + AI explain; fail traffic over via LCR override. |
| CloudCX Switch/CloudCX SBC node down — calls dropping platform-wide | Ops ops/admin | Graceful first (reload/reloadxml); full restart only with reason + confirm + impact ack. |
| Suspected security event (IP, auth, exfiltration) | Admin + Ops admin | Review Security (§25), tighten IP rules, rotate credentials; admins are alerted automatically. |
| Billing/rating loop erroring in the stream | Ops → Engineering | Flag-to-TODO from the stream; do not restart the API blindly — engineering owns the loop. |
At 14:02 a reseller reports that calls on a Singapore route are dropping. Walk the escalation cleanly:
NORMAL_TEMPORARY_FAILURE codes. The platform is healthy; one carrier is degraded.reload first, and restart last — with the live-call warning acknowledged.The whole point of the health dashboard and event stream is to tell you where the problem is before you touch anything. A carrier problem is fixed with an LCR override; an engine problem is fixed graceful-first. Restarting an engine to fix a carrier issue would drop live calls and fix nothing.
When an issue exceeds what the portals can resolve — or you need a human at CloudCX — use the channels below. Classify the incident by severity first; the severity sets both the channel and the expected response. Severities mirror the event-stream classification you saw in §27.4.
| Severity | Definition | Channel | Target first response |
|---|---|---|---|
| Sev-1 | Critical. Platform-wide outage — calls/messages failing across many tenants, or a confirmed security breach. | 24×7 emergency hotline + Ops on-call page | Immediate (minutes) |
| Sev-2 | Major. One major function or one carrier degraded; significant subset of customers affected. | Priority support queue / ops ticket | Within the hour |
| Sev-3 | Minor. A single tenant or feature impaired with a workaround available. | Standard support portal ticket | Same business day |
| Sev-4 | Request. Question, configuration help or enhancement request. | Support portal / email | Next business day |
Every escalation should carry: the affected entity (reseller / tenant / number — never paste full PII or secrets), the object and status you observed (e.g. “invoice overdue”, “SMPP bind unbound”), the time window, the relevant IDs (call UUID, message id, invoice number), and what you have already tried. A well-formed report with IDs is resolved far faster than “it’s broken”.
A suspected breach (credential leak, anomalous IP, data exfiltration) is always Sev-1 regardless of customer impact, and goes to [email protected] plus the Ops on-call page. Do not attempt remediation that could destroy evidence (e.g. mass-deleting logs). Tighten IP rules and rotate the affected credentials (§25), preserve the audit trail, and let the on-call lead coordinate.
Goal: turn these appendices into reflexes. In your sandbox / training tenant:
Success looks like: any new operator can decode an unfamiliar screen and route an incident correctly without asking, using only this chapter.
Every term used across the manual, defined once. Acronyms are shown beside the term. Where a term has a dedicated chapter, the section number points to it.
pending → invoiced (§23).203.0.113.0/24) used by IP allow/deny rules (§25).delivered/undelivered (§14).NORMAL_CLEARING, NO_ANSWER (§12).received) and Mobile-Terminated (outbound) SMS (§14).available / break / acw / offline (§17, §18).approved (§14).bound or unbound (§14, §27.4).That completes the CloudCX Administrator & Training Manual. You now have the full path — from signing in (§3), through tenancy, voice, every channel, supervision, AI, contacts and billing, to the operations and reference material in this chapter. Keep §27.3 (status reference) and §27.4 (escalation) within reach: between them they answer the great majority of day-to-day operational questions. Welcome to running CloudCX.