Skip to content
SSalesShiftDocs

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.

PlanPer user, per monthWhere it runs
Self-Hosted$0Your node and your mailboxes. Nothing to buy and nothing that expires.
Starter$105Our relay and our prospect data, with three sending identities. The model key stays yours.
Professional$238The same, with more volume, mailbox rotation and workflow automation.
Organization$357The 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:

Shell
curl -s https://api.vxcloud.io/api/v1/salesshift/billing/entitlements \
  -H "Authorization: Bearer $SS_TOKEN"
200 — real response, unedited
{
  "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
  }
}
The comped Organization workspace these docs were written against, read on 2026-08-13. The pooling rule is visible in the numbers: 100,000 reveals per seat across 25 seats is the 2,500,000 above. null means unlimited, matching the plans API. Two fields have moved since this was captured and are worth reading with that in mind — Organization's monthly send allowance is now uncapped, so emails answers null rather than the finite figure here; and allowance.ai is a stored number that nothing spends, because AI runs on the organization's own key on every plan.

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 connectUsed forWithout it
SMTP / IMAP mailbox, or an OAuth mailboxSending, reply detection, the webmail surfaceDepends 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 enrichmentPOST /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 oneDraft assists, and call transcription and summaryDraft 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 yetInvoice checkout, payments and subscriptionsInvoices, 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 400

So 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. 1

    Get a token

    Shell
    curl -s -X POST https://api.vxcloud.io/api/v1/auth/login \
      -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"your-password"}'
    200 — real response, trimmed
    {
      "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 access for the calls below. Every SalesShift query is filtered by the organization inside that token, so the contact you create next belongs to user.organization.id.

    Shell
    export SS_TOKEN='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
  2. 2

    Create a contact

    Shell
    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"}'
    201 — real response, trimmed
    {
      "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_score came back 26 for a contact with a job title and a work address.

    409 — the same address a second time
    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 409

    Email 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 its id. Search for it with GET /[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. 3

    Send one tracked email

    Shell
    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>"}'
    200 — real response, unedited
    {
      "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 — provider above is smtp, 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 takes to_email, subject and body_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 in error. Check the status rather than the HTTP code.

  4. 4

    Read the tracking row back

    Shell
    curl -s "https://api.vxcloud.io/api/v1/salesshift/emails?limit=1" \
      -H "Authorization: Bearer $SS_TOKEN"
    200 — real response, trimmed
    {
      "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_at and 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:

RouteGives you
GET /calendar/export.icsThe calendar as an ICS file — covered in the calendar guide.
GET /reports/{report_id}/exportA 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:

Two calls — real transcript
# 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,1
42 contacts, which is the same total GET /stats reports. A report is an aggregate: dimensions group and measures count, so this is not a row-per-contact dump. Add contact.email as a dimension to get one row per address.

Two 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

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.