Guide
Deliverability
Sending mail is easy. Having it arrive is the product. This is the part of SalesShift that decides which mailbox each message leaves from, how many any one mailbox may send today, when a mailbox is hurting you enough to be pulled out of rotation, and whether your domain is even authenticated well enough to be let through.
Every threshold quoted here was read from the running service, not from a design document. Every check was executed while the page was written and the output pasted unedited — including a live SPF parse, live DNSBL queries, and a seed-list placement test that measured a real folder.
The sending pool
The pool is mailboxes you connected. One mailbox is a demo. A business needs several, rotated, each with its own ceiling, each watched, and any one of them able to drop out of the rotation the moment it starts hurting the domain.
/deliverability/inboxespool state, per-mailbox detail and the live formulacurl -s "$API/deliverability/inboxes" -H "Authorization: Bearer $TOKEN"{
"rotation_enabled": true,
"rotation_status": "on — rotating across 5 mailbox(es)",
"inbox_count": 6,
"pooled_count": 5,
"sendable_count": 5,
"paused_count": 0,
"needs_reconnect_count": 0,
"capacity_today": 250,
"remaining_today": 183,
"org_daily_ceiling": 500,
"org_sent_today": 178,
"next_sender": "[email protected]",
"next_sender_blocked_reason": null,
"next_retry_after": null
}next_senderis not a guess — it is the mailbox the selector would actually choose for the next send, computed by the same pure function the dispatcher calls.
How rotation picks a mailbox
Selection is least normalised usage. For every eligible mailbox the selector computes
usage = sent_today / (effective_cap × weight × health_multiplier)and the lowest usage wins. Weighted round-robin falls out of that for free, and it interleaves smoothly rather than in bursts: a mailbox with twice the allocation is picked twice as often, not twice in a row. The function is pure — no session, no clock, no randomness — so two runners given the same inputs make the same choice.
| Term | Value | Meaning |
|---|---|---|
weight | 1.0 | Relative share of the pool. Set to 0 to take a mailbox out of service without pausing it. |
health_multiplier | score / 100, floored at 0.25 | A degraded mailbox keeps sending, but less. It never drops below a quarter of its allocation on health alone. |
| Unmeasured health | 90.0 | “No evidence” is treated as neutral — not as perfect, and not punished like a proven bad record. |
Each mailbox reports its own selector verdict:
{
"id": "66a344eb-4f76-4f67-b4d1-cf631d666fc7",
"email": "[email protected]",
"in_pool": true,
"weight": 1.0,
"daily_cap": 50,
"effective_cap_today": 50,
"sendable": true,
"sent_today": 4,
"remaining_today": 46,
"send_gap_seconds": 0,
"ramp": { "start_date": null, "initial": 10, "daily_increment": 5,
"day": null, "complete": false },
"auth": { "method": "password", "connected": true, "blocked_reason": null },
"needs_reconnect": false,
"paused": false,
"health": {
"score": null,
"grade": "insufficient_data",
"window_sent": 4, "bounced": 0, "complained": 0, "failed": 0,
"replied": 0, "opened": 1,
"bounce_rate": 0.0, "complaint_rate": 0.0,
"reply_rate": 0.0, "open_rate": 0.25
},
"selector": { "eligible": true, "reason": "eligible", "usage": 0.0889,
"retry_after": null }
}Caps and the ramp
Defaults are per organisation and visible at GET /deliverability/settings:
{
"rotation_enabled": true,
"health_window_days": 30,
"defaults": {
"daily_cap": 50,
"ramp_initial": 10,
"ramp_daily_increment": 5,
"send_gap_seconds": 0
}
}Providers score a domain on its trend. A mailbox that goes from nothing to hundreds in a day is the single most reliable way to get filtered, so a new mailbox starts at ramp_initial and climbs by ramp_daily_increment until it reaches its daily_cap.
Where warm-up is enabled on a mailbox, a second ceiling applies on top, keyed to the mailbox’s age in whole weeks:
| Weeks since the mailbox was created | Cap |
|---|---|
| Week 0 | 5 |
| Week 1 | 15 |
| Week 2 | 25 |
| Week 3 and after | 40 |
Adjust one mailbox with a PATCH:
/deliverability/inboxes/{inbox_id}curl -s -X PATCH "$API/deliverability/inboxes/<inbox_id>" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"daily_cap": 80, "weight": 2.0, "send_gap_seconds": 45}'Health scoring and auto-pause
Health is computed per mailbox from telemetry the tracking table already carries, over a rolling 30-day window. The service publishes the whole formula alongside the scores, so nothing here is reverse-engineered:
{
"base_score": 90.0,
"window_days": 30,
"min_sample_for_score": 20,
"penalties": {
"complaint_rate": {
"max_points": 40.0, "limit": 0.003,
"why": "Google/Yahoo/Microsoft bulk-sender ceiling is 0.30%"
},
"bounce_rate": {
"max_points": 35.0, "limit": 0.05,
"why": "sustained >5% reads as a stale or purchased list"
},
"failure_rate": {
"max_points": 15.0, "limit": 0.1,
"why": "our transport failed; the recipient never saw it"
}
},
"engagement_bonus": {
"max_points": 10.0, "target": 0.05,
"formula": "reply_rate + 0.2 * open_rate",
"why": "opens are inflated by image proxies, replies are not"
},
"grades": { "excellent": 85.0, "good": 70.0, "at_risk": 50.0 },
"auto_pause": {
"complaint_rate": { "limit": 0.003, "min_events": 2 },
"bounce_rate": { "limit": 0.05, "min_events": 5 },
"resume": "manual only — POST /deliverability/inboxes/{id}/resume"
}
}Reading that as a set of rules:
- A mailbox starts at 90, not 100. Perfect is unmeasured, not assumed.
- Below 20 sendsin the window, no score is produced at all — the grade is
insufficient_data. Three bounces out of four sends is not a 75% bounce rate worth acting on. - Complaints cost the most (up to 40 points), then bounces (35), then our own transport failures (15). The ordering is deliberate: a complaint means a human marked you as spam, a bounce means a bad address, a transport failure means the recipient never saw anything.
- Engagement can add up to 10 points back, and a reply is worth five times an open in that formula.
What actually pauses a mailbox
Auto-pause is separate from the score, and it needs a minimum number of real events so a tiny sample cannot trip it:
| Trigger | Threshold | Minimum events |
|---|---|---|
| Complaint rate | > 0.30% (0.003) | 2 complaints |
| Bounce rate | > 5% (0.05) | 5 bounces |
Pausing and resuming, executed
Pausing one mailbox and re-reading the pool shows rotation adapting immediately. Both calls below were run in sequence:
curl -s -X POST "$API/deliverability/inboxes/66a344eb-…/pause" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"reason":"docs walkthrough"}'
{"success":true,"data":{"id":"66a344eb-…","paused":true,"reason":"docs walkthrough"}}sendable 4 paused 1 capacity 200 remaining 126
next_sender [email protected] ← rotated away from the paused box
pause {"at": "2026-08-06T18:43:50.283967+00:00", "kind": "manual",
"reason": "docs walkthrough",
"details": {"paused_by": "[email protected]"}}
selector {"eligible": false, "reason": "paused:docs walkthrough"}{
"success": true,
"data": {
"id": "66a344eb-4f76-4f67-b4d1-cf631d666fc7",
"paused": false,
"acknowledged_by": "[email protected]",
"note": "Health is re-evaluated on the next pass; if the rates are still over the limit the mailbox will auto-pause again."
}
}Domain authentication
Rotation and caps are wasted if receivers cannot verify you. The domain checker resolves real DNS and parses what it finds — it does not pattern-match strings.
/deliverability/domainsevery sending domain, summarised{
"domains": [
{ "domain": "example.com", "checked_at": "2026-08-06T15:11:43Z",
"issues": [], "status": "checked" },
{ "domain": "example.net", "checked_at": "2026-08-06T13:56:23Z",
"status": "checked",
"issues": [
{ "code": "spf_missing", "severity": "critical",
"message": "No SPF record. Receivers cannot verify that your sending servers are authorised." },
{ "code": "dkim_missing", "severity": "critical",
"message": "No DKIM key found on the selectors checked. Bulk senders to Gmail and Yahoo are required to sign with DKIM." },
{ "code": "dmarc_missing", "severity": "critical",
"message": "No DMARC record. Gmail and Yahoo require at least p=none for bulk senders." },
{ "code": "mx_missing", "severity": "warning",
"message": "No MX record. Bounces and replies to this domain cannot be delivered." }
] }
]
}SPF and the ten-lookup limit
RFC 7208 caps an SPF evaluation at tenDNS-querying mechanisms. Exceeding it is not a warning that degrades gracefully — the evaluation returns permerror, and receivers treat a permerror as no usable SPF at all. Adding one more SaaS vendor to a long include: chain is the classic way a domain that authenticated fine yesterday silently stops today.
The checker counts the lookups for you and expands includes rather than guessing. Executed live against a real domain:
/deliverability/domain/{domain}?refresh=true forces a fresh resolve{
"lookup_ok": true,
"found": true,
"record": "v=spf1 mx ip4:192.0.2.25 ~all",
"record_count": 1,
"multiple_records": false,
"mechanisms": [
{ "term": "mx", "kind": "mx", "qualifier": "+", "costs_lookup": true },
{ "term": "ip4:192.0.2.25", "kind": "ip4", "qualifier": "+", "costs_lookup": false },
{ "term": "~all", "kind": "all", "qualifier": "~", "costs_lookup": false }
],
"includes": [],
"all_qualifier": "~",
"policy": "softfail",
"dns_lookups": 1,
"lookup_limit": 10,
"exceeds_lookup_limit": false,
"expansion_complete": true,
"expansion_errors": []
}costs_lookup per mechanism is what makes this actionable: ip4: and ip6: are free, while include:, a, mx, ptr and exists each spend one of your ten.multiple_records: trueis fatal on its own — two SPF TXT records is a permerror regardless of what they say.all_qualifierof~is softfail,-is hardfail. Softfail while you are still finding stray senders; hardfail once you are confident.- To get under the limit, replace
include:chains with theip4:ranges they resolve to, or drop vendors that no longer send for you.
DKIM
Bulk senders to Gmail and Yahoo are required to sign with DKIM. The checker probes 23 common selectors, so you do not have to know which one your provider uses:
{
"lookup_ok": true,
"found": true,
"selectors_checked": [
"default","google","selector1","selector2","s1","s2","mail","dkim","k1",
"mailcow","fm1","fm2","fm3","zoho","mandrill","hs1","hs2","protonmail",
"mailjet","sendgrid","smtp","key1","mxvault"
],
"found_selectors": ["dkim"],
"selectors": [
{ "selector": "dkim",
"host": "dkim._domainkey.example.com",
"found": true,
"key_type": "rsa",
"key_bits": 2048,
"testing": false,
"revoked": false }
],
"errors": []
}testing: true (a t=yflag tells receivers to ignore failures — fine while setting up, useless in production) and key_bits below 1024.DMARC
{
"found": true,
"host": "_dmarc.example.com",
"record": "v=DMARC1; p=quarantine; rua=mailto:[email protected]; fo=1",
"record_count": 1,
"multiple_records": false,
"policy": "quarantine",
"subdomain_policy": "",
"effective_subdomain_policy": "quarantine",
"percent": 100,
"enforcing": true,
"rua": ["mailto:[email protected]"],
"ruf": [],
"parse_errors": []
}Gmail and Yahoo require at least p=none for bulk senders, which is the floor, not the goal. effective_subdomain_policy is worth a look: with no sp= tag, subdomains inherit the parent policy, so publishing p=reject quietly applies it to every subdomain that sends mail too.
MX and blocklists
Missing MX is a warning rather than an error — you can send without it — but bounces and replies to that domain cannot be delivered, which means reply detection silently sees nothing.
Blocklist checks resolve your domain to its addresses and query real DNSBL zones. Executed live:
{
"domain": "example.com",
"resolved_ips": [
"2001:db8:5f:65e1:b91f:b39f:c4f9:2ea7",
"2001:db8:60:beab:b667:209:7914:422b",
"192.0.2.190",
"192.0.2.220"
],
"targets": [
{ "target": "2001:db8:5f:65e1:…", "kind": "ip",
"status": "clean", "listed": 0, "clean": 1, "unknown": 0, "unsupported": 10,
"zones": [
{ "zone": "zen.spamhaus.org", "name": "Spamhaus ZEN",
"status": "clean", "elapsed_ms": 124 },
{ "zone": "bl.spamcop.net", "name": "SpamCop", "status": "unsupported",
"reason": "SpamCop does not index IPv6 addresses, so it has nothing to say about 2001:db8:5f:65e1:…" }
] }
]
}unsupported is not clean. Most DNSBLs index IPv4 only, so an IPv6-only host genuinely cannot be cleared by them — and the check says so rather than reporting a pass it did not earn.Seed lists and placement testing
Authentication tells you whether a receiver can verify you. Placement tells you where the message actually landed. A seed list is a set of addresses SalesShift sends a probe to; where the seed is also a mailbox connected to your organisation, SalesShift can log in and read which folder it arrived in.
/deliverability/seed-list{
"seeds": [
{ "id": "875d2ea0-b96a-4422-9de4-085f109da35a",
"email": "[email protected]",
"provider": "internal",
"is_active": true,
"notes": "connected mailbox - folder readable",
"placement_readable": true }
],
"configured": true,
"readable_count": 1,
"note": "Folder placement is measured only for seed addresses that are also mailboxes connected to this organization — those are the only ones SalesShift can log into and read. Other seeds are recorded as sent, with placement unknown."
}Add a seed, then run a test:
curl -s -X POST "$API/deliverability/seed-list" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"email":"[email protected]","notes":"gmail seed"}'
curl -s -X POST "$API/deliverability/placement-test" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'{
"id": "2355ab78-6099-4ac0-9cf8-b1fed59827ab",
"status": "sent",
"from_email": "[email protected]",
"subject": "SalesShift placement probe 3f7a9c21-1786041750",
"seed_count": 1, "sent_count": 1, "failed_count": 0,
"measured_count": 1, "inbox_count": 1, "spam_count": 0,
"note": "1/1 probe(s) sent. Folder placement measured for 1 seed(s).",
"results": [
{ "email": "[email protected]",
"provider": "internal",
"sent": true, "error": null,
"connected_mailbox": true,
"placement": "inbox",
"folder": "INBOX",
"placement_source": "imap_read",
"placement_note": null }
]
}And a test that could not measure placement says so instead of implying a pass. This is a real earlier run from the same org:
{
"placement": null,
"placement_source": null,
"placement_note": "Not found in the seed mailbox after 30s — either still in transit or rejected before delivery. Placement not measured."
}When an inbox pauses
- 1
Find out which rule fired
curl -s "$API/deliverability/inboxes" -H "Authorization: Bearer $TOKEN"The mailbox’s
pauseobject carrieskind(manualor automatic), the reason, and when. Itshealthblock carries the counts behind that decision —bounced,complained,failedandwindow_sent. - 2
Fix the cause, not the symptom
- Complaints.Two complaints is enough to trigger this, so the sample is small — but the ceiling is 0.30% and complaints are the one signal receivers weigh most. Look at who was mailed and why they would not expect it. Tighten targeting before you resume.
- Bounces. Sustained bounces above 5% read as a stale or purchased list. Verify addresses before enrolment; a lead-pool address with
email_status: "guessed"is a guess, not a verified address. - Transport failures.These are ours, not the recipient’s. Check
sync_statusandneeds_reconnecton the mailbox — an expired OAuth grant shows up here.
- 3
Resume deliberately
curl -s -X POST "$API/deliverability/inboxes/<inbox_id>/resume" \ -H "Authorization: Bearer $TOKEN"Health is recomputed on the next pass. If the rates are still over the limit the mailbox pauses again, which is the system working, not a bug. Consider lowering
daily_capand letting the ramp climb again rather than resuming at full volume.
Common problems
Everything says insufficient_data and no mailbox has a score
Scoring needs at least 20 sends in the 30-day window. Below that the grade is insufficient_data and score is null— correctly, because three bounces out of four sends is not a 75% bounce rate. Rotation still works: unmeasured health is treated as the neutral 90.
SPF looks fine to the eye but mail still fails authentication
Check dns_lookups against lookup_limit and multiple_records. Eleven lookups is a permerror, and so is a second SPF TXT record on the same name — neither shows up as a malformed-looking record. Also confirm the domain you are checking is the one in the SMTP envelope sender, since SPF authenticates that, not the visible From:.
A blocklist target reports unsupported on every zone
The address resolved to IPv6 and most DNSBLs index IPv4 only. That is a genuine gap in coverage, not a clean result. Check the IPv4 addresses in resolved_ips separately, and be aware that many receivers apply stricter rules to IPv6 senders precisely because reputation data is thinner there.
A placement test reports placement: null
Read placement_note— it distinguishes the cases. The common ones: the seed is not a mailbox connected to this organisation (so there is nothing to log into and read), or the probe had not arrived within the 30-second wait. Re-run rather than assuming a failure; a slow first hop is not a spam verdict.
Sends stop although no mailbox is paused
Compare org_sent_today with org_daily_ceiling. The organisation ceiling is independent of per-mailbox capacity, and hitting it defers sequence steps with a reason containing ceiling. Check next_sender_blocked_reason and next_retry_after on the pool endpoint for the specific verdict.
Health numbers look stale immediately after a big send
Health is recomputed on a floor of 60 seconds. The response tells you outright rather than pretending to be current:
{
"computed_at": "2026-08-06T18:23:28.734966+00:00",
"age_seconds": 86,
"fresh": false,
"refreshing": true,
"refresh_floor_seconds": 60,
"note": "Health was computed 86s ago; a refresh is running now and the next load will show it."
}Next
Sending email
Connect the mailboxes that fill the pool, and read tracking back out.
Sequences
How caps, pauses and the pool interact with a running multi-step cadence.
Webhooks and events
Feed provider bounce and complaint events into health scoring automatically.
Plans and self-hosting
Whether sending runs on VxCloud's relay or entirely on your own mailboxes, and how many sending identities each plan allows.
REST API reference
All 13 deliverability routes, parameter by parameter.