> ## Documentation Index
> Fetch the complete documentation index at: https://docs.interchange.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Pacing Periods

> Define multi-period flight schedules that split DRAFT buys into per-period packages at execute time

## Overview

**Pacing periods** let you express a non-flat campaign flight as a sequence of
dated periods, each with its own spend intensity. Use them when a campaign
needs to spend more during a holiday window, less during a quiet stretch, or
explicit dollar amounts in specific months.

When `execute_campaign` is called, each DRAFT media buy that has no per-buy
schedule of its own is split into one package per active period, with budget
allocated according to the schedule. You do not have to compute or set
per-package budgets yourself.

<CardGroup cols={2}>
  <Card title="Weight mode" icon="scale-balanced">
    Express each period as a relative multiplier (`1.0` = normal, `2.5` = 150%
    heavier). Scope3 distributes the campaign budget proportionally by
    `days × weight`.
  </Card>

  <Card title="Budget mode" icon="dollar-sign">
    Set explicit dollar amounts per period. Useful when finance hands you a
    monthly spend plan you must hit exactly.
  </Card>
</CardGroup>

<Note>
  **Pacing splits apply at execute time, for DRAFT buys only.** When you call
  `execute_campaign`, every DRAFT media buy without its own per-buy schedule is
  split into one package per period. Live buys (ACTIVE or PAUSED) are not
  affected when you add or change the campaign pacing plan. You can set the plan
  before or after creating buys; it takes effect the next time you execute. The
  API rejects a write if all periods have already elapsed.
</Note>

## Concept

A `pacingPeriods` object has two fields:

| Field       | Description                                    |
| ----------- | ---------------------------------------------- |
| `mode`      | `"weight"` or `"budget"`                       |
| `periods[]` | Ordered, non-overlapping list of dated periods |

Each period has:

| Field    | Required when     | Description                                          |
| -------- | ----------------- | ---------------------------------------------------- |
| `label`  | Always            | Human-readable name (e.g. `"Memorial Day Heavy-Up"`) |
| `start`  | Always            | Inclusive `YYYY-MM-DD` start date                    |
| `end`    | Always            | Inclusive `YYYY-MM-DD` end date                      |
| `weight` | `mode = "weight"` | Positive number ≤ 10 (relative intensity)            |
| `budget` | `mode = "budget"` | Positive dollar amount                               |

Periods must be **inside the campaign flight dates** and must **not overlap**.
Gaps between periods are allowed and are treated as **pauses**: the campaign
spends \$0 during a gap.

## How to use

### Weight mode

Weight mode is the most common: declare the shape, let Scope3 do the math.

```bash theme={null}
curl -X POST 'https://api.interchange.io/api/v2/buyer/campaigns' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "advertiserId": "12345",
    "name": "Summer 2026",
    "flightDates": { "startDate": "2026-06-01T00:00:00Z", "endDate": "2026-08-31T23:59:59Z" },
    "budget": { "total": 300000, "currency": "USD" },
    "pacingPeriods": {
      "mode": "weight",
      "periods": [
        { "label": "June ramp",       "start": "2026-06-01", "end": "2026-06-30", "weight": 1.0 },
        { "label": "July 4th heavy",  "start": "2026-07-01", "end": "2026-07-15", "weight": 2.5 },
        { "label": "Mid-summer cool", "start": "2026-07-16", "end": "2026-08-15", "weight": 0.5 },
        { "label": "Back to school",  "start": "2026-08-16", "end": "2026-08-31", "weight": 1.5 }
      ]
    }
  }'
```

When the campaign executes, each product is split four ways with budget
proportional to `days × weight`.

### Budget mode

Budget mode pins explicit dollars to each period. The sum of period budgets
must not exceed `budget.total` on the campaign; anything left over stays as
unallocated headroom.

```bash theme={null}
curl -X POST 'https://api.interchange.io/api/v2/buyer/campaigns' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "advertiserId": "12345",
    "name": "Q3 Plan",
    "flightDates": { "startDate": "2026-07-01T00:00:00Z", "endDate": "2026-09-30T23:59:59Z" },
    "budget": { "total": 150000, "currency": "USD" },
    "pacingPeriods": {
      "mode": "budget",
      "periods": [
        { "label": "July",      "start": "2026-07-01", "end": "2026-07-31", "budget": 60000 },
        { "label": "August",    "start": "2026-08-01", "end": "2026-08-31", "budget": 60000 },
        { "label": "September", "start": "2026-09-01", "end": "2026-09-30", "budget": 30000 }
      ]
    }
  }'
```

### Updating a schedule

`PUT /api/v2/buyer/campaigns/:id` accepts the full `pacingPeriods` object. To
clear an existing schedule, send `"pacingPeriods": null`.

