Skip to content
SSalesShiftDocs

Reference

SDKs

Five clients — Go, Python, TypeScript, C++ and Java — and one honest answer about each. Go, Python and TypeScript reach the CRM, workflows, sequences, campaigns, the signal pool, platform billing and the Self-Hosted free tier on top of email and leads; C++ and Java still stop at email and leads, and have no billing method of any kind. The table below gives the measured route count for each. Every example on this page was compiled and executed against a live system before it was published.

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.

ClientRoutes reachedOf 251Areas
Go6024%email, leads, contacts, workflows, sequences, campaigns, opportunities, tasks, billing and the free tier, social
Python6124%email, leads, contacts, workflows, sequences, campaigns, opportunities, tasks, billing and the free tier, social
TypeScript6526%email, leads, contacts, workflows, sequences, campaigns, opportunities, tasks, billing and the free tier, social
C++166%email and leads only
Java166%email and leads only

TypeScript used to read 36 here, and that was our mistake

The scanner collected "…" 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 areaGoPythonTypeScriptC++Java
Send a tracked emailcoveredcoveredcoveredcoveredcovered
List tracked emailscoveredcoveredcoveredcoveredcovered
Dashboard statsTypeScript returns an untyped record for this one callcoveredcoveredpartly coveredcoveredcovered
Tenant node email-worker healthcoveredcoveredcoveredcoveredcovered
Pool search — peoplecoveredcoveredcoveredcoveredcovered
Pool search — companiesGo has a separate method; Python, C++ and Java pass a result_typecoveredcoveredcoveredcoveredcovered
Facetscoveredcoveredcoveredcoveredcovered
Reveal, and the reveal metercoveredcoveredcoveredcoveredcovered
Cost preview before a revealPython sync only; not in the async clientcoveredpartly coveredcoveredno methodno method
Bulk reveal helpercoveredno methodno methodno methodno method
Save pool rows as leadscoveredcoveredcoveredcoveredcovered
Saved leads — list, get, updatecoveredcoveredcoveredcoveredcovered
Convert — single, bulk, from poolcoveredcoveredcoveredcoveredcovered
Erasurecoveredcoveredcoveredcoveredcovered
Enrich from a websitecoveredcoveredcoveredcoveredcovered
Saved searchescoveredcoveredcoveredcoveredcovered
Walk every page of a searchcoveredcoveredcoveredno methodno method
Typed response modelsPython, C++ and Java hand back dicts or raw JSON stringscoveredno methodcoveredno methodno method
Contacts — list, get, create, update, deleteThe only records that are mailable; a pool lead is not onecoveredcoveredcoveredno methodno method
Send a tracked one-off to a contactSuppression gate, sending pool, open pixel, unsubscribe footercoveredcoveredcoveredno methodno method
Contact notes, activity timeline and listscoveredcoveredcoveredno methodno method
Workflows — list, create, validate, test-runtest-run defaults to a dry run, so a test cannot mail anyonecoveredcoveredcoveredno methodno method
Workflow enrollment and run historycoveredcoveredcoveredno methodno method
Sequences — create, steps, activate, pause, archivecoveredcoveredcoveredno methodno method
Sequence enrollment, enrollments and analyticsSkips come back with a reason, not just a countcoveredcoveredcoveredno methodno method
Opportunities — the shared signal poolIncluding apply, which records the application and emails the postercoveredcoveredcoveredno methodno method
Tasks — full CRUDcoveredcoveredcoveredno methodno method
Campaigns — create, send, reportPython adds waitForCampaign; sends are asynchronouscoveredcoveredcoveredno methodno 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 eventscoveredpartly coveredpartly coveredno methodno method
Free tier — entitlements, plan activation, self-hosted nodeAll three reach every route of it; no client calls the Stripe webhook, and none shouldcoveredcoveredcoveredno methodno method
Social distributioncoveredcoveredcoveredno methodno method

coveredpartly coveredno 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:

RegistryVersion resolvedHas SalesShift?Checked by
npm @vxcloud/sdk2026.6.15noInstalled from the registry; 62 exports, no Leads, no SalesShift.
PyPI vxsdk2026.6.14noWheel downloaded and opened; no class SalesShift, no search_leads.
proxy.golang.orgv0.1.0-previewnoOnly 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.

LanguageFrom vxcli's credentialsExplicit
Govxsdk.LoadFromVxcli(ctx, opts...)vxsdk.New(ctx, vxsdk.WithJWT(a, r))
Pythonvxsdk.Client.load_from_vxcli()vxsdk.Client(access_token=…)
TypeScriptVxCloud.loadFromVxcli(opts?)new VxCloud({ accessToken })
C++nonevx::ClientOptions{.access_token = …}
JavanoneVxClient.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 live

The 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.

OperationGoPython (sync)TypeScript
Resolved plan, managed flags and allowancesGetEntitlements(ctx)billing_entitlements()entitlements()
Move onto a free planActivatePlan(ctx, code)billing_activate_plan(plan_code)activatePlan(planCode)
Node state and the id it must claimGetSelfHosted(ctx)billing_self_hosted()selfHosted()
Register a nodeRegisterNode(ctx, host)billing_register_node(host)registerNode(host)
Forget it againDetachNode(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

On Self-Hosted the response carries 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.

Go
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 /health
Python
ss = 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"])
TypeScript
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);
Real output — TypeScript entitlements(), printed with JSON.stringify
{
  "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
  }
}
Real output, captured 2026-08-13 against a local backend on localhost:8741, as a comped Organization workspace. Your plan and allowances will differ.

