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.