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. Every plan runs on VxCloud's own sending infrastructure, prospect data and models, and every plan starts with a 7-day trial that needs no card. Connecting your own mailbox, enrichment key, model key or node is supported throughout and changes what you operate rather than what the API does.
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 paid, and a 7-day free trial that every workspace starts on. All four run on infrastructure VxCloud operates — the relay the mail leaves from, the prospect data, the models behind the assistant and the machines under all three. What separates them is volume, and going past a plan's included volume is metered rather than blocked.
The trial is a clock, not a tier. When it runs out managed.sending, managed.ai and managed.compute all go false together and the three metered allowances go to zero — reads keep working and nothing is deleted, but the paths that cost us money answer 402. Check trial.expired to tell that state apart from a plan that simply does not include a capability.
| Plan | Per user, per month | Where it runs |
|---|---|---|
| Free trial | $0 | The whole platform for 7 days, no card. 2,000 emails, 500 reveals, 200 AI credits, 3 users — totals for the week, not monthly. |
| Starter | $14 | Our relay, prospect data and assistant, with three sending identities. 5,000 emails, 1,000 reveals and 500 AI credits a month. |
| Professional | $39 | The same, with more volume, mailbox rotation, workflow automation and API access. |
| Organization | $89 | The same, uncapped sending, 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. The trial is a flat workspace allowance instead — there are no seats to multiply — and it is the only plan with a user ceiling, at three.
Past a plan's allowance, usage is metered rather than refused: $1.00 per 1,000 emails, $2.00 per 100 prospect reveals, $0.02 per call minute and $10.00 per additional node per month. Those four are the only metered resources — nothing else in the product has a counter on it.
AI credits
One credit is one request to the assistant. Three things to know before you build against it:
Two buckets, and only one of them resets
The plan's monthly allowance is spent first and resets on the 1st (UTC); purchased packs are spent second and never expire. GET /billing/credits returns both separately — included_remaining and purchased_balance — plus total_remaining. A null allowance means unlimited, matching the plans API; rendering it as 0 is the one mistake to avoid.
Top-ups are one-time purchases, not a subscription
POST /billing/credits/checkout with a pack_code returns a Stripe Checkout URL in payment mode. Packs are 1,000 for $10, 5,000 for $45 and 10,000 for $80. Credits land on /billing/credits/confirm or the webhook, whichever arrives first — both use the same idempotency ref, so a pack cannot be credited twice.
Your own key spends nothing
Connect a model key under Settings → Integrations and the assistant uses it instead of ours. Those requests are not metered at all: credits.metered reads false and the balance does not move. You pay your provider directly, at their rate, with nothing added by us.
allowance.ai on /billing/entitlements is the plan's monthly INCLUDED figure and nothing more. It does not know about purchased packs and it does not know what has been spent this month, so it is the wrong field to render as a balance — /billing/credits is the one that answers that. On the trial it reads 0, which is correct and is not a lock-out: the trial's 200 credits are granted once into the purchased bucket at activation rather than allowanced monthly, because a monthly allowance on a seven-day plan is a contradiction.
All three managed.* flags are load-bearing now. They read true on every plan in the catalogue and go false together when a trial expires, so a client should branch on them rather than on a price of zero or a quota of zero.
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 the trial, 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": "trial", "status": "none", "source": "trial", one seat, and a trialblock whose clock is counted from the workspace's own creation date — so an older workspace reads expired: true 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 plans and the node handshake 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. It is a preference on every plan rather than a requirement of any: sending, enrichment and the assistant all have a managed default, and connecting your own account replaces it and stops that usage drawing on your allowance. The table below states what each capability does when nothing is connected — which 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 — an expired trial, or a workspace that has opted into its own node — 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.