Go returns the same fields with Go zero values where the wire had nullnode_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.

Real output — the same refusal from each client
# 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.
Real output, captured 2026-08-13 against a local backend on localhost:8741, as a comped Organization workspace. Your plan and allowances will differ.

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

Only the read paths and the refusals above were executed. 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

Shell
# 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 tidy

Worked example

main.go
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)
}
Shell
VXCLOUD_URL=https://api.vxcloud.io VX_NODE_URL=https://node1.vxcloud.io go run .
Real output
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=645f9770e6194bae84049f9f85192ca0
Real output, captured 2026-08-07 against a local backend on 127.0.0.1:8741 and a local node on 127.0.0.1:8744. Counts move as the system is used. These predate the entitlement layer: any reveal allowance shown in them (200) is a router constant no plan produces today — the plan now sets it, at 50 on the free tier, and the payload gained an `unlimited` key. See the entitlements section below.

Gaps

  • 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.
  • SearchCompanies is a separate method from SearchLeads because the item types differ. Its doc comment says company results are unpaged and NextCursor is always empty — on the go-node backend used here that was not true, and a company search did hand back a cursor. Read the field, not the comment.
  • WalkLeads and SearchAllLeads are client-side loops over the same paged endpoint, bounded by LeadWalkMaxPages (100) when you pass 0.

Python

Called live
Path
vxnode/services/sdk/python/
Modules
vxsdk (sync, stdlib only) and vxsdk_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

Shell
# 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 httpx

Worked example

example.py
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)
Shell
VXCLOUD_URL=https://api.vxcloud.io VX_NODE_URL=https://node1.vxcloud.io python example.py
Real output
stats: {'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"}
Real output, captured 2026-08-07 against a local backend on 127.0.0.1:8741 and a local node on 127.0.0.1:8744. Counts move as the system is used. These predate the entitlement layer: any reveal allowance shown in them (200) is a router constant no plan produces today — the plan now sets it, at 50 on the free tier, and the payload gained an `unlimited` key. See the entitlements section below.

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 no events, portal, change, cancel, resume or checkout confirmation — those are REST or vxcli only.
  • 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 check email_revealed yourself. Go and TypeScript both have an accessor for exactly this; Python does not.
  • The module reports __version__ == "2026.6.10" while pyproject.toml declares 2026.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

Shell
# 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

example.ts
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);
How this was type-checked and run
# 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.mjs
Real output
stats: { 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
}
Real output, captured 2026-08-07 against a local backend on 127.0.0.1:8741 and a local node on 127.0.0.1:8744. Counts move as the system is used. These predate the entitlement layer: any reveal allowance shown in them (200) is a router constant no plan produces today — the plan now sets it, at 50 on the free tier, and the payload gained an `unlimited` key. See the entitlements section below.

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 or vxcli only. It reaches every other billing route except the Stripe webhook, which no client should call.
  • getStats returns Record<string, unknown> — the one call with no result interface, which is why the example indexes it by string.
  • searchAllLeads is 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 than src/ 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

Shell
# 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 example

Worked example

example.cpp
// 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;
}
Real output (bodies truncated by the example's own show())
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 batch
Real output, captured 2026-08-07 against a local backend on 127.0.0.1:8741 and a local node on 127.0.0.1:8744. Counts move as the system is used. These predate the entitlement layer: any reveal allowance shown in them (200) is a router constant no plan produces today — the plan now sets it, at 50 on the free tier, and the payload gained an `unlimited` key. See the entitlements section below.

Note 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’s revealedEmail(): you cannot tell a mask from an address without parsing email_revealed yourself.
  • No paging helper — no counterpart to Go’s WalkLeads or TypeScript’s searchAllLeads. Loop on next_cursor by hand.
  • leadsErasure has no confirmation argument. Its signature is leadsErasure(email, reason = "gdpr_erasure", note = ""), and the only guard is a non-empty address. Java refuses to act without an explicit confirmGlobalIrreversible flag; 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

Shell
# 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 SalesShiftExample

Worked example

SalesShiftExample.java
// 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());
        }
    }
}
Real output (bodies truncated by the example's own show())
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 sending
Real output, captured 2026-08-07 against a local backend on 127.0.0.1:8741 and a local node on 127.0.0.1:8744. Counts move as the system is used. These predate the entitlement layer: any reveal allowance shown in them (200) is a router constant no plan produces today — the plan now sets it, at 50 on the free tier, and the payload gained an `unlimited` key. See the entitlements section below.

Gaps

  • 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.

LanguageToolchainBuiltRan
Gogo1.22.5 linux/amd64go buildAll 8 steps, including a real send.
PythonCPython 3.12.0pip installAll 9 steps, including a real send and a 404.
TypeScripttsc 5.x strict, Node 24.12tsc --noEmit esbuildType-check clean, then all 8 steps including a real send.
C++g++ 13, C++17, libcurlg++All 10 steps, including a real send and both guards.
Javajavac 17.0.19javacAll 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.