```bash theme={null}
curl -X PUT 'https://api.interchange.io/api/v2/buyer/campaigns/cmp_987654321' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "pacingPeriods": {
      "mode": "weight",
      "periods": [
        { "label": "June ramp",      "start": "2026-06-01", "end": "2026-06-30", "weight": 1.0 },
        { "label": "July 4th heavy", "start": "2026-07-01", "end": "2026-07-15", "weight": 3.0 },
        { "label": "Mid-summer cool","start": "2026-07-16", "end": "2026-08-15", "weight": 0.5 },
        { "label": "Back to school", "start": "2026-08-16", "end": "2026-08-31", "weight": 1.5 }
      ]
    }
  }'
```

<Warning>
  Periods that have already started or ended **cannot be modified or removed**.
  Updates may only change future periods. Changing the `mode` is rejected if
  any existing period is already in progress or past.
</Warning>

## Live cascade for appended periods

When you append new periods to a campaign that already has paced live media
buys, Scope3 automatically pushes the new period packages to those media
buys via ADCP 3.0 `update_media_buy.new_packages`. The result is returned on
the update response as `pacingCascadeResult`.

### What gets cascaded

Only **strict appends** are cascaded: the new `pacingPeriods.periods` array
must start with the same items as the existing schedule. Insertions in the
middle, modifications of an existing period, removals, and mode changes
return `pacingCascadeResult.attempted: false` with a `notAppendedReason` and
require manually creating new media buys to apply.

Only media buys that were **already paced** (executed with `pacingPeriods`
set on the campaign at execution time) are eligible. Unpaced media buys are
reported with `outcome: "skipped"`, `reason: "unpaced"`. To apply pacing,
create a new media buy.

### Agent capability

Each seller agent must advertise `add_packages` in its `valid_actions` to
accept new packages mid-flight. Agents that don't are reported with
`outcome: "unsupported"`. Manual workaround: create new media buys for the
appended periods on those agents.

### Response shape

```json theme={null}
{
  "campaign": { "...": "..." },
  "pacingCascadeResult": {
    "attempted": true,
    "totalMediaBuys": 2,
    "updatedCount": 1,
    "skippedCount": 0,
    "failedCount": 0,
    "unsupportedCount": 1,
    "results": [
      {
        "mediaBuyId": "mb_abc",
        "agentName": "Vox",
        "outcome": "updated",
        "addedPackageCount": 3
      },
      {
        "mediaBuyId": "mb_xyz",
        "agentName": "Acme",
        "outcome": "unsupported",
        "reason": "agent does not advertise add_packages in valid_actions"
      }
    ]
  }
}
```

The block is omitted when `pacingPeriods` is not part of the update payload.

## Per-media-buy pacing

Campaign-level `pacingPeriods` shapes the whole campaign: every paced media
buy under it follows the same schedule. When you only want to shape *one*
media buy (heavy-up on a specific publisher with seasonal or event-driven
inventory, leave the rest flat), set `pacingPeriods` on the `mediaBuys[]`
entry of `update_campaign` instead.

The per-buy schedule **replaces** the campaign-level shape for that specific
buy. It uses the same `mode` + `periods[]` shape as the campaign-level
field.

```bash theme={null}
curl -X PUT 'https://api.interchange.io/api/v2/buyer/campaigns/cmp_987654321' \
  -H 'Authorization: Bearer scope3_<your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "mediaBuys": [
      {
        "mediaBuyId": "mb_vox_concerts",
        "pacingPeriods": {
          "mode": "weight",
          "periods": [
            { "label": "Pre-tour",      "start": "2026-06-01", "end": "2026-06-30", "weight": 1.0 },
            { "label": "Tour heavy-up", "start": "2026-07-01", "end": "2026-07-15", "weight": 3.0 },
            { "label": "Wind-down",     "start": "2026-07-16", "end": "2026-08-31", "weight": 0.5 }
          ]
        },
        "updated_reason": "Heavy-up on Vox concert inventory during the tour window"
      }
    ]
  }'
```

### Behavior by media buy state

