Guide
Plans and self-hosting
There are four plans, and the line between them is not how much you get — it is whose infrastructure it runs on. Self-Hosted is free because the node, the mailboxes and the model key are yours; the software is the same software. The three paid tiers are that same software with those parts run, warmed and paid for by us.
This guide reads a workspace’s entitlements, moves onto the free plan, brings a node up and registers it through the handshake that makes the free tier possible, and goes through the five places any of this is enforced. Everything below came from the running service on 2026-08-13, refusals included — unless the block says otherwise, which happens five times, each with the reason attached.
Before you start
Every call below needs a credential. A JWT works everywhere (Authorization: Bearer <jwt>), and a workspace API key (X-API-Key: xc_<env>_...) works on the same routes. The one exception is the Stripe webhook, which takes no credential at all — the signature is the authentication.
TOKEN=$(curl -s -X POST https://api.vxcloud.io/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"you","password":"…"}' \
| python -c 'import sys,json; print(json.load(sys.stdin)["access"])')
export API=https://api.vxcloud.io/api/v1/salesshiftaccess, not access_token.The four plans
Sending costs IP reputation and relay capacity. Prospect reveals cost data. Models cost tokens. A node costs a VM. Those four things are the entire price of the product, so a workspace that brings its own node, its own mailboxes and its own model key consumes none of them — which is why it can have the software for nothing without anyone subsidising anything.
| Plan | Price | We run | You run |
|---|---|---|---|
Self-Hostedself_hosted | $0 | The control plane, the record, the shared prospect pool | The node, the mailboxes, the model key |
Starterstarter | $105 / user / month | Compute, sending and AI | Nothing |
Professionalprofessional | $238 / user / month | Compute, sending and AI | Nothing |
Organizationorganization | $357 / user / month | Compute, sending and AI | Nothing |
GET /billing/plans. The catalogue lives in code rather than only in the database, because these numbers are stated to the public and a value editable by a stray UPDATE with nothing to reconcile it against is a liability.| Per month | Self-Hosted | Starter | Professional | Organization |
|---|---|---|---|---|
| Prospect reveals | 5,000 | 12,000 / seat | 40,000 / seat | 100,000 / seat |
| Emails through our relay | not ours — you send | 50,000 / seat | 100,000 / seat | unlimited |
| AI calls on our key | not ours — your key | 1,000 / seat | 5,000 / seat | 20,000 / seat |
| Sending identities † | 2 | 3 | 10 | Unlimited |
| Stored contacts | Unlimited | Unlimited | Unlimited | Unlimited |
| Users | 2 | Unlimited | Unlimited | Unlimited |
GET /billing/plans on 2026-08-13. Paid quotas are per seat; Self-Hosted is a flat workspace allowance. † The sending-identity figure is published and reported back in allowance.mailboxes, and nothing checks it when you connect a mailbox — there is no guard for it in the enforcement layer. The ceilings that will actually refuse a write are the five listed under What is enforced. It is stated rather than quietly dropped because a number sitting in a table next to two that do bite reads as if it bites too.Per seat and pooled, or a flat workspace allowance
On a paid plan the published quota is per seat, and the workspace allowance is that number multiplied by the seats you bought, pooled — one busy user can spend what a quiet one did not. Self-Hosted has no seats to multiply, so its numbers are the workspace total as printed.
curl -s "$API/leads/quota" -H "Authorization: Bearer $TOKEN"
{"success":true,"data":{"used":27,"allowance":2500000,"unlimited":false,
"remaining":2499973,"display":"27 / 2500000"}}625,000 is 25,000 reveals per seat × 25 seats. The plan sets that number; a per-organisation override in ss_org_settings.lead_reveal_quota can only make it smaller. That ordering is deliberate — support sets an override to contain a workspace that is abusing the pool, and buying a bigger plan must not quietly lift a limit somebody applied for a reason.
The minimum to send your first email on the free tier
Three steps, and a node is not one of them. This is written out because the rest of this page spends a lot of words on the node, and it would be easy to read that as a prerequisite for everything. It is not one for sending.
- 1
Be on the plan — which you already are
A workspace with no subscription row resolves to Self-Hosted, so there is nothing to do here on a new account.
POST /billing/activatewrites the row explicitly, which is worth doing before you register a node (see Activate) but changes nothing about what you may send. - 2
Connect a mailbox
OAuth (Microsoft 365, Google Workspace) or IMAP/SMTP credentials, under Settings → Integrations. The procedure is identical on every plan and is written up in Sending email. This is the step that matters: it is what the free tier replaces our relay with.
- 3
Send
POST /email/send, or start a sequence. Mail leaves your mailbox over your IP. With no node registered the SMTP conversation happens from our API process using the credentials you stored, and the tracking row recordsprovider: "smtp".
Read your own entitlements
This is the endpoint that answers “why did that return 402”. It is separate from /billing/subscriptionbecause the application asks this question far more often than it asks about money — every screen that hides a managed-sending control needs it — and the answer costs two indexed reads with no Stripe call anywhere near it.
/billing/entitlementswhat this workspace may do, right nowcurl -s "$API/billing/entitlements" -H "Authorization: Bearer $TOKEN"vxcli salesshift billing entitlements
# the raw payload, unchanged, for scripting
vxcli salesshift billing entitlements --output jsonimport vxsdk
c = vxsdk.Client.load_from_vxcli()
c.authenticate()
ent = c.salesshift.billing_entitlements()
# Read the flag, not the number.
if not ent["managed"]["sending"]:
print(ent["plan_name"], "sends through your own mailbox")
# None means unlimited. 0 means zero.
print(ent["allowance"]["reveals"], "reveals across", ent["seats"], "seat(s)")import { VxCloud } from '@vxcloud/sdk';
const c = new VxCloud({ apiKey: process.env.VX_API_KEY! });
const ent = await c.salesshiftBilling.entitlements();
if (!ent.managed.sending) {
console.log(`${ent.planName} sends through your own mailbox`);
}
// null is unlimited; the wire is snake_case, this module is camelCase.
console.log(ent.allowance.reveals, 'reveals across', ent.seats, 'seat(s)');c, err := vxsdk.LoadFromVxcli(ctx)
if err != nil {
return err
}
ss := c.SalesShift()
ent, err := ss.GetEntitlements(ctx)
if err != nil {
return err
}
if !ent.Managed.Sending {
log.Printf("%s sends through your own mailbox", ent.PlanName)
}
// Allowance fields are *int: a nil pointer is unlimited, not zero.
if ent.Allowance.Reveals != nil {
log.Printf("%d reveals across %d seat(s)", *ent.Allowance.Reveals, ent.Seats)
}{
"plan_code": "organization",
"plan_name": "Organization",
"status": "active",
"source": "comp",
"seats": 25,
"is_free": false,
"managed": { "compute": true, "sending": true, "ai": true },
"allowance": {
"emails": 12500000,
"reveals": 2500000,
"ai": 500000,
"mailboxes": null,
"contacts": null,
"users": null
},
"self_hosted": {
"required": false,
"node_host": null,
"verified_at": null,
"ready": true
}
}| Field | What it means |
|---|---|
plan_code | The plan in force. A workspace with no subscription row, or with a lapsed one, resolves to self_hosted— never to “no plan”. |
status | The subscription’s own status. active, trialing and past_due all still carry the plan — Stripe retries a failed card for days and cutting someone off on the first retry loses them over an expired card. Anything else and plan_code has already fallen back to the free tier while status still reports what happened. none means there has never been a row. |
source | How the plan was acquired: stripe bought, comp granted by VxCloud, free self-served through /activate. |
seats | Billed seats, and the multiplier for every per-seat quota. Always 1 on the free tier. |
managed | The load-bearing object. compute— do we run the pool engine, the crawlers and the agents. sending — may this workspace use our relay. ai— may it use our model keys. Everything the product refuses, it refuses because one of these is false. |
allowance | Workspace totals per month, already multiplied by seats. null is unlimited. 0 on emails and aimeans “not ours to meter”, not “none”. |
self_hosted | required is the inverse of managed.compute. node_host and verified_at are the node you registered. readyis false only when a plan needs a node and none has been registered — the one flag worth gating a sending, scraping or agent screen on. |
The same workspace, through the CLI, which spells out who runs what:
Organization ACTIVE
· organization · source comp · 25 seat(s)
Who runs what
✓ compute SalesShift runs the prospect pool, the crawlers and the agents
✓ sending SalesShift runs the outbound mail path
✓ ai SalesShift runs the model and the key it bills to
Allowance — workspace total per month, null is unlimited
· reveals 2500000
· emails 12500000
· ai 500000
· mailboxes unlimited
· contacts unlimited
· users unlimitedActivate Self-Hosted
No card, no Stripe, no email. A $0 recurring Price is a real object Stripe will happily create, and once one exists every subscription report has a line item that means nothing — so the free plan never reaches Stripe at all, and this is the only route onto it.
/billing/activatefree plans onlycurl -s -X POST "$API/billing/activate" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"plan_code":"self_hosted"}'# --plan defaults to self_hosted
vxcli salesshift billing activatesub = c.salesshift.billing_activate_plan() # defaults to "self_hosted"
print(sub["subscription"]["entitlements"]["plan_name"])const sub = await c.salesshiftBilling.activatePlan('self_hosted');
// The re-resolved entitlements ride along on the subscription.
console.log(sub.plan?.name, sub.status, sub.entitlements?.managed.sending);sub, err := ss.ActivatePlan(ctx, "self_hosted")
if err != nil {
return err
}
if sub.Entitlements != nil {
log.Printf("now on %s — managed sending: %v",
sub.Entitlements.PlanName, sub.Entitlements.Managed.Sending)
}It answers {"subscription": …} with the freshly resolved entitlements nested inside, so one call tells you both what you are on and what you may now do.
Two refusals are built into it, and both are on purpose.
- A paid code is refused, not granted. The route only ever activates a plan flagged
is_free, so posting{"plan_code":"organization"}cannot become a way to hand yourself a paid tier. It answers400 Organization is a paid plan — use checkout. - It will not downgrade a live plan, bought or granted. An earlier version tested only for
source == "stripe"and a single POST silently dropped a comped workspace from Organization to 5,000 reveals a month. Losing a plan by accident hurts the same whether you paid cash for it or were given it, so the test is on the plan, not on how it was acquired.
curl -s -o - -w '\nHTTP:%{http_code}\n' -X POST "$API/billing/activate" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"plan_code":"self_hosted"}'
{"detail":"This workspace is on Organization, granted by VxCloud. Contact support to move it to Self-Hosted."}
HTTP:409In the app this is the button on Billing that says Activate rather than Choose plan: a free plan is activated, not bought, so it posts somewhere else. GET /billing/plans marks that for you with is_activatable and is_purchasable— a plan can be neither, on a deployment with no Stripe key configured.
One reason to call it even when you do not have to: it creates the subscription row. Entitlements resolve without one, so nothing about what you may do depends on it — but node_verified_at and node_fingerprint are columns on that row, and registering a node without it records the host and silently drops those two.
Bring up a node and register it
Your node is where work you want to run yourself runs: the Go email worker, scrapers, agents. Registering one is what moves the outbound send onto it — the tracking row flips from provider: "smtp" to provider: "node-smtp" — and it is what makes self_hosted.ready true.
- 1
Ask for your workspace id and the install facts
GET/billing/self-hostedcurl -s "$API/billing/self-hosted" -H "Authorization: Bearer $TOKEN"vxcli salesshift billing nodest = c.salesshift.billing_self_hosted() print("set TENANT_ID=", st["install"]["tenant_id"])const st = await c.salesshiftBilling.selfHosted(); console.log('set TENANT_ID=', st.install.tenantId);st, err := ss.GetSelfHosted(ctx) if err != nil { return err } log.Printf("set TENANT_ID=%s", st.Install.TenantID){ "required": false, "host": null, "verified_at": null, "fingerprint": null, "live": null, "install": { "tenant_id": "92efb9b0-6785-468a-80d7-ceb5df400168", "accepts": ["92efb9b0-6785-468a-80d7-ceb5df400168"], "image": "ghcr.io/vxcloud/vxnode:latest", "health_path": "/health" } }Executed 2026-08-13 against the running service. install.acceptsis every value your node is allowed to report as itstenant_id. The workspace UUID is always in there. The workspace nameis in there too, but only when that name is unique across every organisation — and it is not, for this one, which is why the list above has a single entry. See the refusals.liveisnulluntil a node is registered. Afterwards it carries a real probe —reachable,version,tenant_name,time— and reports{"reachable": false, "error": …}rather than failing the request when the node is down. A node that is down is still the registered node, and this screen has to load either way. - 2
Run the node with that id in its environment
docker run -d --name vxnode --restart unless-stopped \ -p 8743:8743 \ -e TENANT_ID=92efb9b0-6785-468a-80d7-ceb5df400168 \ ghcr.io/vxcloud/vxnode:latestSame command the in-app screen builds, with your own install.tenant_idsubstituted. Setting that environment variable requires shell access to the machine — which is the entire security argument below.Confirm the node agrees about who it belongs to before you register it. This is the exact request our control plane is about to make:
curl -s https://node1.vxcloud.io/health {"message":"👋 Hello from vxnode — auto-update is live!","status":"ok", "tenant_id":"joelwembo","tenant_name":"joelwembo", "time":"2026-08-12T19:52:39Z","version":"unknown"}Executed 2026-08-13 against the running service. Note what this one reports: the workspace name, not the id. That is enough only when the name is unique — it is not, here. - 3
Register it
POST/billing/self-hosted/nodeprobes the node before writing anythingcurl -s -X POST "$API/billing/self-hosted/node" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"host":"https://node.example.com"}'vxcli salesshift billing node register https://node.example.comreg = c.salesshift.billing_register_node("https://node.example.com") print(reg["verified"], reg["version"], reg["tenant_name"])const reg = await c.salesshiftBilling.registerNode('https://node.example.com'); console.log(reg.verified, reg.version, reg.tenantName);reg, err := ss.RegisterNode(ctx, "https://node.example.com") if err != nil { return err } log.Printf("verified=%v version=%s", reg.Verified, reg.Version)A bare hostname is normalised to
https://and a trailing slash is stripped, sonode.example.comandhttps://node.example.com/are the same request.On success the response carries
host,verified: true, the node’sversionandtenant_name, and the re-resolvedentitlements. Three things are written: the workspace’stenant_primary_node_host— the same column the sequence dispatcher and the pool client already read, so nothing needs to be told twice — plusnode_verified_atand anode_fingerprintof<claimed-id>@<version>.
Why there is a handshake at all
A hostname in a form is a claim, and there are two attacks that follow from believing one. Point us at somebody else’s node and their traffic starts flowing through a workspace you control. Point us at oursand you have managed sending on a free plan — the one thing the free tier is defined by not including.
So the address is not taken on trust: the server calls GET {host}/health and requires the node to report a tenant_id that belongs to the workspace making the claim. vxnode answers with the TENANT_ID from its own environment, and setting that needs shell access to the machine. It is a weaker statement than a signed attestation and a much stronger one than a text field, and it closes both of those doors.
What stays on our side
“Self-hosted” means the work moves, not the product. If you are evaluating this as a data-residency or credential-custody question, the boundary is worth having in one place.
| What | Where it lives | Why |
|---|---|---|
| The control plane and the database | Ours | Contacts, companies, deals, sequence state, tracking rows and the billing record are all in VxCloud’s database on every plan, including this one. The node runs work; it does not store the CRM, and detaching it deletes nothing. |
| Your mailbox and model credentials | Our Vault | Stored under workspaces/{org}/{user}/integrations/*and read at send time. The row in the database holds metadata only — the secret itself never sits in a table. They are your credentials held by us, which is not the same thing as holding them yourself. |
| The SMTP conversation | Your node, or ours | With a node registered it runs on your box. When that worker fails, or when no node is registered, the connection to your mail server is opened from our API process using those Vault credentials — deliberately, so a node outage never stops a sequence. The tracking row tells you which happened: node-smtp versus smtp. |
| The prospect pool and its Go engine | Ours, always | A tenant-run node has no access to the pool partitions and must never be given it, so a self-hosted workspace is served from the ORM path instead. That is what search_backend: "fastapi-orm" is telling you. |
What a refusal looks like
All four were executed while writing this page, and none of them wrote anything — re-reading GET /billing/self-hosted afterwards still returned host: null.
| Status | When |
|---|---|
403 | The node answered, and identified as a different workspace. The claimed id is never echoed back — it names somebody else. |
400 | The node answered but reported no tenant at all, or the address was plaintext http. |
502 | The node did not answer at the address given. |
# each of these is:
# curl -s -o - -w '\nHTTP:%{http_code}\n' -X POST "$API/billing/self-hosted/node" \
# -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '<body>'
-d '{"host":"node1.vxcloud.io"}' # a real node, owned by another workspace
{"detail":"That node identifies as another workspace. Set TENANT_ID=92efb9b0-6785-468a-80d7-ceb5df400168 in its environment and restart it. (Your workspace name 'joelwembo' is shared with another workspace, so only the id above is accepted here.)"}
HTTP:403
-d '{"host":"http://leads.example.com"}' # plaintext
{"detail":"The node must be reachable over HTTPS."}
HTTP:400
-d '{"host":"https://api.vxcloud.io"}' # answers, but is not a vxnode
{"detail":"https://api.vxcloud.io answered but did not identify a tenant. Set TENANT_ID=92efb9b0-6785-468a-80d7-ceb5df400168 in the node's environment and restart it."}
HTTP:400
-d '{"host":"https://nope.invalid-host-for-test.io"}' # nothing there
{"detail":"Could not reach https://nope.invalid-host-for-test.io/health — HTTPSConnectionPool(host='nope.invalid-host-for-test.io', port=443): Max retries exceeded with url: /health (Caused by NameResolutionError(…Failed to resolve 'nope.invalid-host-for-test.io'…))"}
HTTP:502That first one is worth reading twice. https://node1.vxcloud.io/health really does answer "tenant_id": "joelwembo", and the workspace registering it really is called joelwembo— but three separate organisations share that name, so the name is ambiguous and only the UUID is accepted here. Names are not unique in this database; ids are. Nodes provisioned before the handshake existed carry TENANT_ID=<workspace name>, which is why a unique name is accepted at all, and the first live test of this endpoint refused a node that genuinely did belong to the workspace registering it.
The CLI handles these three statuses itself rather than printing a one-line HTTP error, because the tenant id you have to go and set is the only actionable thing in the message:
Not registered — the node did not prove it belongs to this workspace.
That node identifies as another workspace. Set TENANT_ID=92efb9b0-6785-468a-80d7-ceb5df400168 in its environment and restart it. (Your workspace name 'joelwembo' is shared with another workspace, so only the id above is accepted here.)
Nothing was recorded, and the node itself was not modified.Detaching
/billing/self-hosted/nodecurl -s -X DELETE "$API/billing/self-hosted/node" \
-H "Authorization: Bearer $TOKEN"vxcli salesshift billing node detach --yesc.salesshift.billing_detach_node()await c.salesshiftBilling.detachNode(); // resolves to voidif err := ss.DetachNode(ctx); err != nil {
return err
}It answers {"host": null, "verified": false} and clears tenant_primary_node_host, node_verified_at and the fingerprint, so on a self-hosted plan self_hosted.ready flips back to false. What actually changes for sending is the route the mail takes: with no node, the dispatcher uses the platform worker URL and then direct SMTP from our API — still your mailbox, still your credentials, and the tracking row reads smtp rather than node-smtp. Sending does not stop. Nothing is deleted either — contacts, sequences and tracking live in our database, not on your node.
Your own mailbox and your own model key
These are what make the free tier a working product rather than a read-only CRM, and neither is specific to it — a paid workspace can connect both and often does.
Sending
Connect a mailbox with OAuth (Microsoft 365, Google Workspace) or IMAP/SMTP credentials and mail goes out as you, over your IP, with replies landing where you already read mail. That flow, the tracking that rides along with it and the ceiling that applies to every organisation are covered in Sending email — it is the same procedure on every plan, so it is not repeated here.
The one part that is plan-specific: with a mailbox connected, a self-hosted workspace never reaches the refusal. The entitlement check sits on the platform-fallback branch of the send path, not at the top of it, precisely so that a workspace with its own mailbox is unaffected by it.
AI
Store a provider key against the workspace and the AI routes use it. The credential goes to Vault; the row that records it holds only metadata.
/settings/integrationscurl -s -X POST "$API/settings/integrations" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"integration_type": "ai",
"provider": "openai",
"credentials": {"api_key": "sk-…"},
"metadata": {"model": "gpt-4o-mini"},
"is_default": true
}'| provider | Required credentials | Usable as the default? |
|---|---|---|
openai | api_key | Yes |
anthropic | api_key | Yes |
ollama | none — but send base_url in credentials unless the model really is on localhost:11434 | Yes |
gemini | api_key | No. It can be stored, but nothing calls it yet, so it is refused as the org default rather than silently disabling the provider that works. |
400 naming it, not a row that fails later. The model is not a credential: it goes in metadata (stored as metadata_json) and defaults per provider — gpt-4o-mini for OpenAI. A local Ollama is a legitimate answer here: no key, no per-token cost, and nothing leaves your network.With no AI integration connected, POST /ai/write-email does not fail. It returns a deterministic template with source: "template" and a reasonsaying the plan runs AI on your own key — a usable skeleton and an honest explanation, rather than an error or a silently worse draft.
What is enforced, and what you see
Five places, and they are the places money actually leaves. Everything else in the product is indifferent to your plan.
| Where | Route | What happens without it |
|---|---|---|
| The platform relay | POST /email/send | The send is refused, not failed: the tracking row records provider: "refused" with the reason, the caller moves to the next contact and no sequence step is burned. A workspace with its own mailbox never reaches this branch. |
| The prospect pool meter | POST /leads/reveal | 402 Reveal limit reached for this month (50/50). The plan sets the allowance; an org override may only tighten it. |
| Models on our keys | POST /ai/write-email | 200 with source: "template" and a reason. This one does not raise — it degrades. |
| Rows in our database | POST /contacts, POST /contacts/bulk, POST /leads/bulk-convert | 402naming the ceiling and the current count. Bulk imports are checked against the whole file before a single row is written — importing 900 of a 1,000-row CSV and failing on the rest is worse than refusing the file. |
| Seats | POST /settings/team/invite | 402 before the account is created anywhere, so a refused invite leaves no orphan in the auth database. Only the free tier has a ceiling. |
The prospect pool on a self-hosted workspace
Search still works. What changes is where it runs: the Go pool engine lives on our node, against partitions a tenant-run node has no access to and must never be given, so a self-hosted workspace is not asked to query it — the request falls through to the ORM path instead. Same data, same database, one layer closer. You can see which one answered: search_backend reads fastapi-orm rather than go-node.
Reveals are still metered at 50 a month, because a reveal spends something of ours no matter whose node asked for it.
Upgrading, and the one asymmetry
Free plans and paid plans move through different doors. It looks inconsistent until you notice that only one of the two directions has a Stripe subscription that has to be told about it.
| Move | Route | Why |
|---|---|---|
| Free → paid | POST /billing/checkout | There is a card to take. Returns a Stripe Checkout URL; nothing is charged until it completes. |
| Nothing → free | POST /billing/activate | There is no card to take and no $0 price to redirect to. |
| Paid → other paid, or a seat change | POST /billing/change | Modifies the Stripe subscription in place, with prorations. |
| Paid → free | POST /billing/cancel | This is cancellation. The workspace falls to Self-Hosted at the end of the period it already paid for. |
# a free plan is not checked out — no Checkout Session was created
POST $API/billing/checkout -d '{"plan_code":"self_hosted","seats":1}'
{"detail":"Self-Hosted is free — activate it instead of checking out."}
HTTP:400
# a paid plan is not "changed" to a free one — that is cancellation
POST $API/billing/change -d '{"plan_code":"self_hosted"}'
{"detail":"To move to Self-Hosted, cancel this subscription — the workspace falls to it at the end of the period."}
HTTP:400
# and a granted plan is not cancelled through Stripe, because Stripe never knew about it
POST $API/billing/cancel -d '{"at_period_end":true}'
{"detail":"This workspace is on a granted plan. Contact support to change it."}
HTTP:409Cancelling at the end of the period is the default; {"at_period_end": false} ends it immediately. POST /billing/resumeclears a pending cancellation while the period is still running. Cards, receipts and the cancellation UI itself are Stripe’s hosted portal, reached through POST /billing/portal— rebuilding that would mean handling PCI-scope card entry for no gain. That is also the route to send somebody to when a card fails; see Common problems.
Reading the billing record
Three read routes answer the money questions, and they are separate from /entitlementson purpose — that one answers “what may I do”, these answer “what was I charged and when did it change”.
/billing/subscriptionplan, seats, period boundaries, cancel_at_period_end, and the raw status Stripe last reported/billing/eventsthe audit trail: activation, node.registered, node.removed, comp.granted, and every Stripe transition the webhook wrote/billing/invoicesStripe's own invoices for this customer — 503, not an empty list, on a deployment with no Stripe keyThe distinction on that last one is deliberate: “you have no invoices” and “payments are not configured on this deployment” are different answers, and a client that renders an empty list for both tells a self-hosted operator their billing history was lost.
Common problems
A send comes back provider: "refused" on a workspace that does have a mailbox
The send reached the platform-relay branch, which means neither a sending mailbox nor an email integration was selected for it — not that your mailbox was rejected. Check that the mailbox is active, and that the default email integration is one that can actually send: the send path picks the org default with no provider filter, so an IMAP row left as the default takes the slot from the SMTP one. GET /settings/integrations shows is_default per row.
Note the HTTP status is still 200 — read status, provider and error in the body. The refusal is recorded rather than raised so that a batch keeps going and no sequence step is burned on it.
self_hosted.ready is false and agents will not run
The plan requires a node and none is registered. readyis true whenever the plan does not need one, and otherwise only once a node has passed the handshake — so on a managed plan it is always true, which is what makes it safe to gate a screen on directly rather than on the plan code. Register one; step 3 above.
403 registering a node you definitely own
The node is reporting a tenant_id that is not in install.accepts — most often the workspace name, on a deployment where that name is not unique. Set TENANT_ID to the UUID from GET /billing/self-hosted and restart the container. The message includes the exact id to use.
Confirm what the node is actually saying before you retry: curl https://your-node/health and read tenant_id. The control plane makes that identical request.
502 Could not reach …/health for a node you can reach yourself
The probe runs on the server, not in your browser. A node behind a VPN, on a private address, or with a certificate our host does not trust is unreachable from where it counts. The error text is the raw exception — a DNS failure and a TLS failure look quite different in it, and both are worth reading.
Your UI shows “0 emails” for a Self-Hosted workspace
It is reading allowance.emailsand rendering the zero as a limit. It is not one — it means we are not the one sending. Branch on managed.sending: when it is false, say who does the sending instead of printing a number. The same applies to allowance.ai.
409 on /activate
The workspace already has a live plan above free, and /activate will not take one away. If it was bought, POST /billing/cancelis the route — the workspace falls to Self-Hosted at the end of the period. If it was granted, the message says to contact support, because there is no Stripe subscription to cancel and the row was created by a human on purpose.
A payment failed and the subscription is past_due
Nothing has been taken away yet. past_dueis one of the three statuses that still carry the plan — alongside active and trialing— because Stripe retries a failed card for days, and dropping a workspace to the free tier on the first decline would refuse writes to somebody who is about to pay.
Fix it with the card, not with the API: POST /billing/portalreturns a URL into Stripe’s hosted portal, which is where a card is updated and a failed invoice is retried. GET /billing/subscription shows the current status and GET /billing/events the history, so you can confirm the recovery rather than guess.
If the retries are exhausted, Stripe moves the subscription out of those three statuses, the webhook records whatever it sends, and the workspace resolves to Self-Hosted. The data is not touched: contacts already over the free tier’s 1,000 stay exactly where they are and stay readable, sequences keep their state, and connected mailboxes keep sending — the cap is only checked when something new is written, so what you lose is the ability to add, not what you had.
search_backend changed to fastapi-orm after moving to Self-Hosted
Expected. The Go pool engine runs on our infrastructure, against partitions a tenant-run node has no access to and must never be given, so a self-hosted workspace is served from the ORM path instead — the same rows from the same database, one layer closer. Nothing about the query or the results changes, and search_backend is there so you can tell which path answered rather than having to guess.
Next
Sending email
Connect the mailbox that makes sending work on the free tier, with OAuth or IMAP.
Leads and enrichment
What a reveal actually spends, and how the monthly allowance behaves when it runs out.
vxcli
The billing command group: entitlements, activate, and the node register/detach pair.
REST API reference
All 16 billing operations with parameters, response shapes and error cases.