Skip to main content

The cross-campaign send allocator

When you run several campaigns at once, they don’t each get their own budget. They draw on three shared resources at once: your global frequency cap (the hourly send volume your organization allows across everything), your quiet-hours windows, and the pace your senders’ reputation supports. Two campaigns launched at the same instant with no arbiter both consume the same hourly cap — so each can throttle the other, and the one you cared about more may lose. Until recently, teams worked around this by hand: stagger campaign start times so their peaks don’t collide. The cross-campaign send allocator removes that guesswork. Given a spread window, the campaigns you select, and the shared constraints, it computes a per-campaign send schedule — a recommended start time and per-slot volume for each campaign — that water-fills volume across the window so no time slot ever exceeds the shared cap. This page explains the two single-campaign primitives that predate it, why neither arbitrates the shared budget, and how the allocator resolves contention. The operator-facing walkthrough of the cross-schedule planner on the Campaigns page covers the clicks; this page is the model behind it.

Two single-campaign primitives — and the gap between them

Two send-timing optimizations already exist, and both are single-campaign by design: Smart send — per-contact timing. Smart send picks the hour-of-day each individual contact is most likely to engage, using that contact’s engagement profile. It optimizes when one recipient inside one campaign gets the message. It has no view of how many other campaigns are sending in the same hour, so it can still land its picks on top of another campaign’s burst. Adaptive pacing — live throttling. Adaptive pacing adjusts a single running campaign’s send rate against live carrier feedback: when delivery receipts show a destination network rejecting or filtering a blast, the pace factor drops and the campaign slows so the carrier sees a lighter, recoverable load. This is a closed-loop reaction for one campaign — it responds to what already happened. It does not plan how ten campaigns share next week’s budget before any of them starts. Neither answers the question a marketer with several queued campaigns actually asks: “spread these across this week without blowing the hourly cap or my quiet-hours window.” That is the allocator’s job. Both constraints — the global hourly cap and the quiet-hours window — are tenant-owned: you set them in your organization’s frequency caps and quiet-hours configuration, and the allocator distributes volume inside whatever you configured. See outbound send gating for how these controls also gate live sends.

Inputs: a window, campaign demands, and the shared constraints

The allocator takes four inputs — the same ones you fill in on the planner, or in the POST /campaigns/cross-schedule/preview request body: The window is cut into slots — one hour by default, adjustable from one minute to one day per request (a wider optional slot_minutes you set explicitly). Each slot’s shared capacity is the hourly cap scaled to the slot length: a 200/hour cap gives 200 messages per 60-minute slot, 50 per 15-minute slot.

The water-filling algorithm

“Water-filling” here means exactly what it sounds like: pour each campaign’s volume into the window as evenly as the shared capacity allows, lowest level first. Per slot, the allocator runs two passes in priority order:
  1. Even-spread pass. Every campaign takes up to its per-slot spread target — its total volume divided by the number of usable slots in the window, capped by its own pace ceiling. Volume distributes across the whole window instead of front-loading into the first slots.
  2. Catch-up pass. When a slot still has spare shared capacity after every campaign took its spread, the spare goes to campaigns that fell behind their cumulative spread because earlier slots were contended. A campaign that is on track takes nothing here — spare capacity never front-loads an uncontended campaign past its even spread.
Two constraints clip every assignment:
  • The slot never exceeds the shared cap. Across all campaigns combined, no slot receives more than global_cap_per_hour scaled to the slot length. Quiet-hour slots receive nothing at all.
  • Per-campaign pace ceiling. Each campaign’s per-slot share is throttled by its pace factor — a sender-reputation term between 0 and 1, where 1 is a healthy sender at full per-slot rate. A reputation-stressed sender gets a fraction of the per-slot throughput, so stressed traffic drips slower instead of competing for slots at full speed. This is the same reputation signal the sender warming and reputation curve and the live adaptive-pacing loop use; in the allocator it shapes planned spread rather than reacting to a live campaign.
The result is deterministic: the same inputs always produce the same plan, which is what makes the preview safe to review before you apply anything.

Priority: who defers when demand exceeds capacity

When total campaign volume fits inside the window under the cap, priority rarely matters — everything lands, spread evenly. Priority decides what happens when the window is too small or the cap too tight for the demand:
  • Slots are filled in priority order. High-priority campaigns claim contended slot capacity first.
  • Lower-priority campaigns defer: their volume pushes into later slots of the window, and what still doesn’t fit comes back as overflow — volume reported per campaign that did not schedule inside the window. Nothing is silently dropped: overflow is stated explicitly in the plan so you can widen the window, raise the cap, or accept that a campaign continues after the window ends.
  • Ties break by the larger request first, purely so the output is stable; equal-priority campaigns share contention by volume, not arbitrarily.
Within one campaign, priority also interacts with pace: a high-priority campaign with a stressed sender still bends to its own pace ceiling in every slot — priority wins slots, it never overrides a sender-health limit.

Where the allocator features — and where it pauses

The cross-schedule planner in the Campaigns dashboard is the allocator’s surface: select the campaigns, set the window, the global hourly cap, and optionally quiet hours, then generate a plan. Each campaign returns with a recommended start time (its first scheduled slot, offered as the scheduled_at to apply) and per-slot rows showing exactly how its volume spreads. The same computation is available over the API as POST /campaigns/cross-schedule/preview. The planner is deliberately read-only and advisory:
  • Generating a plan never schedules, mutates, or launches anything. You review the recommendation and apply each campaign’s start time through the normal schedule path — so the allocator can preview dozens of “what-if” windows at no risk.
  • A window whose campaigns collectively have no recipients left to send is refused with a validation error rather than returning a hollow all-zero plan.
  • Empty campaigns inside a mixed selection still appear in the plan as explicit zero-scheduled rows, so the preview reflects your selection instead of silently dropping entries.
Once a planned campaign actually launches, the live controls take over: adaptive pacing adjusts its rate against carrier feedback in real time, and the gates every send walks still apply per message. The allocator plans the spread; it does not replace the runtime loops — it hands them a starting schedule where campaigns are no longer fighting each other for the same budget.

See also