| Media buy status                                     | Behavior                                                                                                                                                 |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DRAFT` / `PENDING_APPROVAL`                         | Schedule persisted on the buy. At execute time, packages are split by the per-buy schedule instead of the campaign-level schedule.                       |
| `ACTIVE` / `PAUSED`, already paced, strict append    | Appended period packages are sent to the seller via `update_media_buy.new_packages`. Requires the seller to advertise `add_packages` in `valid_actions`. |
| `ACTIVE` / `PAUSED`, unpaced (live flat packages)    | **Rejected.** Introducing period packages on top of live flat packages would double-spend. Create a new media buy with pacing periods instead.           |
| `ACTIVE` / `PAUSED`, paced, non-strict-append change | **Rejected.** Insertions, modifications, removals, and mode changes are not supported. Create a new media buy.                                           |

Pass `"pacingPeriods": null` to clear an existing per-buy schedule. The
column is cleared but any deployed period packages keep running. To stop
spend on a deployed window, cancel those packages directly.

Periods must lie inside the **media buy's** flight (not the campaign's),
must not overlap, and follow the same future-only update rules as the
campaign-level field. In response shapes, each per-buy period includes a
resolved `budget` computed from the sum of the buy's product budgets.

When per-buy pacing is set on the same `update_campaign` call as
`mediaBuys[].creative_ids` (or relies on auto-sync from the campaign
manifest), the appended period packages inherit those creatives in the
same seller request. There is no separate creative-attach step.

### Budget reductions

Lowering campaign `budget.total` through `update_campaign` can also reduce
live package budgets. If the new media budget is below current live package
allocations, Scope3 proportionally reduces eligible live package budgets,
including period packages created from pacing periods. Explicit
`mediaBuys[].packages[].budget` values in the same request take precedence
over proportional scaling.

If Scope3 cannot apply a generated seller budget update, the campaign budget is
not lowered. This keeps campaign budget and live package allocations in sync.

## Response shape

Campaign `GET` responses surface the schedule with **resolved budgets** for
every period. In weight mode, the resolved dollar amount is computed by
`(days × weight)` proportional split of `budget.total`; weights are preserved
alongside the resolved budget so you can audit the math.

```json theme={null}
{
  "campaign": {
    "id": "cmp_987654321",
    "budget": { "total": 300000, "currency": "USD" },
    "allocatedBudget": 240000,
    "unallocatedBudget": 60000,
    "pacingPeriods": {
      "mode": "weight",
      "periods": [
        { "label": "June ramp",       "start": "2026-06-01", "end": "2026-06-30", "weight": 1.0, "budget": 60000 },
        { "label": "July 4th heavy",  "start": "2026-07-01", "end": "2026-07-15", "weight": 2.5, "budget": 75000 },
        { "label": "Mid-summer cool", "start": "2026-07-16", "end": "2026-08-15", "weight": 0.5, "budget": 31000 },
        { "label": "Back to school",  "start": "2026-08-16", "end": "2026-08-31", "weight": 1.5, "budget": 74000 }
      ]
    }
  }
}
```

`unallocatedBudget` is the dollar gap between `budget.total` and
`allocatedBudget` (the sum of active media buy budgets plus delivered spend
on ended or archived media buys, all gross and fee-inclusive), telling you how
much room is left for new media buys to fill the schedule. It can go negative
when delivered spend on ended buys exceeds `budget.total`.

## Automatic package splitting

When a campaign with pacing periods executes:

<Steps>
  <Step title="Resolve per-product budgets">
    Each selected product gets a budget, either set by the user or derived
    from a discovery proposal.
  </Step>

  <Step title="Split per period">
    For every product, Scope3 creates one package per pacing period. In weight
    mode, the per-period budget is
    `(days × weight) / sum(all weighted days) × product_budget`. In budget mode,
    each period's fixed budget is split across products proportionally to
    each product's share of total product budgets.
  </Step>

  <Step title="Round to last">
    Each period after the first uses banker's rounding to two decimals; the
    last period absorbs any rounding remainder so per-product totals match
    exactly.
  </Step>

  <Step title="Submit to sales agent">
    The split packages are sent to the sales agent as the media buy
    composition. Each package carries its own `start`, `end`, and budget.
  </Step>
</Steps>

This means a campaign with 10 products and 4 periods produces **40 packages**
on execution, each one a discrete dated bucket of spend.

## Validation

The campaign endpoints validate `pacingPeriods` against the campaign before
saving:

* **Date order**: every period's `start` must be ≤ `end`.
* **Inside flight**: every period must lie within `flightDates.startDate` and
  `flightDates.endDate`.
* **No overlaps**: when sorted by `start`, no period may begin on or before
  the previous period's `end`.
* **Budget sum** (budget mode): the sum of period budgets must not exceed
  `budget.total`.
* **Required fields per mode**: weight periods need a `weight`; budget periods
  need a `budget`.
* **Update safety**: a period that has already started cannot be changed or
  removed; the mode cannot change while any period is in progress or past.

A failing validation returns a 400 with a clear error message identifying the
offending period by label.

## Best practices

* **Start in weight mode.** It's easier to reason about "2× during the holiday
  week" than to recompute exact dollar splits when the budget moves.
* **Use labels generously.** Period labels show up in package metadata and in
  reporting; names like `"July 4th heavy"` beat `"Period 2"`.
* **Keep periods aligned with media buy intent.** A media buy can have any
  start/end inside the campaign flight, but matching its dates to a period
  boundary keeps reporting clean.
* **Treat gaps as pauses.** If you don't want spend on July 16–18, leave that
  range out of `periods` rather than setting `weight: 0` (which is rejected
  as non-positive).
* **Plan ahead: past periods are immutable.** Once a period starts, its
  shape is locked. Front-load decisions you might need to revisit.
* **Cap period count.** The schema allows up to 52 periods; in practice,
  monthly or weekly granularity is plenty.

## Limits

| Limit                      | Value                                      |
| -------------------------- | ------------------------------------------ |
| Periods per campaign       | 1–52                                       |
| `weight` range             | `(0, 10]`                                  |
| `budget` per period        | Positive dollar amount                     |
| `budget` sum (budget mode) | ≤ `budget.total`                           |
| Max label length           | 100 characters                             |
| Date format                | `YYYY-MM-DD` (UTC, inclusive on both ends) |
