Warm-up
Warm-up paces a sender's outbound volume up a ramp instead of letting it blast from day one. A brand-new WhatsApp number that sends 5,000 messages on its first day gets throttled by the provider or banned outright, and the damage is to the number, not the campaign — you cannot undo it by sending more slowly next week.
The engine in warmup-engine.ts is pure, channel-agnostic and framework-free: no database, no
vendor. The ramp is a schedule, quality is a multiplier, and the provider tier cap is an optional
input the caller supplies. The same maths paces any channel.
Is warm-up on?
The cascade for "does warm-up apply here, and with which profile":
| Order | Source | |
|---|---|---|
| 1 | The connection's own state | supplies the live qualityRating and providerTierCap |
| 2 | WarmupChannelSetting for (tenantId, channel) |
the workspace's own choice |
| 3 | WarmupChannelSetting for (null, channel) |
the global default for that channel |
| 4 | DEFAULT_CHANNEL_ENABLED |
the built-in code default |
| 5 | — | OFF |
With no rows at all the code default enables whatsapp and whatsapp_session and disables sms
and email. A channel not listed is treated as disabled. So the common case — WhatsApp, nothing
configured — warms out of the box with zero seeding, and any level can be overridden without a
migration.
The profile resolves the same way: the setting's profileId, else the WarmupProfile flagged
isGlobalDefault, else the built-in DEFAULT_WARMUP_PROFILE.
The default ramp
Seven days, from warmup-defaults.ts:
| Day | Unique recipients/day | Batch size | Gap (min) |
|---|---|---|---|
| 1 | 100 | 20 | 30 |
| 2 | 200 | 50 | 30 |
| 3 | 500 | 50 | 30 |
| 4 | 750 | 100 | 30 |
| 5 | 1000 | 100 | 30 |
| 6 | 1500 | 150 | 45 |
| 7 | 2500 | 250 | 45 |
Plus qualityModifiers: { HIGH: 1, MEDIUM: 0.5, LOW: 0, UNKNOWN: 0.25 },
graduationMinDeliveryRate: 0.75, graduationMinSampleSize: 50, waveSettleTimeoutHours: 12.
batchSize and gapMinutes pace within a wave. When a schedule row carries both gapMinutesMin and
gapMinutesMax, the gap is randomised in that range — a perfectly regular cadence is exactly the
signature a provider's spam heuristics look for.
The cap
resolveEffectiveCap = floor(dayCap × qualityModifier(rating)) // while warming
= min(that, providerTierCap) // if a tier cap applies
= floor(providerTierCap × modifier) // once graduated
No tier cap means unlimited, which the service renders as null.
Gotcha: an absent rating is not UNKNOWN
A rating that is present but unrecognised falls to the profile's UNKNOWN weight (0.25) — that is a
real signal we cannot interpret, and pacing down is the safe reading.
No rating at all returns 1.0, not 0.25:
const raw = rating && rating !== '' ? rating.toUpperCase() : null;
if (raw === null) return 1;
This diverges from axis-api on purpose. axis-api could treat absent as UNKNOWN because it refreshed
quality from the provider every six hours, so "absent" lasted minutes. Here nothing populates it yet,
and applying the penalty pinned every sender at floor(dayCap × 0.25) — a permanent 75% cut for
senders whose quality had simply never been measured. Unmeasured means "no adjustment". The day's
step cap still applies, and a real rating still scales it the moment one arrives.
Waves, not the calendar
planWave carves one wave off a recipient count:
const { batches, taken, leftover } = planWave(profile, state, day, recipientCount);
leftover is the load-bearing half. Recipients beyond the cap are not rejected — they stay
pending and roll into the next window. This is the difference between a sender pacing itself and a
campaign silently losing three quarters of its audience: before the planner existed, a campaign
either blasted everything at once or marked the excess permanently failed, and because sent > 0
the campaign was then labelled completed so the loss never surfaced.
A campaign whose wave deferred re-enqueues in 24 hours, because the cap is a daily unique-recipient budget — resuming sooner would just re-hit the same ceiling.
Advancement is per settled wave, never by calendar day. warmupDay is a step counter, not a date
offset. A sender that sends nothing for a month is still on day 1: it has produced no evidence that it
can be trusted with more.
const rate = outcome.attempted > 0 ? outcome.delivered / outcome.attempted : 0;
const meetsGate =
outcome.attempted >= profile.graduationMinSampleSize && // ≥ 50
rate >= profile.graduationMinDeliveryRate; // ≥ 0.75
A wave that does not meet the gate leaves the state untouched — no advance, no penalty, settle again
next send. Once warmupDay passes the schedule's highest day, graduatedAt is stamped and the
provider tier cap governs from then on. The day still increments past the top step so it stays a
monotonic counter.
settleWave re-reads the state immediately before writing and declines to advance if warmupDay
moved underneath it. Two campaigns settling on the same sender at once would otherwise each advance
from the same snapshot and jump the ramp two steps on one wave's worth of evidence.
ConnectionWarmupState also carries warmupStartedAt, cooldownUntil and cooldownReason. They are
persisted and reported to clients through the connection detail, but the engine does not gate the cap
on them today — treat them as operator-facing state, not an enforced pause.
Projection
buildProjectionContext returns the shape a preflight or a UI renders: projection_start_date,
current_day, max_schedule_day, graduated, current_day_remaining_cap,
current_day_effective_cap, graduated_effective_cap, and the per-day schedule with each row's
effective_cap.
projectWaves then fills an audience into one wave per day, wave 1 using today's remaining budget
and every wave past the last schedule day using the graduated cap. It stops at 60 waves, and stops
early if a cap resolves to zero — a LOW quality rating has a modifier of 0, so a sender with that
rating projects no waves at all, which is the correct answer.
The engine never reads the clock: startDate and sentInWindow are inputs, which keeps projection
deterministic and testable.
Reading it over HTTP
Two campaign routes surface warm-up, both scope inbox:
GET /v1/campaigns/senders/:connectionId/warmup-status
GET /v1/campaigns/senders/:connectionId/send-now
warmup-status is derived from the last 7 days of recipient outcomes on that connection, not from
the ramp state:
{
"data": {
"connectionId": "...",
"sent": 4210,
"failed": 63,
"failureRate": 0.0147,
"status": "healthy"
}
}
status is healthy when the failure rate is under 5% (or there is no traffic at all), warming
under 20%, at_risk above that.
send-now answers whether a session sender is free to start a campaign right now: the occupying
campaign, when the sender frees up, and the remaining daily cap. It is the read that
Campaigns enforces again at send time, so a send that skipped preflight —
or raced another — is still refused.
Adapter signals
A channel registers a ChannelWarmupSignal to feed the engine live health:
interface ChannelWarmupSignal {
providerTierCap(connectionId: string): Promise<number | null>;
qualityRating(connectionId: string): Promise<string | null>;
}
WhatsApp fills both from its Meta tier. SMS and email return nulls — unlimited ceiling, neutral quality. A channel with no signal registered warms on the ramp alone. Adapter-supplied values win over the persisted snapshot, because they are the live truth.