Skip to content
SSalesShiftDocs

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.

Executed end to endLive API: 127.0.0.1:8741OAuth consent hop unproven

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.

Get a 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/salesshift

Connect 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:

GET/webmail/oauth/providerswhich providers are configured
Shell
curl -s "$API/webmail/oauth/providers" -H "Authorization: Bearer $TOKEN"
Real response
{
  "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.

POST/webmail/oauth/{provider}/connect
Shell
curl -s -X POST "$API/webmail/oauth/microsoft/connect" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"return_to":"/mail"}'
Real response (authorize_url truncated)
{
  "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.

POST/webmail/accounts
Shell
curl -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:

Real response — GET /webmail/accounts
{
  "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.

POST/email/sendcreates the contact if it does not exist
  1. 1

    Send it

    Shell
    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>"
      }'
    Real response
    {
      "success": true,
      "status": "sent",
      "tracking_id": "e9166271d56e4221a9f27c982d536c0b",
      "provider": "smtp",
      "contact_id": "613f2aec-14b8-4281-9ced-19fc2c8d38a8",
      "error": null
    }

    success is true only when status == "sent" — that is, when the mail server accepted the message. A failed send still returns HTTP 200 with status: "failed" and a populated error, because the tracking row was still created. Check the field, not the HTTP code.

    provider records which path the message actually took, and it is the field to read when you want to know whether your own mailbox was used:

    providerWhat 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. 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) href is rewritten through /t/c/<tracking_id>?u=<url-encoded destination>. Deliberately left alone: mailto: and tel: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 tagFalls 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 elseleft as literal text — there is no {{company}}, only {{company_name}}

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

GET/t/o/{tracking_id}.pngno auth
Executed
curl -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/png
70 bytes of transparent PNG, and no-store so a proxy cannot serve the second open from cache and lose the count.

Clicks

GET/t/c/{tracking_id}?u= destination
Executed
curl -sD - -o /dev/null \
  "$API/t/c/e9166271d56e4221a9f27c982d536c0b?u=https%3A%2F%2Fexample.com%2Fpricing"

HTTP/1.1 302 Found
location: https://example.com/pricing

The query parameter is u, not url. Only http and https destinations redirect; anything else is refused rather than turned into an open redirect:

Executed — scheme guard
curl -s -o - -w '\nHTTP:%{http_code}\n' \
  "$API/t/c/e9166271d56e4221a9f27c982d536c0b?u=javascript%3Aalert(1)"

Invalid link
HTTP:400

Confirming both landed

After the two requests above, the tracking row carried the counters. This is the database state, read directly:

Real row
tracking_id                      | to_email                     | status  | open_count | click_count | opened | clicked | provider
---------------------------------+------------------------------+---------+------------+-------------+--------+---------+---------
e9166271d56e4221a9f27c982d536c0b | [email protected] | clicked |          1 |           1 | t      | t       | smtp
A first click promotes status to 'clicked'. Richer states are never downgraded back to 'delivered' by a late provider webhook.

Through the API, the same information comes from the tracked-email feed and the per-contact conversation view:

Shell
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_categoryMatched on phrases like
unsubscribedunsubscribe · remove me · stop emailing
out_of_officeout of office · auto-reply · annual leave
not_interestednot interested · not a fit · no thanks
not_right_personwrong person · no longer with · left the company
willing_to_meetbook a call · happy to chat · tell me more
referralreach out to · the right person is · cc’d
follow_up_questionno keyword matched, but the reply contains a question mark
Matching is heuristic — sender address to the most recent tracked send. Message-ID threading is not in this path.

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.

Executed — the one-click POST
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:

Real response — GET /settings/suppressions
{
  "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:

Executed
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:400

When 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 planWhat 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.
A workspace with no subscription resolves to the free tier rather than to a lock-out, so it lands in the second row too.

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:

Real response — GET /deliverability/inboxes (excerpt)
{
  "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:

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