Guide
Sending email
Connect a mailbox and every message leaves from it, authenticated as you, so the reputation you build is yours and the replies land where you already read mail. That is what this guide does, and it is the path every plan supports.
Connect nothing and your plan decides what happens instead: the paid tiers fall back to a relay VxCloud runs, and the free Self-Hosted tier has no relay to fall back to and refuses the send rather than borrowing ours. The fallback is a floor, not a feature — it is covered under When nothing of yours is connected, along with what it gives up.
This guide connects a mailbox, sends one tracked message, and then follows that message all the way through open, click and unsubscribe. Every command below was run against a live server while the guide was written, and the responses are pasted unedited.
Before you start
Every call needs a credential. A JWT from the login endpoint works everywhere, and a workspace API key (X-API-Key: xc_live_…) works on the same routes. The examples use a JWT held in $TOKEN.
TOKEN=$(curl -s -X POST https://api.vxcloud.io/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"…"}' \
| python -c 'import sys,json; print(json.load(sys.stdin)["access"])')
export API=https://api.vxcloud.io/api/v1/salesshiftConnect a mailbox
There are two ways in, and they are not equivalent. Use OAuth if your mailbox is Microsoft 365 or Google Workspace. Use IMAP/SMTP passwords only for providers that still support them.
Why OAuth is not optional for Microsoft
Microsoft has removed Basic authentication for IMAP, POP and EWS, and Basic authentication for SMTP AUTH ends for existing tenants at the end of December 2026. A mailbox password that works against smtp.office365.com today is on a clock. OAuth is the path that keeps working, and it is also the only one where revoking SalesShift’s access does not mean changing your mailbox password.
Option A — OAuth (Microsoft or Google)
Start by asking which providers this deployment has credentials for. This is a real response from the running service:
/webmail/oauth/providerswhich providers are configuredcurl -s "$API/webmail/oauth/providers" -H "Authorization: Bearer $TOKEN"{
"success": true,
"data": [
{
"provider": "microsoft",
"label": "Microsoft 365 / Outlook",
"configured": true,
"missing": [],
"scopes": [
"openid", "profile", "email", "offline_access",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send"
],
"redirect_uri": "https://api.vxcloud.io/api/v1/salesshift/webmail/oauth/callback",
"imap_host": "outlook.office365.com",
"smtp_host": "smtp.office365.com"
},
{
"provider": "google",
"label": "Gmail / Google Workspace",
"configured": true,
"missing": [],
"scopes": ["openid", "profile", "email", "https://mail.google.com/"],
"redirect_uri": "https://api.vxcloud.io/api/v1/salesshift/webmail/oauth/callback",
"imap_host": "imap.gmail.com",
"smtp_host": "smtp.gmail.com"
}
]
}configured: false with a populated missing array means the deployment has no client id or secret for that provider yet.Then mint an authorize URL and send the user’s browser to it.
/webmail/oauth/{provider}/connectcurl -s -X POST "$API/webmail/oauth/microsoft/connect" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"return_to":"/mail"}'{
"authorize_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=5bef7a05-…&response_type=code&code_challenge_method=S256&scope=openid+profile+email+offline_access+…&redirect_uri=https%3A%2F%2Fapi.vxcloud.io%2Fapi%2Fv1%2Fsalesshift%2Fwebmail%2Foauth%2Fcallback&state=eyJhIjoiIiwiZXhwIjoxNzg2MDQyODc4…",
"provider": "microsoft",
"label": "Microsoft 365 / Outlook",
"scopes": [
"openid", "profile", "email", "offline_access",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send"
],
"redirect_uri": "https://api.vxcloud.io/api/v1/salesshift/webmail/oauth/callback",
"expires_in": 900
}code_challenge_method=S256 — the flow is PKCE. The state is HMAC-signed and paired with a one-shot server-side record, so it can be neither forged nor replayed. You have 15 minutes to complete it.The provider redirects back to /webmail/oauth/callback, which is unauthenticated by necessity — a top-level redirect from Microsoft carries no Authorization header. The tenant is recovered from the signed state, not from the session. Tokens are stored in Vault and used for SMTP with AUTH XOAUTH2; refresh is automatic.
Option B — IMAP and SMTP with a password
The credentials are proven before the row is saved: if the login fails, you get an error instead of a mailbox that silently never sends.
/webmail/accountscurl -s -X POST "$API/webmail/accounts" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{
"email": "[email protected]",
"display_name": "Sales",
"password": "…",
"imap_host": "imap.example.com",
"imap_port": 993,
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_security":"starttls",
"is_shared": true
}'Listing the accounts back shows what the system knows about each one. Real output, trimmed to one account:
{
"id": "beff05ad-3833-4063-b388-62522da60ad1",
"email": "[email protected]",
"provider": "internal",
"is_shared": true,
"is_active": true,
"imap_host": "127.0.0.1", "imap_port": 993,
"smtp_host": "127.0.0.1", "smtp_port": 587,
"last_sync_at": "2026-08-06T17:51:36.861540+00:00",
"sync_status": "ok",
"sync_error": null,
"auth_method": "password",
"oauth_provider": null,
"oauth_connected_at": null,
"oauth_token_expires_at": null,
"oauth_error": null,
"token_health": {
"state": "not_applicable",
"message": "",
"expires_at": null,
"expires_in_seconds": null
},
"needs_reconnect": false
}token_health.state is not_applicable for password mailboxes. For OAuth mailboxes it reports how long the grant has left, and needs_reconnect flips to true when the refresh token is gone.Send your first tracked email
One endpoint covers the whole thing. It upserts the contact by email address, then routes through the same engine sequences use — suppression gate, daily caps, sending pool, tracking injection and the event stream. Nothing about a one-off send bypasses the safety rails.
/email/sendcreates the contact if it does not exist- 1
Send it
curl -s -X POST "$API/email/send" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{ "to_email": "[email protected]", "first_name": "Joel", "subject": "SalesShift docs guide - tracked send proof", "body_html": "<p>Hello Joel,</p><p>This message was sent from the docs guide while writing it. <a href=\"https://example.com/pricing\">See pricing</a>.</p>" }'{ "success": true, "status": "sent", "tracking_id": "e9166271d56e4221a9f27c982d536c0b", "provider": "smtp", "contact_id": "613f2aec-14b8-4281-9ced-19fc2c8d38a8", "error": null }successistrueonly whenstatus == "sent"— that is, when the mail server accepted the message. A failed send still returns HTTP 200 withstatus: "failed"and a populatederror, because the tracking row was still created. Check the field, not the HTTP code.providerrecords which path the message actually took, and it is the field to read when you want to know whether your own mailbox was used:provider What sent it smtpA mailbox you connected, sent directly from the API. node-smtpThe same mailbox, handed to the Go send worker. Password mailboxes only — an OAuth mailbox always goes direct, because the worker speaks no XOAUTH2. sendgrid·mailgunA provider integration you configured, on your own key. platformThe platform relay — VxCloud's own sender, under VxCloud's own identity. Reached only when nothing of yours is connected, and only if your plan includes managed sending. refusedNothing of yours was connected and your plan does not include ours. Not a failure — a refusal, described below. - 2
Know what got rewritten
The HTML you supplied is not the HTML that was delivered. Two transformations happen on the way out:
- A 1×1 tracking pixel is appended, pointing at
/t/o/<tracking_id>.png. - Every
http(s)hrefis rewritten through/t/c/<tracking_id>?u=<url-encoded destination>. Deliberately left alone:mailto:andtel:links, in-message anchors, and any URL already pointing at the SalesShift API — wrapping the unsubscribe link would break RFC 8058 one-click.
Merge fields are rendered at this point too. Known tags get a safe fallback; unknown tags are left in the message verbatim, which is how a stray
{{company}}ends up visible to a recipient.Merge tag Falls back to when empty {{first_name}}“there” {{full_name}}“there” {{company_name}}“your company” {{last_name}}empty string {{job_title}}empty string {{email}}empty string {{city}}empty string {{country}}empty string anything else left as literal text — there is no {{company}}, only{{company_name}} - A 1×1 tracking pixel is appended, pointing at
Reading tracking back
The three public tracking endpoints take no credential — they are hit by recipients’ mail clients, which have none. All three were exercised against the tracking id from the send above.
Opens
/t/o/{tracking_id}.pngno authcurl -sD - -o /dev/null \
"$API/t/o/e9166271d56e4221a9f27c982d536c0b.png"
HTTP/1.1 200 OK
cache-control: no-store, max-age=0
content-length: 70
content-type: image/pngno-store so a proxy cannot serve the second open from cache and lose the count.Clicks
/t/c/{tracking_id}?u= destinationcurl -sD - -o /dev/null \
"$API/t/c/e9166271d56e4221a9f27c982d536c0b?u=https%3A%2F%2Fexample.com%2Fpricing"
HTTP/1.1 302 Found
location: https://example.com/pricingThe query parameter is u, not url. Only http and https destinations redirect; anything else is refused rather than turned into an open redirect:
curl -s -o - -w '\nHTTP:%{http_code}\n' \
"$API/t/c/e9166271d56e4221a9f27c982d536c0b?u=javascript%3Aalert(1)"
Invalid link
HTTP:400Confirming both landed
After the two requests above, the tracking row carried the counters. This is the database state, read directly:
tracking_id | to_email | status | open_count | click_count | opened | clicked | provider
---------------------------------+------------------------------+---------+------------+-------------+--------+---------+---------
e9166271d56e4221a9f27c982d536c0b | [email protected] | clicked | 1 | 1 | t | t | smtpThrough the API, the same information comes from the tracked-email feed and the per-contact conversation view:
curl -s "$API/emails?status=sent&limit=20" -H "Authorization: Bearer $TOKEN"
curl -s "$API/conversations/613f2aec-14b8-4281-9ced-19fc2c8d38a8" -H "Authorization: Bearer $TOKEN"Replies
Replies are not webhooks — they are found by polling. Connect a mailbox as an IMAP integration and the poller reads UNSEEN messages every few dispatch ticks, matches the sender against recent tracked sends, marks the row replied and categorises the intent from the sender’s own words.
“Own words” is load-bearing. The quoted original is stripped before categorising, because your own unsubscribe footer coming back in a quote would otherwise file a warm “thanks, we’ll take it” reply as unsubscribed and suppress a live prospect.
| reply_category | Matched on phrases like |
|---|---|
unsubscribed | unsubscribe · remove me · stop emailing |
out_of_office | out of office · auto-reply · annual leave |
not_interested | not interested · not a fit · no thanks |
not_right_person | wrong person · no longer with · left the company |
willing_to_meet | book a call · happy to chat · tell me more |
referral | reach out to · the right person is · cc’d |
follow_up_question | no keyword matched, but the reply contains a question mark |
Bounces and complaints
These arrive from your email provider, not from SalesShift. Point SendGrid, Mailgun or SES at the webhook receivers and hard bounces and spam reports feed the suppression list automatically. That wiring is covered in Webhooks and events.
Unsubscribe
Every tracked message carries a hosted unsubscribe link at /u/<tracking_id>. GET renders a confirmation page; POSTis the RFC 8058 one-click target that Gmail and Outlook hit directly from their own “unsubscribe” button.
curl -s -X POST "$API/u/e9166271d56e4221a9f27c982d536c0b" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'List-Unsubscribe=One-Click'
<h1>You're unsubscribed</h1>
<p><b>[email protected]</b> will not receive further emails from this sender.</p>That writes a suppression row recording where it came from:
{
"id": "6a57e7ce-cddb-4adb-89a7-fb954732c813",
"email": "[email protected]",
"reason": "unsubscribe",
"source": "tracking:e9166271d56e4221a9f27c982d536c0b",
"created_at": "2026-08-06T18:27:43.692728+00:00"
}And the next send to that address is refused:
curl -s -X POST "$API/email/send" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"to_email":"[email protected]","subject":"…","body_html":"<p>…</p>"}'
{"detail":"Cannot send: unsubscribed"}
HTTP:400When nothing of yours is connected
Everything above assumes a mailbox. Without one — no mailbox, no SendGrid or Mailgun integration — what happens next is the one place in the send path where your plan changes the outcome, so it is worth knowing before you meet it in production.
| Your plan | What a send does with nothing connected |
|---|---|
| Managed sending (the paid tiers) | Falls through to the platform relay. The tracking row records provider 'platform'. |
| No managed sending (Self-Hosted, or no subscription at all) | The relay is refused. The row records provider 'refused' with the reason in 'error', and no sequence step is burned on it. |
The organisation daily ceiling
There is a hard ceiling on sends per organisation per day, independent of how many mailboxes you connect or what caps you set on them. It is derived from your plan, not a flat number: dispatch.daily_ceiling_fordivides the plan’s monthly allowance by 20 — a 30-day spread with a 1.5× burst, so a paused weekend can be caught up — and the uncapped tier derives 25,000 × seats instead. 500 is the floor, not the default: the larger of the two always wins, so a 25-seat Organization workspace has a ceiling in the hundreds of thousands, not 500.
Setting SALESSHIFT_ORG_DAILY_CEILINGon the API pins the ceiling to that exact number for every workspace and ignores the plan — including below the floor, because pinning it is how an operator contains an incident. It is notset on this deployment, so the plan-derived figure is the live one. Bring-your-own-key organisations get a ceiling too — it exists to protect a tenant from its own runaway sequence, not to meter them.
Current usage against the ceiling is on the deliverability pool endpoint. Real values from the org used to write this guide:
{
"inbox_count": 6,
"pooled_count": 5,
"sendable_count": 5,
"capacity_today": 250,
"remaining_today": 183,
"org_daily_ceiling": 500,
"org_sent_today": 178,
"next_sender": "[email protected]"
}capacity_todayis the sum of the per-mailbox caps (5 × 50). The ceiling sits above it, so adding mailboxes raises capacity only until it meets org_daily_ceiling. That figure is this workspace’s plan-derived ceiling at capture time — it happens to be the 500 floor here; read your own from this field rather than assuming 500.Hitting the ceiling does not fail a sequence step — it defers it. The rest of that behaviour, and everything about per-mailbox caps, ramp and rotation, is in Deliverability.
Common problems
400 Cannot send: unsubscribed for someone who never unsubscribed
The contact’s email_subscribed flag is false. That flag is set by three things besides the hosted unsubscribe page: a reply categorised as unsubscribed, a provider webhook carrying an unsubscribe event, and a bulk import that brought the flag in as false.
Restoring it takes two steps, and the first alone does nothing. Deleting the suppression row and retrying reproduced the identical error while writing this guide:
# 1. release the suppression
curl -s -X DELETE "$API/settings/suppressions/<suppression_id>" \
-H "Authorization: Bearer $TOKEN"
# 2. clear the contact flag — PUT, not PATCH (PATCH returns 405)
curl -s -X PUT "$API/contacts/<contact_id>" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"email_subscribed": true}'400 There was an error parsing the body on a payload that looks like valid JSON
A non-ASCII character in a curl -dstring — an em dash in a subject line is the usual culprit — gets mangled by the shell before curl sees it. Put the body in a UTF-8 file and send it with --data-binary @file.json. This bit the author of this guide on the first attempt to create a sequence.
The email arrived, but {{company}} is printed literally in it
There is no company merge tag; the field is company_name. Unknown tags are passed through unchanged rather than blanked, so a typo reaches the recipient. Preview a step before enrolling anyone — POST /sequences/{id}/preview renders against a real contact and shows exactly what will go out.
Clicks never register, though opens do
Only http(s) links inside an <a href="…"> are rewritten. A bare URL in text, a button built from a background image, or a link written as <a href='…'> with single quotes is not wrapped, so the click endpoint is never reached and the metric cannot move.
status: "failed" with an authentication error in error
The mailbox could not sign in. For OAuth mailboxes check needs_reconnect and oauth_error on GET /webmail/accounts, then re-consent with POST /webmail/accounts/{id}/reconnect— which is bound to that account id, so the callback refuses a grant for a different address rather than silently repointing a shared mailbox.
Inside a sequence this is treated differently from a bad address: the step is deferred for 30 minutes rather than burned, and the pool rotates to a mailbox that works.
status: "failed" with provider: "refused"
Nothing of yours is connected and your plan does not include the platform relay. The errorstring is the reason, written to be shown to a user unchanged, and it names the fix: connect a mailbox or an SMTP server under Settings → Integrations, or move to a plan with managed sending.
Worth checking first: a workspace with no subscription at all resolves to the free tier, so this is also what a lapsed card looks like on the send path. GET /billing/entitlementstells you which of the two you are in — read status alongside managed.sending.
A send returns HTTP 200 but nothing arrives
Read status and error in the body. sentmeans the SMTP server accepted the message, which is not the same as delivery — the message can still be rejected downstream or filed as spam. If status is sentand the mail never appears, the question is deliverability, not the API: check the domain’s SPF, DKIM and DMARC, then run a seed-list placement test.
Next
Deliverability
Rotation, per-inbox caps and the ramp, health scoring, SPF/DKIM/DMARC and placement tests.
Sequences
Turn one send into a multi-step cadence with variants, windows and reply detection.
Webhooks and events
Wire provider bounce and complaint callbacks into the suppression list.
Plans and self-hosting
Which plans include the platform relay, what a refusal looks like, and how a self-hosted workspace sends instead.
REST API reference
Every parameter, error case and response shape for the routes used above.