Reference
SDKs
Coverage matrix
How much of the API each client can reach, measured rather than estimated: the routers are parsed for their route decorators, each SDK’s own string building is collapsed back to a placeholder, and the two sets are intersected. Counterparty-facing pages — signing, the public quote and invoice pages, RSVP — are left out of the denominator, because no client library should be driving those.
The counts are conservative by construction. When an SDK builds the last segment of a path from a variable — the sequence activate/pause/archive calls are all one /sequences/{id}/{verb} helper — the collapsed form no longer matches a literal route, and the measurement scores it as unreached. It under-reports rather than over-reports, which is the only direction this table can safely be wrong in.
| Client | Routes reached | Of 251 | Areas |
|---|---|---|---|
| Go | 60 | 24% | email, leads, contacts, workflows, sequences, campaigns, opportunities, tasks, billing and the free tier, social |
| Python | 61 | 24% | email, leads, contacts, workflows, sequences, campaigns, opportunities, tasks, billing and the free tier, social |
| TypeScript | 65 | 26% | email, leads, contacts, workflows, sequences, campaigns, opportunities, tasks, billing and the free tier, social |
| C++ | 16 | 6% | email and leads only |
| Java | 16 | 6% | email and leads only |
TypeScript used to read 36 here, and that was our mistake
"…" and '…' literals only. TypeScript builds nearly every URL as a backtick template literal, so most of that client was invisible to the measurement and the page invented an explanation for the gap. Reading backticks too moves it from 36 to 65 without a line of SDK code changing. The three clients are within five routes of each other; the real differences between them are in ergonomics and in a handful of leads-pool helpers — bulk reveal, page walking, cost preview — that Go and Python have and TypeScript does not.The five SDKs are near-identical in surface and quite different in ergonomics. The table below is the surface; the per-language sections after it are the ergonomics.
| SalesShift area | Go | Python | TypeScript | C++ | Java |
|---|---|---|---|---|---|
| Send a tracked email | covered | covered | covered | covered | covered |
| List tracked emails | covered | covered | covered | covered | covered |
| Dashboard statsTypeScript returns an untyped record for this one call | covered | covered | partly covered | covered | covered |
| Tenant node email-worker health | covered | covered | covered | covered | covered |
| Pool search — people | covered | covered | covered | covered | covered |
| Pool search — companiesGo has a separate method; Python, C++ and Java pass a result_type | covered | covered | covered | covered | covered |
| Facets | covered | covered | covered | covered | covered |
| Reveal, and the reveal meter | covered | covered | covered | covered | covered |
| Cost preview before a revealPython sync only; not in the async client | covered | partly covered | covered | no method | no method |
| Bulk reveal helper | covered | no method | no method | no method | no method |
| Save pool rows as leads | covered | covered | covered | covered | covered |
| Saved leads — list, get, update | covered | covered | covered | covered | covered |
| Convert — single, bulk, from pool | covered | covered | covered | covered | covered |
| Erasure | covered | covered | covered | covered | covered |
| Enrich from a website | covered | covered | covered | covered | covered |
| Saved searches | covered | covered | covered | covered | covered |
| Walk every page of a search | covered | covered | covered | no method | no method |
| Typed response modelsPython, C++ and Java hand back dicts or raw JSON strings | covered | no method | covered | no method | no method |
| Contacts — list, get, create, update, deleteThe only records that are mailable; a pool lead is not one | covered | covered | covered | no method | no method |
| Send a tracked one-off to a contactSuppression gate, sending pool, open pixel, unsubscribe footer | covered | covered | covered | no method | no method |
| Contact notes, activity timeline and lists | covered | covered | covered | no method | no method |
| Workflows — list, create, validate, test-runtest-run defaults to a dry run, so a test cannot mail anyone | covered | covered | covered | no method | no method |
| Workflow enrollment and run history | covered | covered | covered | no method | no method |
| Sequences — create, steps, activate, pause, archive | covered | covered | covered | no method | no method |
| Sequence enrollment, enrollments and analyticsSkips come back with a reason, not just a count | covered | covered | covered | no method | no method |
| Opportunities — the shared signal poolIncluding apply, which records the application and emails the poster | covered | covered | covered | no method | no method |
| Tasks — full CRUD | covered | covered | covered | no method | no method |
| Campaigns — create, send, reportPython adds waitForCampaign; sends are asynchronous | covered | covered | covered | no method | no method |
| Platform billing — plans, subscription, checkout, invoicesWhat you pay for SalesShift, not what your customers pay you. Python stops after checkout: no events, portal, change, cancel or resume. TypeScript has everything except events | covered | partly covered | partly covered | no method | no method |
| Free tier — entitlements, plan activation, self-hosted nodeAll three reach every route of it; no client calls the Stripe webhook, and none should | covered | covered | covered | no method | no method |
| Social distribution | covered | covered | covered | no method | no method |
●covered◐partly covered—no method
What no SDK covers
These are not gaps in one language. No SalesShift SDK, in any language, has a method for any of them. Use the REST API directly.
- Companies, deals, pipelines and forecast
- Quotes, products, and the invoices you send customers
- Contracts and e-signature
- Calendar, events and ICS invitations
- Customer subscriptions, MRR, reports, dashboards and goals
- Deliverability and the sending pool
- Marketing — forms, landing pages, booking links, automations
- Webmail — accounts, OAuth connect, message sync
- Settings, team and provider webhooks
- Reply threads — the whole /conversations area
- AI email drafting (/ai/write-email)
- All 55 messaging and call routes (51 HTTP, 4 WebSocket)
Three flows people expect to find here, and do not
Fetch deliverability health — no SDK reaches the deliverability or sending-pool routes. The nearest thing any of them has is the tenant node’s email-worker health check, which reports the worker’s providers and rate limit, not domain authentication or inbox placement.
Read what came back — there is no method for /conversations in any language. An SDK can send, and can list sent mail with its engagement counters, but the reply threads themselves are REST only.
Do any of this from the Python async client — vxsdk_async.py has a SalesShiftresource, and it covers email and the leads pool and stops there. It has no billing method at all, new or old, and none of the CRM, workflow or sequence surface the sync client gained. Every count on this page for “Python” is the sync client.
Getting the SDKs
All five are in this repository under vxnode/services/sdk/. Three of them are also published — to npm, PyPI and the Go module proxy — but none of the published artifacts contains the SalesShift surface. Each was downloaded and inspected while writing this page:
| Registry | Version resolved | Has SalesShift? | Checked by |
|---|---|---|---|
| npm @vxcloud/sdk | 2026.6.15 | no | Installed from the registry; 62 exports, no Leads, no SalesShift. |
| PyPI vxsdk | 2026.6.14 | no | Wheel downloaded and opened; no class SalesShift, no search_leads. |
| proxy.golang.org | v0.1.0-preview | no | Only version on the proxy; the fetched module has no salesshift package. |
So every install instruction below points at the repository. When a release carrying this surface ships, the registry lines become the shorter path; today they would give you a client with no SalesShift methods on it.
Authenticating
Every SDK accepts the same two credentials the API does: a JWT access token, or a developer API key (xc_dev_, xc_test_, xc_live_). Go, Python and TypeScript can also read whatever vxcli auth login already stored in ~/.vxcloud/credentials.json, which is how every example on this page authenticates. C++ and Java have no such loader — pass the credential yourself.
| Language | From vxcli's credentials | Explicit |
|---|---|---|
| Go | vxsdk.LoadFromVxcli(ctx, opts...) | vxsdk.New(ctx, vxsdk.WithJWT(a, r)) |
| Python | vxsdk.Client.load_from_vxcli() | vxsdk.Client(access_token=…) |
| TypeScript | VxCloud.loadFromVxcli(opts?) | new VxCloud({ accessToken }) |
| C++ | none | vx::ClientOptions{.access_token = …} |
| Java | none | VxClient.builder().accessToken(…) |
Only the API-key path refreshes: the Go, Python, TypeScript, C++ and Java clients all exchange a key for a JWT on demand and retry once on a 401. A client constructed from a bare JWT has nothing to refresh with, so an expired token surfaces as a 401 for you to handle.
Entitlements and the self-hosted node
Called liveThe Self-Hosted plan is free and runs on the tenant’s own node, so a client cannot infer what a workspace may do from what it is paying. Three clients have methods for asking. They are the first thing to reach for when a call comes back 402 Payment Required, which is what a refusal on this plan looks like — never a 403.
| Operation | Go | Python (sync) | TypeScript |
|---|---|---|---|
| Resolved plan, managed flags and allowances | GetEntitlements(ctx) | billing_entitlements() | entitlements() |
| Move onto a free plan | ActivatePlan(ctx, code) | billing_activate_plan(plan_code) | activatePlan(planCode) |
| Node state and the id it must claim | GetSelfHosted(ctx) | billing_self_hosted() | selfHosted() |
| Register a node | RegisterNode(ctx, host) | billing_register_node(host) | registerNode(host) |
| Forget it again | DetachNode(ctx) | billing_detach_node() | detachNode() |
C++ and Java have none of these, and no other billing method either. Neither does the Python async client.
Read managed before you read allowance
allowance.emails: 0 and allowance.ai: 0. That is not a cap of zero — it is the platform saying it does not run those, so it has nothing to count. managed.sending and managed.ai are what tell you, and a UI that renders the zeros as limits will tell your users they may not send when in fact they may.The same call in three languages
The workspace below is a comped Organization, so every managed flag is true and no node is needed. The shapes are what matter: the wire is snake_case, Go and Python hand it back that way, and the TypeScript client maps to camelCase at the boundary.
ent, err := ss.GetEntitlements(ctx)
if err != nil {
log.Fatal(err)
}
// Nil quota means unlimited, the same convention as PlanQuotas.
fmt.Println(ent.PlanCode, ent.Managed.Sending, ent.Allowance.Reveals)
sh, err := ss.GetSelfHosted(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(sh.Install.TenantID) // the id a node must report on /healthss = c.salesshift
ent = ss.billing_entitlements()
if not ent["managed"]["sending"]:
print("we do not send for this workspace — its own node does")
node = ss.billing_self_hosted()
print(node["install"]["tenant_id"])const ent = await c.salesshiftBilling.entitlements();
if (!ent.managed.sending) {
console.log("we do not send for this workspace — its own node does");
}
const node = await c.salesshiftBilling.selfHosted();
console.log(node.install.tenantId);{
"planCode": "organization",
"planName": "Organization",
"status": "active",
"source": "comp",
"seats": 25,
"isFree": false,
"managed": {
"compute": true,
"sending": true,
"ai": true
},
"allowance": {
"emails": 12500000,
"reveals": 2500000,
"ai": 500000,
"mailboxes": null,
"contacts": null,
"users": null
},
"selfHosted": {
"required": false,
"nodeHost": null,
"verifiedAt": null,
"ready": true
}
}Go returns the same fields with Go zero values where the wire had null — node_host and verified_at come back as empty strings rather than nulls — and Python returns the raw dict, so it prints null exactly as the API sent it.
Registering a node is a handshake
registerNode does not store what you pass it. The control plane fetches <host>/healthand requires the node to report this workspace’s tenant id before the host is written down; a node that names a different workspace is refused. HTTPS is required except for localhost. All three clients surface the server’s own wording rather than inventing their own.
# Go
salesshift.RegisterNode: salesshift.RegisterNode: 400 Bad Request — {"detail":"The node must be reachable over HTTPS."}
# Python
VxValidationError -> salesshift.billing_register_node: 400 Bad Request — {"detail":"The node must be reachable over HTTPS."}
# TypeScript
VxValidationError -> The node must be reachable over HTTPS.Python and TypeScript also refuse an empty host before making a request at all — salesshift.billing_register_node: host is required — which matches how billing_checkout guards its own argument.
The doubled prefix in the Go line is real
salesshift.RegisterNode: appears twice because the Go client wraps an error that already carries its own operation prefix. It is pasted as printed rather than tidied up — a defect in the client, not a transcription slip, and worth knowing about if you match on that string.Nothing here was activated or registered
ActivatePlan, a successful RegisterNode and DetachNode were not run from any SDK: the workspace these captures come from is a comped Organization, and all three would have changed what it is on. They are documented from their source and from the equivalent vxcli command, which is labelled the same way on its own page.Go
Called live- Path
vxnode/services/sdk/salesshift/- Module
github.com/prodxcloud/vxcloud- Entry point
c.SalesShift()- Methods
- 98 exported, all on one client
- Typed
- Yes — request and response structs throughout
The most complete of the five. Everything is a struct, and several helpers exist specifically to stop a class of mistake: PoolPerson.MailableEmail() returns an address only when your org revealed it, Lead.Convertible() explains why a lead cannot be converted, LeadPage.DisplayTotal()honours the server’s 10,000 count cap, and ConvertFromPoolReport.Describe() renders every bucket rather than just the successes.
Install
# The module is not on the proxy with this surface yet, so point at the repo.
cat > go.mod <<'EOF'
module yourapp
go 1.22
require github.com/prodxcloud/vxcloud v0.0.0
replace github.com/prodxcloud/vxcloud => /path/to/vxnode/services/sdk
EOF
go mod tidyWorked example
package main
import (
"context"
"fmt"
"log"
"os"
vxsdk "github.com/prodxcloud/vxcloud"
"github.com/prodxcloud/vxcloud/salesshift"
)
func main() {
ctx := context.Background()
// LoadFromVxcli reuses ~/.vxcloud/credentials.json — the same identity
// `vxcli auth login` stored. The WithVxCloudURL/WithNodeURL overrides are
// only needed when you are pointing at a local control plane.
c, err := vxsdk.LoadFromVxcli(ctx,
vxsdk.WithVxCloudURL(os.Getenv("VXCLOUD_URL")),
vxsdk.WithNodeURL(os.Getenv("VX_NODE_URL")),
)
if err != nil {
log.Fatal(err)
}
ss := c.SalesShift()
// 1 — dashboard stats
stats, err := ss.GetStats(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("stats: contacts=%d companies=%d open_deals=%d sent=%d\n",
stats.Contacts, stats.Companies, stats.OpenDeals, stats.EmailStats["sent"])
// 2 — tenant node email worker
h, err := ss.GetWorkerHealth(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("worker: status=%s providers=%v redis=%v rate=%d/min\n",
h.Status, h.Providers, h.RedisConnected, h.RateLimitDomain)
// 3 — search the pool
page, err := ss.SearchLeads(ctx, salesshift.SearchLeadsInput{
Filters: salesshift.LeadFilters{
Countries: []string{"AU"},
Seniorities: []string{"founder"},
},
Limit: 3,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("search: %s matches, backend=%s, sort=%s desc=%v\n",
page.DisplayTotal(), page.SearchBackend, page.Sort.Field, page.Sort.Desc)
for _, p := range page.Items {
// MailableEmail is the ONLY safe accessor: it returns nothing when the
// address is still a mask.
addr, ok := p.MailableEmail()
if !ok {
addr = "(masked — needs reveal)"
}
fmt.Printf(" %-18s %-12s %-16s %s\n", p.FullName, p.Title, p.Company.Name, addr)
}
// 4 — the reveal meter
q, err := ss.RevealQuota(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Printf("quota: %d/%d used, %d remaining\n", q.Used, q.Allowance, q.Remaining)
// 5 — facets for the same filter set
f, err := ss.LeadFacets(ctx, salesshift.LeadFilters{Countries: []string{"AU"}})
if err != nil {
log.Fatal(err)
}
for _, b := range f.Seniority {
fmt.Printf(" seniority %-10s %d\n", b.Value, b.Count)
}
// 6 — this tenant's saved leads
leads, err := ss.ListLeads(ctx, salesshift.ListLeadsInput{Limit: 3})
if err != nil {
log.Fatal(err)
}
for _, l := range leads {
ok, why := l.Convertible()
fmt.Printf(" lead %-16s status=%-10s convertible=%v (%s)\n", l.FullName, l.Status, ok, why)
}
// 7 — tracked email feed
emails, err := ss.ListEmails(ctx, "sent")
if err != nil {
log.Fatal(err)
}
fmt.Printf("emails: %d rows; newest -> %s / %s\n", len(emails), emails[0].ToEmail, emails[0].Status)
// 8 — send a tracked email. This delivers a real message; the suppression
// gate and the daily caps are applied server-side.
sent, err := ss.SendEmail(ctx, salesshift.SendEmailInput{
ToEmail: "[email protected]",
Subject: "Docs example - Go SDK SendEmail",
BodyHTML: "<p>Sent by c.SalesShift().SendEmail while writing the SDK reference.</p>",
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("sent: success=%v status=%s provider=%s tracking_id=%s\n",
sent.Success, sent.Status, sent.Provider, sent.TrackingID)
}VXCLOUD_URL=https://api.vxcloud.io VX_NODE_URL=https://node1.vxcloud.io go run .stats: contacts=41 companies=1 open_deals=22 sent=220
worker: status=healthy providers=[smtp] redis=true rate=20/min
search: 11 matches, backend=go-node, sort=score desc=true
Sofia Ferreira Founder Kestrel Data [email protected]
Mia Calderon Founder Fjord Energy (masked — needs reveal)
Callum Reyes Founder Helix Mfg (masked — needs reveal)
quota: 4/200 used, 196 remaining
seniority director 33
seniority manager 11
seniority founder 11
seniority c_suite 6
seniority vp 4
lead Marco Oduya status=converted convertible=false (already converted)
lead Sofia Ferreira status=converted convertible=false (already converted)
lead Mia Calderon status=new convertible=false (reveal this lead's email before converting — a contact without an address cannot be emailed)
emails: 50 rows; newest -> [email protected] / sent
sent: success=true status=sent provider=smtp tracking_id=645f9770e6194bae84049f9f85192ca0Gaps
- Nothing outside the areas marked covered in the matrix above. The one billing route it does not reach is the Stripe webhook, which is a server-to-server callback and has no business in a client library.
SearchCompaniesis a separate method fromSearchLeadsbecause the item types differ. Its doc comment says company results are unpaged andNextCursoris always empty — on thego-nodebackend used here that was not true, and a company search did hand back a cursor. Read the field, not the comment.WalkLeadsandSearchAllLeadsare client-side loops over the same paged endpoint, bounded byLeadWalkMaxPages(100) when you pass 0.
Python
Called live- Path
vxnode/services/sdk/python/- Modules
vxsdk(sync, stdlib only) andvxsdk_async(needs httpx)- Entry point
Client(...).salesshift- Methods
- 91 sync, 26 async — the async client is email and leads only
- Typed
- No — plain dicts and lists
Returns whatever the API returned, unwrapped from its {success, data} envelope. The typing that is there is in the exceptions: VxAuthError (401/403), VxValidationError (400/422), VxNotFoundError (404), VxRateLimitError (429), VxServerError (5xx) and VxNetworkError, so you branch on a class rather than on a status code.
The sync and async clients are not identical
preview_reveal_cost exists only on the sync client. describe_convert is a method on the async class and a module-level function used by the sync one. Everything else matches.Install
# PyPI's vxsdk does not carry the SalesShift surface yet — install from the repo.
pip install /path/to/vxnode/services/sdk/python
# Async client as well:
pip install httpxWorked example
import os
import vxsdk
# load_from_vxcli reuses ~/.vxcloud/credentials.json — the identity
# `vxcli auth login` stored. Point it at a local control plane by assigning
# the two URL attributes afterwards.
c = vxsdk.Client.load_from_vxcli()
c.vxcloud_url = os.environ.get("VXCLOUD_URL", c.vxcloud_url)
c.node_url = os.environ.get("VX_NODE_URL", c.node_url)
ss = c.salesshift
# 1 — dashboard stats
stats = ss.get_stats()
print("stats:", {k: stats[k] for k in ("contacts", "companies", "open_deals", "active_sequences")})
# 2 — tenant node email worker
print("worker:", ss.get_worker_health())
# 3 — search the pool
page = ss.search_leads(filters={"countries": ["AU"], "seniorities": ["founder"]}, limit=3)
print(f"search: {page['total_display']} matches, backend={page['search_backend']}, sort={page['sort']}")
for p in page["items"]:
# email_revealed is the ONLY thing that makes `email` an address. Without
# it the field is a mask and must never reach a send path.
addr = p["email"] if p.get("email_revealed") else "(masked - needs reveal)"
print(f" {p['full_name']:<18} {p['title']:<12} {p['company']['name']:<16} {addr}")
# 4 — the reveal meter, and what a batch would cost before spending it
print("quota:", ss.reveal_quota())
print("preview:", ss.preview_reveal_cost([p["pool_id"] for p in page["items"]]))
# 5 — facets for the same filter set
print("seniority facets:", ss.lead_facets(filters={"countries": ["AU"]})["seniority"])
# 6 — this tenant's saved leads
for lead in ss.list_leads(limit=3):
print(f" lead {lead['full_name']:<16} status={lead['status']:<10} "
f"converted={bool(lead.get('converted_contact_id'))}")
# 7 — tracked email feed
emails = ss.list_emails(status="sent")
print(f"emails: {len(emails)} rows; newest -> {emails[0]['to_email']} / {emails[0]['status']}")
# 8 — send a tracked email. This delivers a real message.
print("sent:", ss.send_email(
to_email="[email protected]",
subject="Docs example - Python SDK send_email",
body_html="<p>Sent by client.salesshift.send_email while writing the SDK reference.</p>",
))
# 9 — typed errors, not bare HTTP codes
try:
ss.get_pool_person("00000000-0000-0000-0000-000000000000")
except vxsdk.VxNotFoundError as e:
print("not found ->", e)VXCLOUD_URL=https://api.vxcloud.io VX_NODE_URL=https://node1.vxcloud.io python example.pystats: {'contacts': 41, 'companies': 1, 'open_deals': 22, 'active_sequences': 2}
worker: {'providers': ['smtp'], 'rate_limit_domain': 20, 'redis_connected': True, 'service': 'salesshift-emailworker', 'status': 'healthy'}
search: 11 matches, backend=go-node, sort={'desc': True, 'field': 'score'}
Sofia Ferreira Founder Kestrel Data [email protected]
Mia Calderon Founder Fjord Energy (masked - needs reveal)
Callum Reyes Founder Helix Mfg (masked - needs reveal)
quota: {'used': 4, 'allowance': 200, 'remaining': 196, 'display': '4 / 200'}
preview: {'requested': 3, 'max_reveals': 3, 'quota': {'used': 4, 'allowance': 200, 'remaining': 196, 'display': '4 / 200'}, 'remaining': 196, 'would_exceed_allowance': False}
seniority facets: [{'value': 'director', 'count': 33}, {'value': 'manager', 'count': 11}, {'value': 'founder', 'count': 11}, {'value': 'c_suite', 'count': 6}, {'value': 'vp', 'count': 4}]
lead Marco Oduya status=converted converted=True
lead Sofia Ferreira status=converted converted=True
lead Mia Calderon status=new converted=False
emails: 50 rows; newest -> [email protected] / sent
sent: {'success': True, 'status': 'sent', 'tracking_id': '757ef7c52b72414988e39d5875c3ee5d', 'provider': 'smtp', 'contact_id': 'ac38c3bf-d4f2-4704-b3a1-be4467294e1a', 'error': None}
not found -> salesshift.get_pool_person: 404 Not Found — {"detail":"Person not found in the pool"}Gaps
- Nothing outside the areas marked covered in the matrix above, and its billing surface is the shallowest of the three: nine
billing_*methods, covering plans, subscription, entitlements, invoices, checkout, activation and the self-hosted node. There is noevents,portal,change,cancel,resumeor checkout confirmation — those are REST orvxclionly. - No
social_stats, and none of the three webmaster methods Go has (generate,robots,sitemap). Social is posting and distribution only. - No company-search method by name — pass the company result type through
search_leads. - No response models. Nothing stops you reading
row["email"]on an unrevealed row and getting a mask, so checkemail_revealedyourself. Go and TypeScript both have an accessor for exactly this; Python does not. - The module reports
__version__ == "2026.6.10"whilepyproject.tomldeclares2026.6.14. Do not use the module version to decide whether the leads surface is present.
TypeScript
Called live- Path
vxnode/services/sdk/typescript/src/- Package
@vxcloud/sdk- Entry point
- Ten accessors on
new VxCloud(opts):.salesshift.leads.contacts.workflows.sequences.salesshiftBilling.social.opportunities.tasks.campaigns - Methods
- 91 across ten classes in four modules
- Typed
- Yes — the most thoroughly of the five
Around thirty exported interfaces and union types, so a bad seniority or an unsortable column is a compile error rather than a silent server-side degrade. searchLeads is overloaded: { resultType: 'company' }narrows the return to a company page. The server’s caps are exported as constants — LEADS_MAX_BATCH 200, LEADS_MAX_PAGE_SIZE 100, LEADS_MAX_LIST_LIMIT 500, LEADS_TOTAL_CAP 10000 — and two named errors, VxLeadQuotaExceededError and VxLeadErasedError, mean a 402 and a 410 are distinguishable without reading a status.
Nullable by design — and strict mode will tell you
fullName, title, email, company.name and friends are all string | null. Under strict the first draft of the example below failed to compile with five possibly 'null' errors, which is the type system doing its job. Use the exported revealedEmail(row) — it returns the address or null, never the mask, and returns null for an erased row too.Install
# npm's @vxcloud/sdk does not carry the SalesShift surface yet, and its
# package exports map points at a dist/index.mjs the build never emits.
# Build from the repo instead:
cd /path/to/vxnode/services/sdk/typescript
npm install
npm run build # tsup -> dist/index.js (ESM) + dist/index.cjs (CJS)Two problems with the published package
Installed from the registry, @vxcloud/[email protected] exports 62 names and none of them is Leads or SalesShift.
Separately, its exports.import points at ./dist/index.mjs, which the build does not produce — the ESM output is dist/index.js. So import '@vxcloud/sdk' fails with ERR_MODULE_NOT_FOUND while require('@vxcloud/sdk') resolves fine, and a deep import is blocked by the same map with ERR_PACKAGE_PATH_NOT_EXPORTED. All three were reproduced against a fresh install.
Worked example
import { VxCloud, revealedEmail, estimateRevealCost } from '@vxcloud/sdk';
// loadFromVxcli reuses ~/.vxcloud/credentials.json. The two URL overrides are
// only needed when pointing at a local control plane.
const c = VxCloud.loadFromVxcli({
vxcloudURL: process.env.VXCLOUD_URL,
nodeURL: process.env.VX_NODE_URL,
});
// 1 — dashboard stats (the one call with no result interface)
const stats = await c.salesshift.getStats();
console.log('stats:', {
contacts: stats.contacts,
companies: stats.companies,
open_deals: stats.open_deals,
});
// 2 — tenant node email worker
const health = await c.salesshift.getWorkerHealth();
console.log(
`worker: status=${health.status} providers=${health.providers} redis=${health.redisConnected} rate=${health.rateLimitDomain}/min`,
);
// 3 — search the pool. The overload makes this a LeadPersonSearchPage.
const page = await c.leads.searchLeads({
filters: { countries: ['AU'], seniorities: ['founder'] },
limit: 3,
});
console.log(
`search: ${page.totalDisplay} matches, backend=${page.searchBackend}, sort=${page.sort.field} desc=${page.sort.desc}`,
);
for (const p of page.items) {
// revealedEmail returns the address or null — never the mask.
const addr = revealedEmail(p) ?? '(masked — needs reveal)';
console.log(` ${(p.fullName ?? '').padEnd(18)} ${(p.title ?? '').padEnd(12)} ${(p.company.name ?? '').padEnd(16)} ${addr}`);
}
// 4 — the meter, and what revealing this page would cost before spending it
const quota = await c.leads.revealQuota();
console.log('quota:', quota);
console.log('estimate:', estimateRevealCost(page.items, quota));
// 5 — the company overload returns a different page type
const companies = await c.leads.searchLeads({
filters: { countries: ['AU'] },
resultType: 'company',
limit: 3,
});
for (const co of companies.items) {
console.log(` company ${(co.name ?? '').padEnd(18)} ${(co.domain ?? '').padEnd(22)} ${co.employeeCount ?? '-'} staff`);
}
// 6 — saved leads
for (const l of await c.leads.listLeads({ limit: 3 })) {
console.log(` lead ${(l.fullName ?? '').padEnd(16)} status=${l.status}`);
}
// 7 — tracked email feed
const emails = await c.salesshift.listEmails('sent');
console.log(`emails: ${emails.length} rows; newest -> ${emails[0].toEmail} / ${emails[0].status}`);
// 8 — send a tracked email. This delivers a real message.
const sent = await c.salesshift.sendEmail({
toEmail: '[email protected]',
subject: 'Docs example - TypeScript SDK sendEmail',
bodyHtml: '<p>Sent by client.salesshift.sendEmail while writing the SDK reference.</p>',
});
console.log('sent:', sent);# strict typecheck against the SDK source
npx tsc --noEmit -p tsconfig.json
# bundle the example together with the SDK source, then run it
npx esbuild example.ts --bundle --platform=node --format=esm --target=node20 \
--alias:@vxcloud/sdk=/path/to/sdk/typescript/src/index.ts --outfile=example.mjs
VXCLOUD_URL=https://api.vxcloud.io VX_NODE_URL=https://node1.vxcloud.io node example.mjsstats: { contacts: 41, companies: 1, open_deals: 22 }
worker: status=healthy providers=smtp redis=true rate=20/min
search: 11 matches, backend=go-node, sort=score desc=true
Sofia Ferreira Founder Kestrel Data [email protected]
Mia Calderon Founder Fjord Energy (masked — needs reveal)
Callum Reyes Founder Helix Mfg (masked — needs reveal)
quota: { used: 4, allowance: 200, remaining: 196, display: '4 / 200' }
estimate: {
total: 3,
alreadyRevealed: 1,
noAddress: 0,
willSpend: 2,
remaining: 196,
affordable: 2,
shortfall: 0,
display: '3 selected · 1 already revealed (free) · 2 reveals will be spent · 196 remaining now, 194 after this'
}
company Northwind Systems northwind.example 400 staff
company Fjord Energy fjord.example 200 staff
company Kestrel Data kestreldata.example 1500 staff
lead Marco Oduya status=converted
lead Sofia Ferreira status=converted
lead Mia Calderon status=new
emails: 50 rows; newest -> [email protected] / sent
sent: {
success: true,
status: 'sent',
trackingId: '85cfb138d7c4440c8cc9dfebdeabb77e',
provider: 'smtp',
contactId: 'ac38c3bf-d4f2-4704-b3a1-be4467294e1a',
error: undefined
}Gaps
- Nothing outside the areas marked covered in the matrix above. On billing it is one method short of Go: there is no
events(), so the workspace’s billing history is REST orvxclionly. It reaches every other billing route except the Stripe webhook, which no client should call. getStatsreturnsRecord<string, unknown>— the one call with no result interface, which is why the example indexes it by string.searchAllLeadsis an async generator that walks pages client-side; there is no server-side equivalent.- The checked-in
dist/does carry the leads surface, but it is a build artifact and was older thansrc/when this page was written. Build before you rely on it.
C++
Called live- Path
vxnode/services/sdk/cpp/- Standard
- C++17
- Dependency
- libcurl, and nothing else
- Entry point
vx::Client- Methods
- 21 SalesShift methods
- Typed
- Requests yes, responses no
Requests are real structs — LeadFilters, LeadSearchRequest, LeadUpdate — but every method returns std::string: the raw JSON body. There is deliberately no JSON dependency, so you plug in whichever parser you already have. Errors are the exception to the no-types rule: vx::VxError carries http_status(), is_auth(), is_retryable() and detail().
Install
# Debian/Ubuntu
sudo apt-get install -y libcurl4-openssl-dev
# CMake
cmake -S /path/to/vxnode/services/sdk/cpp -B build && cmake --build build
# or straight g++
g++ -std=c++17 -O2 -Iinclude -c src/vxsdk.cpp -o vxsdk.o
g++ -std=c++17 -O2 -Iinclude example.cpp vxsdk.o -lcurl -o exampleWorked example
// SalesShift — C++ SDK example. Every method returns the raw JSON body as a
// std::string; there is no response model, so the caller parses with whatever
// JSON library it already has. This example prints the raw bodies to keep the
// dependency list at libcurl.
#include "vxsdk/vxsdk.hpp"
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
static std::string env(const char* k, const std::string& fallback = "") {
const char* v = std::getenv(k);
return v && *v ? std::string(v) : fallback;
}
// The SDK returns raw JSON, so a demo needs a way to show a slice of it
// without pulling in a parser.
static void show(const std::string& label, const std::string& json, size_t n = 220) {
std::cout << label << ": " << (json.size() > n ? json.substr(0, n) + " …" : json) << "\n";
}
int main() {
vx::ClientOptions o;
o.access_token = env("VX_ACCESS_TOKEN");
o.username = env("VX_USERNAME");
o.vxcloud_url = env("VXCLOUD_URL", "https://api.vxcloud.io");
o.node_url = env("VX_NODE_URL");
try {
vx::Client c(std::move(o));
// 1 — dashboard stats
show("stats", c.salesshift_stats());
// 2 — tenant node email worker
show("worker", c.salesshift_worker_health());
// 3 — search the pool
vx::LeadSearchRequest req;
req.filters.countries = {"AU"};
req.filters.seniorities = {"founder"};
req.limit = 2;
show("search", c.leadsSearch(req), 400);
// 4 — the reveal meter
show("quota", c.leadsQuota());
// 5 — facets
vx::LeadFilters f;
f.countries = {"AU"};
show("facets", c.leadsFacets(f), 200);
// 6 — this tenant's saved leads
show("saved leads", c.leadsList("", 2), 260);
// 7 — tracked email feed
show("emails", c.salesshift_emails("sent"), 200);
// 8 — send a tracked email. This delivers a real message.
show("sent", c.salesshift_send_email(
"[email protected]",
"Docs example - C++ SDK salesshift_send_email",
"<p>Sent by vx::Client::salesshift_send_email while writing the "
"SDK reference.</p>"));
// 9 — errors are typed by status, not by string matching
try {
c.leadsPoolPerson("00000000-0000-0000-0000-000000000000");
} catch (const vx::VxError& e) {
std::cout << "not found -> status=" << e.http_status()
<< " auth=" << e.is_auth()
<< " retryable=" << e.is_retryable()
<< " detail=" << e.detail() << "\n";
}
// 10 — client-side guards fire before any request goes out
try {
c.leadsErasure(""); // erasure is global; an empty address never reaches it
} catch (const vx::VxError& e) {
std::cout << "erasure guard -> " << e.what() << "\n";
}
try {
c.leadsSave(std::vector<std::string>(201, "x")); // reveals are metered
} catch (const vx::VxError& e) {
std::cout << "batch guard -> " << e.what() << "\n";
}
} catch (const vx::VxError& e) {
std::cerr << "vxsdk error: " << e.what() << " (status " << e.http_status() << ")\n";
return 1;
}
return 0;
}stats: {"contacts":41,"companies":1,"open_deals":22,"open_pipeline_value":762700.0,"active_sequences":2,"open_tasks":23,"email_stats":{"sent":223,"delivered":26,"opened":26,"replied":4,"bounced":0},"reply_categories":{"follow_u …
worker: {"providers":["smtp"],"rate_limit_domain":20,"redis_connected":true,"service":"salesshift-emailworker","status":"healthy"}
search: {"success":true,"data":{"items":[{"pool_id":"7d2e1a44-90bc-5e17-a3f6-1b8c40d92e55","full_name":"Sofia Ferreira","title":"Founder","seniority":"founder","department":"executive","email_masked":"s•••@kestreldata.example","email_status":"unverified","has_email":true,"phone_count":1,"location":"Melbourne, AU","score":{"value":62},"company":{"name":"Kestrel Data","domain":"kestreldata.example","industry":"Computer …
quota: {"success":true,"data":{"used":4,"allowance":200,"remaining":196,"display":"4 / 200"}}
facets: {"success":true,"data":{"country":[{"value":"AU","count":65}],"department":[{"value":"marketing","count":33},{"value":"sales","count":15},{"value":"executive","count":11},{"value":"engineering","count …
saved leads: {"success":true,"data":[{"id":"9f13c6a8-52d0-4e71-b384-07ea25f916bc","pool_person_id":"4a7e6d20-9b31-5c48-8e02-1f5b7c93da60","first_name":"Marco","last_name":"Oduya","full_name":"Marco Oduya","title":"Marketing Director","seniority":"director","departmen …
emails: {"success":true,"data":[{"id":"b720d3ac-c74e-4ca6-aa56-21eafd88142f","to_email":"[email protected]","from_email":"[email protected]","contact_id":"ac38c3bf-d4f2-4704-b3a1-be4467294e1a", …
sent: {"success":true,"status":"sent","tracking_id":"0ea491be53cd4248a47d60f955b68646","provider":"smtp","contact_id":"ac38c3bf-d4f2-4704-b3a1-be4467294e1a","error":null}
not found -> status=404 auth=0 retryable=0 detail={"detail":"Person not found in the pool"}
erasure guard -> leads.erasure: 400 email required — erasure is global and irreversible, so it is never issued against an empty address
batch guard -> leads.save: 400 max 200 ids per call — reveals are metered, split the batchNote what the raw search body shows that the Go and TypeScript clients hide: an unrevealed row carries email_masked and no email at all. A C++ caller has to know that, because nothing in the SDK will tell it.
Gaps
- No method for anything outside email, stats and the leads pool.
- No response types at all. There is no equivalent of Go’s
MailableEmail()or TypeScript’srevealedEmail(): you cannot tell a mask from an address without parsingemail_revealedyourself. - No paging helper — no counterpart to Go’s
WalkLeadsor TypeScript’ssearchAllLeads. Loop onnext_cursorby hand. leadsErasurehas no confirmation argument. Its signature isleadsErasure(email, reason = "gdpr_erasure", note = ""), and the only guard is a non-empty address. Java refuses to act without an explicitconfirmGlobalIrreversibleflag; C++ does not. If you wrap this call, put the confirmation in your own code.
Java
Called live- Path
vxnode/services/sdk/java/src/main/java/io/vxcloud/sdk/- Requires
- JDK 11+
- Dependencies
- None — java.net.http only
- Entry point
VxClient.builder()- Methods
- 21 SalesShift methods plus 4 static helpers
- Typed
- Requests yes, responses no
Same shape as C++ — LeadQuery and LeadUpdate builders for requests, raw JSON String back — with one addition worth knowing about. The four static helpers exist precisely because you would otherwise have to parse the body to explain a failure: leadsAllowanceSpent(e), leadsErased(e), leadsExplain(e) and leadsConvertFromPoolSummary(json).
Two guards C++ does not have
leadsErasure throws unless you pass confirmGlobalIrreversible = true, and assertNotMasked refuses a masked address on the send path — before the request is made. Both fired in the run below.Install
# Maven
mvn -f /path/to/vxnode/services/sdk/java package # target/vxsdk-<version>.jar
# or straight javac — there are only three source files
javac -d out /path/to/sdk/java/src/main/java/io/vxcloud/sdk/*.java SalesShiftExample.java
java -cp out SalesShiftExampleWorked example
// SalesShift — Java SDK example. Every call returns the raw JSON body as a
// String; there are no response types, so the four static helpers exist to
// explain a failure without making the caller parse it.
import io.vxcloud.sdk.VxClient;
import io.vxcloud.sdk.VxException;
import java.util.List;
public class SalesShiftExample {
static String env(String k, String fallback) {
String v = System.getenv(k);
return (v == null || v.isEmpty()) ? fallback : v;
}
// The SDK returns raw JSON, so a demo needs a way to show a slice of it
// without pulling in Jackson.
static void show(String label, String json, int n) {
System.out.println(label + ": " + (json.length() > n ? json.substring(0, n) + " ..." : json));
}
public static void main(String[] args) {
VxClient c = VxClient.builder()
.accessToken(env("VX_ACCESS_TOKEN", ""))
.username(env("VX_USERNAME", ""))
.vxcloudUrl(env("VXCLOUD_URL", "https://api.vxcloud.io"))
.nodeUrl(env("VX_NODE_URL", ""))
.build();
// 1 — dashboard stats
show("stats", c.salesshiftStats(), 180);
// 2 — tenant node email worker
show("worker", c.salesshiftWorkerHealth(), 200);
// 3 — search the pool
VxClient.LeadQuery q = new VxClient.LeadQuery()
.countries(List.of("AU"))
.seniorities(List.of("founder"))
.limit(2);
show("search", c.leadsSearch(q), 320);
// 4 — the reveal meter
show("quota", c.leadsQuota(), 200);
// 5 — facets
show("facets", c.leadsFacets(new VxClient.LeadQuery().countries(List.of("AU"))), 180);
// 6 — this tenant's saved leads
show("saved leads", c.leadsList("", 2), 220);
// 7 — tracked email feed
show("emails", c.salesshiftEmails("sent"), 160);
// 8 — send a tracked email. This delivers a real message.
show("sent", c.salesshiftSendEmail(
"[email protected]",
"Docs example - Java SDK salesshiftSendEmail",
"<p>Sent by VxClient.salesshiftSendEmail while writing the SDK reference.</p>"), 200);
// 9 — leadsExplain turns an exception into a sentence a user can act on
try {
c.leadsPoolPerson("00000000-0000-0000-0000-000000000000");
} catch (VxException e) {
System.out.println("not found -> allowanceSpent=" + VxClient.leadsAllowanceSpent(e)
+ " erased=" + VxClient.leadsErased(e));
System.out.println("explain -> " + VxClient.leadsExplain(e));
}
// 10 — erasure refuses to act without the explicit confirmation flag
try {
c.leadsErasure("[email protected]", "", false);
} catch (VxException e) {
System.out.println("erasure guard -> " + e.getMessage());
}
// 11 — a masked address can never reach the send path
try {
c.salesshiftSendEmail("s•••@kestreldata.example", "hi", "<p>hi</p>");
} catch (VxException e) {
System.out.println("mask guard -> " + e.getMessage());
}
}
}stats: {"contacts":41,"companies":1,"open_deals":22,"open_pipeline_value":762700.0,"active_sequences":2,"open_tasks":23,"email_stats":{"sent":224,"delivered":26,"opened":26,"replied":4,"b ...
worker: {"providers":["smtp"],"rate_limit_domain":20,"redis_connected":true,"service":"salesshift-emailworker","status":"healthy"}
search: {"success":true,"data":{"items":[{"pool_id":"7d2e1a44-90bc-5e17-a3f6-1b8c40d92e55","full_name":"Sofia Ferreira","title":"Founder","seniority":"founder","department":"executive","email_masked":"s•••@kestreldata.example","email_status":"unverified","has_email":true,"phone_count":1,"location":"Melbourne, AU","score":{"value":62}," ...
quota: {"success":true,"data":{"used":4,"allowance":200,"remaining":196,"display":"4 / 200"}}
facets: {"success":true,"data":{"country":[{"value":"AU","count":65}],"department":[{"value":"marketing","count":33},{"value":"sales","count":15},{"value":"executive","count":11},{"value": ...
saved leads: {"success":true,"data":[{"id":"9f13c6a8-52d0-4e71-b384-07ea25f916bc","pool_person_id":"4a7e6d20-9b31-5c48-8e02-1f5b7c93da60","first_name":"Marco","last_name":"Oduya","full_name":"Marco Oduya","title":"Marketing Dir ...
emails: {"success":true,"data":[{"id":"abf005ee-a804-42ce-b659-035af0ee90ca","to_email":"[email protected]","from_email":"[email protected]","contact_id":"a ...
sent: {"success":true,"status":"sent","tracking_id":"f7c8cf8d3dfc4c53b0805cc17b4d7b0f","provider":"smtp","contact_id":"ac38c3bf-d4f2-4704-b3a1-be4467294e1a","error":null}
not found -> allowanceSpent=false erased=false
explain -> Not found - either this person is not in the pool, or that lead does not belong to your organisation.
erasure guard -> salesshift.leads.erasure: refusing to erase without explicit confirmation: this removes the person from the pool for EVERY tenant, strips every org's saved copy, and cannot be undone - pass confirmGlobalIrreversible=true to proceed
mask guard -> salesshift.send_email: that address is a MASK, not a real address - reveal the lead first, then convert it to a contact before sendingGaps
- No method for anything outside email, stats and the leads pool.
- No response types. The static helpers cover the failure cases; success bodies you parse yourself.
- No paging helper.
LeadQuery.hasEmail()and.hasPhone()are sent on the wire, but the server’s filter model does not declare them, so today they are dropped rather than narrowing the result. The SDK’s own javadoc says so; do not rely on them to filter.
How these examples were verified
Every example above was compiled or type-checked, then executed against a running control plane and tenant node, and its output pasted without editing. Five real emails were sent in the process — one per language. The one thing on this page that was not executed is labelled where it appears, not here.
| Language | Toolchain | Built | Ran |
|---|---|---|---|
| Go | go1.22.5 linux/amd64 | go build | All 8 steps, including a real send. |
| Python | CPython 3.12.0 | pip install | All 9 steps, including a real send and a 404. |
| TypeScript | tsc 5.x strict, Node 24.12 | tsc --noEmit esbuild | Type-check clean, then all 8 steps including a real send. |
| C++ | g++ 13, C++17, libcurl | g++ | All 10 steps, including a real send and both guards. |
| Java | javac 17.0.19 | javac | All 11 steps, including a real send and both guards. |
The table is the 2026-08-07 pass, which is where every example outside the entitlements section came from. On 2026-08-13 the free-tier methods were run separately: Go compiled against the module with a replace directive and executed, Python through the same interpreter as the rest of the page, TypeScript through the built dist/. Each ran entitlements, selfHosted and one refused registerNode, and the output in that section is what those runs printed.
Nothing that spends reveal quota was called from an SDK, and no SDK erasure was performed. Nothing activated a plan, registered a node or detached one. Those paths are documented from their source and from the behaviour of the equivalent CLI command, which is labelled accordingly on the vxcli page.