Guides
Recurring & Triggered Campaigns

Recurring & Triggered Campaigns

This guide details how to programmatically trigger subsequent runs of a published campaign for new audiences, such as weekly rolling enrollments or automated re-engagement flows.


Immutable Campaign Configurations

When a campaign transitions to published, the core configuration is locked:

  • The template (creative, layout, merge tags)
  • The channel setup (sender profile, postal/print profile, default mail class)
  • The schedule intent

Attempting to update these via PUT /campaigns/:id/audience, PUT /campaigns/:id/content, or PUT /campaigns/:id/schedule will return 409 JOURNEY_API.PUBLIC.CAMPAIGNS.NOT_DRAFT.

This immutability ensures that the artifact generated for execution strictly matches the approved configuration at publish time. To send to a new cohort, you request a new run against the locked campaign definition.


The Execution Model

POST /campaigns/:id/runs generates a new run associated with the campaign. Every run receives a unique runId and, for package-producing channels like Direct Mail, its own execution package.

                    Published Campaign

        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
   Run #1 (initial          Run #2 (rolling     Run #3 (manual
   send from /schedule-jobs)  enrollment)         re-mail)
        │                     │                     │
   audience snapshot      fresh audience       different audience
   at publish time        from this week       (suppressed cohort)

Each run executes independently without mutating the underlying campaign state.


Implementation Pattern: Rolling Enrollment

The following example demonstrates a scheduled process computing an eligible segment and triggering a run.

import os, requests
from datetime import date
 
API_KEY = os.environ["EXPERITURE_API_KEY"]
BASE = "https://api.experiture.ai/public/v1"
H = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
 
CAMPAIGN_ID = "4130bada-9264-465f-bc0c-a26bebcfcc81"
WEEKLY_ELIGIBLE_SEGMENT = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
 
# Generate an idempotency key tied to the execution period
this_week = date.today().isocalendar()
idem_key = f"weekly-{CAMPAIGN_ID}-{this_week.year}W{this_week.week:02d}"
 
resp = requests.post(
    f"{BASE}/campaigns/{CAMPAIGN_ID}/runs",
    headers={**H, "Idempotency-Key": idem_key},
    json={
        # The audience override is what makes this recurring — each run targets
        # this week's eligible cohort. requestKind in v1 is always "on_demand".
        "audience": {
            "include": [
                {"audienceId": WEEKLY_ELIGIBLE_SEGMENT, "type": "segment"}
            ]
        },
        "metadata": {
            "cadence": "weekly",
            "weekOf": str(date.today()),
            "trigger": "weekly-cron",
        },
    },
)
 
if resp.status_code == 429:
    print("Execution queue at capacity. Implement backoff and retry.")
elif resp.status_code == 202:
    run = resp.json()["data"]
    print(f"Queued run {run['runId']} for week of {date.today()}")
else:
    resp.raise_for_status()

Key Implementation Details:

  1. The audience override is the engine. Passing an audience.include array targets this run only — it never mutates the published campaign. Each week you pass that week's eligible segment, and the locked recipe sends to the fresh cohort. This is what makes the loop "recurring."
  2. Idempotency Strategy: The Idempotency-Key is derived from the operational period (e.g., the calendar week). If a network timeout occurs and the script retries, the API safely returns the existing run instead of enqueuing a duplicate.
  3. Tag the cadence in metadata. Use metadata (e.g. cadence, trigger) to record why this run fired. This is the durable place for that context — see the note below on requestKind.

Categorizing the Request

In v1, the requestKind field accepts a single value, on_demand (also the default). Sending any other value — including rolling_enrollment or manual_drop — returns 400 JOURNEY_API.VALIDATION.BODY_SCHEMA_MISMATCH. Additional kinds will be introduced alongside their execution adapters in a future version.

Until then, record the purpose of a run in the metadata object, which is stored with the run and surfaced on the run record. It's the durable place for the context you'd otherwise put in requestKind:

{
  "audience": { "include": [{ "audienceId": "...", "type": "segment" }] },
  "metadata": {
    "cadence": "weekly",
    "purpose": "rolling_enrollment",
    "authorizedBy": "ops-ticket-4421"
  }
}

Your own reporting can group runs on these metadata keys exactly the way you would have grouped on requestKind.


Concurrency and Backpressure

Experiture supports a maximum of 5 active run requests (pending, claiming, or enqueued) per campaign concurrently. Exceeding this limit returns:

429 JOURNEY_API.CAMPAIGN_RUNS.BACKPRESSURE

This limit ensures that composition and artifact generation resources are efficiently distributed across all tenants.

Handling Backpressure: Implement exponential backoff with jitter when encountering a 429 response. The stable Idempotency-Key guarantees that delayed retries will not duplicate successfully enqueued requests.

import time, random, requests
 
def submit_run_with_backoff(campaign_id, body, idem_key, max_attempts=8):
    for attempt in range(max_attempts):
        r = requests.post(
            f"{BASE}/campaigns/{campaign_id}/runs",
            headers={**H, "Idempotency-Key": idem_key},
            json=body,
        )
        if r.status_code == 202:
            return r.json()["data"]
        if r.status_code == 429:
            sleep = min(2 ** attempt + random.random(), 300)
            time.sleep(sleep)
            continue
        r.raise_for_status()
    raise RuntimeError(f"Backpressure persisted across {max_attempts} attempts")

Polling Run Status

Use GET /campaigns/:id/runs to monitor the execution of specific run IDs.

runs = requests.get(f"{BASE}/campaigns/{CAMPAIGN_ID}/runs?limit=10", headers=H).json()["data"]["runs"]
current_run = next((r for r in runs if r["runId"] == run_id), None)
 
if current_run is None:
    pass # Request is queued; finalizer processing pending
elif current_run["runStatus"] == "failed":
    pass # Process failure diagnostics
elif current_run.get("packageStatus") == "ready":
    pass # Artifacts successfully materialized

The run progresses through pending, claiming, and enqueued. The unitCount attribute reflects the final number of recipients successfully hydrated and processed, accounting for suppression rules and invalid records.


Operational Considerations

  • POST /campaigns/:id/runs executes against the current published version of the campaign. To alter the message content, sender profile, or layout, a new campaign version must be authored and published.
  • Each run request is processed independently; concurrent runs do not cancel or interfere with prior runs.
  • Empty or skipped runs (e.g., due to an empty audience segment) update the run status appropriately without halting the system.

See Also