SalesShift documentation
Sales and marketing,
driven from an API.
SalesShift is a CRM and outreach platform. Contacts, prospecting, sequences, deliverability, contracts, quotes and invoicing, calendar, messaging and calls all sit behind one HTTP surface — the same one the product's own interface uses. The paid plans run on VxCloud's own sending infrastructure and prospect data; the free Self-Hosted plan runs the same application on your own node and mailboxes. The model key is yours on every plan — there is no managed model behind any tier.
What you get
One API for the whole funnel
322 SalesShift operations and 57 messaging and call operations across 302 paths, from contact creation to a countersigned PDF.
Your domain, your data
Mail leaves under your own sending identity and your records stay in your workspace. Sending and enrichment run on our infrastructure or on credentials you supply — which of the two is your plan's, and the API is the same either way. AI is always the second kind: it runs on a key you connect.
Documented against a live service
110 of these endpoints were called against a running server while this reference was written. The rest say so, plainly.
Plans, and what runs where
Worth reading before the quickstart, because it decides what some of these calls will answer. There are four plans. Three are paid and run on infrastructure VxCloud operates — the relay the mail leaves from, the prospect data, the node. The fourth, Self-Hosted, is free permanently and runs the same application on your own node and your own mailboxes. It is free because those two are the expensive parts, and on that plan you are the one paying for them. The model key is not a difference between the plans: it is yours on all four, and managed.ai reading true on a paid plan does not change that — nothing in ai_router holds a key of ours to fall back to.
| Plan | Per user, per month | Where it runs |
|---|---|---|
| Self-Hosted | $0 | Your node and your mailboxes. Nothing to buy and nothing that expires. |
| Starter | $105 | Our relay and our prospect data, with three sending identities. The model key stays yours. |
| Professional | $238 | The same, with more volume, mailbox rotation and workflow automation. |
| Organization | $357 | The same, on dedicated infrastructure in the region you choose. |
Allowances on the paid plans are per seat and pool across the workspace, so a number you read back is the per-seat quota multiplied by the seats you bought. Self-Hosted is a flat workspace allowance instead — there are no seats to multiply — and it is the only plan with a user ceiling, at two.
What Self-Hosted asks of you
Three things it is built around. Only one of them is needed to send your first email:
A mailbox or SMTP server — the one thing you actually need
Every message leaves the mailbox you connect. The platform relay is refused on this plan, and the refusal is recorded on the tracking row rather than thrown — see the table below. Connect a mailbox and sending works; nothing else has to be in place first.
A node you run
The vxnode container under your own control, registered in the product under Billing → Self-hosted. Registration is a handshake rather than a text field: the server calls GET {host}/health and refuses the node unless it reports your workspace id back, over HTTPS unless the host is localhost. What registering one changes today is where the Go send worker runs; with no node registered, the same mail goes out through our worker or straight from the API over SMTP, using your credentials either way. It is not a prerequisite for sending.
A model key — the same as on every other plan
OpenAI, Anthropic or a local Ollama, connected under Settings → Integrations. A Gemini key can be stored, but nothing calls it yet and it is refused as the org default. Without a usable one the draft endpoints still answer, with source "template" and a reason. This is not something Self-Hosted gives up: no plan carries a model key of ours, so a paid workspace with nothing connected gets the same template.
What is left is what stays ours to pay for, and that is what the free tier meters: 5,000 prospect reveals a month and 2 users. (A mailbox figure of 2 is published alongside them, but nothing checks it on connect — the enforced ceilings are reveals, contacts and users.) The email and AI allowances read 0 on this plan and that is not a cap — we are not the one sending or paying for tokens, so there is nothing for us to meter. managed.sending and managed.ai being false is what says so; a client that renders those zeros as a limit is reading the wrong field. One asymmetry to be aware of while you are here: managed.sending is load-bearing — the relay really is refused on this plan — but managed.ai is not, because there is no managed model on the plans where it reads true either. Treat a non-zero allowance.ai as a stored number rather than as credit you can spend.
Ask the API which plan you are on
One call answers it, and it is the one to make before you build anything that assumes a capability:
curl -s https://api.vxcloud.io/api/v1/salesshift/billing/entitlements \
-H "Authorization: Bearer $SS_TOKEN"{
"plan_code": "organization",
"plan_name": "Organization",
"status": "active",
"source": "comp",
"seats": 25,
"is_free": false,
"managed": {
"compute": true,
"sending": true,
"ai": true
},
"allowance": {
"emails": 12500000,
"reveals": 2500000,
"ai": 500000,
"mailboxes": null,
"contacts": null,
"users": null
},
"self_hosted": {
"required": false,
"node_host": null,
"verified_at": null,
"ready": true
}
}A workspace with no subscription at all is not locked out — it resolves to Self-Hosted, deliberately, and so does one whose paid plan has lapsed. Read in source rather than captured, because every workspace on this deployment has a plan and producing that response would have meant moving one: the same call answers "plan_code": "self_hosted", "status": "none", "source": "free", one seat, and managed false on all three.
The other fifteen billing operations — the catalogue, the subscription, invoices, Stripe checkout and the self-hosted node handshake — are in the billing domain of the API reference, and Plans and self-hosting is the guide that walks the free tier end to end.
A plan refusal is 402, not 403
When a plan does not include the thing you asked for, the answer is 402 Payment Required. It is not a bug and not a permission problem, and the detail string is written to be shown to the user unchanged. Creating a contact past the stored-contact cap, importing a CSV past it, converting pool rows in bulk and inviting a teammate past the user ceiling all raise it.
Sending and AI report the same refusal in the response body instead of raising, because both run inside loops over many rows: a send that the plan does not cover comes back 200 with a tracking row at status failed and provider refused, so the batch carries on and the reason is on the row; a draft comes back with "source": "template" and the refusal in reason. A client that collapses 402 into 403 loses the difference between “upgrade” and “you may not” — see the API conventions for the rest of the status vocabulary.
Bring your own keys
An organization can put its own accounts behind any capability, and we take no margin on usage billed to them. For sending and enrichment that is a preference on the paid plans and the plan itself on Self-Hosted; for AI it is neither, because there is no managed alternative to prefer over. The table below is written against a deployment with no managed credential provisioned, so it states what each capability does when nothing is connected at all — which now also depends on the plan, so GET /billing/entitlements is the call to make before you assume a row applies to you:
| You connect | Used for | Without it |
|---|---|---|
| SMTP / IMAP mailbox, or an OAuth mailbox | Sending, reply detection, the webmail surface | Depends on the plan. With managed sending the message still goes out, over the platform relay. Without it — Self-Hosted, or any workspace with no subscription — the send is refused rather than queued: 200 with success false, a tracking row at status 'failed', provider 'refused', and the reason in 'error'. A recipient-level refusal (unsubscribed, suppressed, no address) is a different thing and still answers 400 with a “Cannot send:” detail. |
| A Hunter.io key (optional) | Contact enrichment | POST /contacts/enrich falls back to deterministic email-pattern inference instead of a lookup. |
| An AI provider key — required on every plan, not only the free one | Draft assists, and call transcription and summary | Draft endpoints answer 200 with source 'template' and a reason; every recording ends at status 'unavailable'. That is the state of the organization these docs were written against, and of every organization on this deployment — ss_org_integrations holds no rows of type 'ai' at all. |
| Stripe — not connectable yet | Invoice checkout, payments and subscriptions | Invoices, PDFs and manual payments all work. Card checkout does not: POST /invoices/{id}/checkout answers 400, “Connect Stripe under Settings → Integrations first”, and there is currently no way to carry out that instruction. See below. |
Connected providers for the calling organization are listed by GET /settings/integrations. The AI row above is not hypothetical: the organization these docs were written against has three email integrations and no AI provider, which is exactly why call transcription is marked unproven throughout.
Stripe cannot be connected today
The 400 above tells you to connect Stripe under Settings → Integrations. That instruction cannot currently be followed, and it is the server's wording rather than a description of something that works. Settings → Integrations has four groups — email, AI, SMS and enrichment — and no payments group; and the API refuses the connection outright:
$ curl -s -X POST "$API/settings/integrations" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"integration_type":"payment","provider":"stripe",
"credentials":{"secret_key":"sk_test_…"}}'
{"detail":"Unsupported provider: payment/stripe"} # HTTP 400So everything downstream of a Stripe connection — hosted checkout, the payment webhook and Stripe-backed subscriptions — is code that exists and has never run here. Invoicing without Stripe is unaffected: issue, PDF, email and record-payment for bank transfers were all exercised, and are covered in Quotes and invoicing.
Base URL and authentication
Everything lives under two prefixes — /api/v1/salesshift and /api/v1/messaging. The examples in these docs use http://127.0.0.1:8741, the host every verified call was made against; swap in your own.
Bearer JWT most common
Obtained from POST /api/v1/auth/login. The access token in this session's login carried a one-hour lifetime (iat to exp), with a separate refresh token alongside it.
X-API-Key
Accepted by the 296 organization-scoped routes, with the prefixes xc_dev_, xc_live_, xc_test_, xc_stg_, xc_sbx_, xc_prev_. The messaging routes take a JWT only.
app.py constructs FastAPI with docs_url=None, redoc_url=None and openapi_url=None, so /openapi.json and /docs are 404 on the running service. Do not tell readers to browse them.
Quickstart
Four calls: get a token, create a contact, send that contact a tracked email, then read the tracking row back. Every command below was run in this order against http://127.0.0.1:8741 and the responses are what came back.
- 1
Get a token
curl -s -X POST https://api.vxcloud.io/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"[email protected]","password":"your-password"}'{ "user": { "id": 35, "email": "[email protected]", "account_id": "100000000001", "principal_arn": "vxarn:vxcloud:iam::100000000001:user/35", "organization": { "id": "3f7a9c21-5e84-4b16-9d0a-2c6f8e1b7a40", "name": "your-org" } }, "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }Trimmed in three ways: the org's member list is omitted, the two JWTs are truncated, and the identity fields carry placeholders. The field names and the structure are exactly as returned. Keep
accessfor the calls below. Every SalesShift query is filtered by the organization inside that token, so the contact you create next belongs touser.organization.id.export SS_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' - 2
Create a contact
curl -s -X POST https://api.vxcloud.io/api/v1/salesshift/contacts \ -H "Authorization: Bearer $SS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"first_name":"Ada","last_name":"Lovelace", "email":"[email protected]", "job_title":"Analytical Engine Lead"}'{ "id": "d6424c56-66e8-4692-a5ab-e3aeb5d272d1", "organization_id": "3f7a9c21-5e84-4b16-9d0a-2c6f8e1b7a40", "first_name": "Ada", "last_name": "Lovelace", "email": "[email protected]", "job_title": "Analytical Engine Lead", "status": "active", "lifecycle_stage": "lead", "fit_score": 26, "intent_score": 0, "total_score": 16, "emails_sent_count": 0, "email_opens_count": 0, "emails_replied_count": 0, "enrichment_status": "none", "email_subscribed": true, "created_at": "2026-08-06T18:27:24.831374Z" }Trimmed to the fields worth reading; the null columns (phone, company, enrichment payload, tags) are omitted. Scoring is applied on write:
fit_scorecame back 26 for a contact with a job title and a work address.curl -s -X POST https://api.vxcloud.io/api/v1/salesshift/contacts \ -H "Authorization: Bearer $SS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"first_name":"Ada","last_name":"Lovelace", "email":"[email protected]"}' {"detail":"Contact with email [email protected] already exists"} # HTTP 409Email is unique per organization, and a repeat is rejected rather than merged: you get
409 Conflict, not the existing record. So an importer has to look the address up first, or treat 409 as “already have it” and carry on — the response body names the address but does not include itsid. Search for it withGET /[email protected]. One other refusal is worth handling here: past the plan's stored-contact cap the same call answers 402 rather than 409, and it is checked after the duplicate test, so re-saving somebody already in the workspace never trips it. - 3
Send one tracked email
curl -s -X POST https://api.vxcloud.io/api/v1/salesshift/email/send \ -H "Authorization: Bearer $SS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"to_email":"[email protected]","first_name":"Ada", "subject":"Your API quickstart worked", "body_html":"<p>Hi Ada, this email was sent from the SalesShift quickstart.</p>"}'{ "success": true, "status": "sent", "tracking_id": "328ce49ea6314771b831b7092ef2b26f", "provider": "smtp", "contact_id": "d6424c56-66e8-4692-a5ab-e3aeb5d272d1", "error": null }This is the same path a sequence step takes: suppression check, per-inbox cap, the tenant node worker, then tracking.
"status": "sent"means whatever carried the message accepted it —providerabove issmtp, the organization's own connected server. Delivery to the recipient's mail server is a separate matter, and deliverability is where that is dealt with. The endpoint takesto_email,subjectandbody_html; without all three it returns 400. On a plan without managed sending and with no mailbox connected, this same call still answers 200 — with"status": "failed","provider": "refused"and the reason inerror. Check the status rather than the HTTP code. - 4
Read the tracking row back
curl -s "https://api.vxcloud.io/api/v1/salesshift/emails?limit=1" \ -H "Authorization: Bearer $SS_TOKEN"{ "success": true, "data": [ { "id": "7fabe545-3b83-4c1f-9aa9-2eb43a466f2c", "to_email": "[email protected]", "from_email": "[email protected]", "contact_id": "d6424c56-66e8-4692-a5ab-e3aeb5d272d1", "contact_name": "Ada Lovelace", "subject": "Your API quickstart worked", "status": "sent", "provider": "smtp", "open_count": 0, "click_count": 0, "error": null, "reply_body": null, "reply_category": null, "sent_at": "2026-08-06T18:27:44.727661+00:00", "first_opened_at": null, "replied_at": null, "created_at": "2026-08-06T18:27:44.730208+00:00" } ] }body_html is omitted here for width and from_email carries a placeholder; every other value is as returned. One row per outbound message, carrying the engagement state:
open_count,click_count,first_opened_at,replied_atand the reply body once one arrives.from_emailis the organization's own sending identity, not a shared pool.
That is the whole loop
Everything else is a variation on it: a sequence is steps and enrollments around the same send path, a campaign is one send to a resolved audience, and a contract or invoice is the same contact with a document attached to it.
Getting your data out
Worth knowing before you commit, and easy to assume wrongly. There is no /contacts/export, /deals/export or per-object CSV endpoint anywhere in the API. Searching every path in the inventory for export, csv or download returns exactly two routes:
| Route | Gives you |
|---|---|
| GET /calendar/export.ics | The calendar as an ICS file — covered in the calendar guide. |
| GET /reports/{report_id}/export | A saved report's rows as text/csv, with a Content-Disposition attachment filename. |
The second one is the general answer, because a report can be built over any of ten datasets — contacts, deals, quotes, invoices, payments, subscriptions, MRR, email activity, tasks and campaigns. Create a report, then export it. Both calls below were run while writing this section:
# 1. Save a report over the dataset you want out
curl -s -X POST "$API/reports" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Contacts by lifecycle stage","dataset":"contacts",
"config":{"dimensions":[{"field":"contact.lifecycle_stage"}],
"measures":[{"field":"contact.id","agg":"count"}]}}'
{"success":true,"data":{"id":"0cbc621c-…","dataset":"contacts", …}} # HTTP 201
# 2. Export it
curl -s -D - "$API/reports/0cbc621c-…/export" -H "Authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
content-type: text/csv; charset=utf-8
content-disposition: attachment; filename="Contacts by lifecycle stage.csv"
Lifecycle stage,Count of Contact count
lead,37
sql,2
customer,1
mql,1
opportunity,1Two things this does not give you. It is an aggregate queryrather than a raw table dump, so a full-fidelity backup of every field on every record is not something the API offers today. And there is no mailbox export: what SalesShift holds of the mail is the tracking row — one per outbound message, body included, on GET /emails— and where you connected a mailbox, that mailbox stays yours on your own server or provider, with nothing to migrate out of ours.
Where to go next
REST API reference
All 379 operations, grouped by domain, with parameters, error cases and a copyable curl for each.
vxcli
The command-line surface — the fastest way to try a call without writing a client.
SDKs
Python, TypeScript, Go, C++ and Java clients, and an honest map of which endpoints each one covers.
Guides
Task-shaped walkthroughs: plans and self-hosting, sending, deliverability, sequences, leads, contracts, quotes, calendar and webhooks.
How to read these docs
The reference is generated from an inventory of the running service, and each endpoint states how much is known about it. Nothing is described as working because it looks like it should.
live-callCalled against http://127.0.0.1:8741 during this inventory run; the `verified` block holds the request and the response.session-verifiedThe feature was verified working against the live system in the session that produced this inventory, but this specific route was not re-called here.code-onlyRead in source for this inventory and not executed. Undemonstrated is not the same as broken — but do not write it up as proven.known-unprovenOne of the two items in known_unproven.
Two capabilities are fully built and still unproven here — OAuth mailbox connect, and call transcription. Both are called out where they appear, with what was actually observed. See the API conventions for the full statements.