Campaigns + DSL

A campaign is a sequence of steps fired when a trigger event happens for a profile. Use the visual journey builder, or author the JSON definition directly via the API.

Visual builder

Open /dashboard/campaigns/[slug]/builder. Click steps to edit, drag to reorder, ✕ to delete, + buttons to extend. Save validates the definition before writing — branch and wait-for-event jump targets are checked at save time so structurally-valid but index-out-of-bounds campaigns can't reach production.

Step types

  • send — render a template and send to the profile (respects suppression + consent). Required: templateSlug. Optional: variables, from, replyTo.
  • delay — wait ms milliseconds before the next step.
  • branch— evaluate a predicate against the profile's traits or the triggering event's properties; jump to step index then on true, else on false.
  • wait_for_event — pause until a matching event fires for this profile or the timeoutMs elapses. onTimeout can be 'continue', 'exit', or { jumpTo: number }.
  • exit— terminate the run immediately (typically used as a branch target for "don't send this person").

Variables

Three forms — pick whichever fits the step:

// 1. Literal object
{ "kind": "send", "templateSlug": "welcome", "variables": { "firstName": "Ada" } }

// 2. Read from the recipient's profile traits
{ "kind": "send", "templateSlug": "welcome",
  "variables": { "fromProfile": ["firstName", "plan"] } }

// 3. Read from the triggering event's properties
{ "kind": "send", "templateSlug": "receipt",
  "variables": { "fromTrigger": ["amount", "currency"] } }

Full definition example

Every branch arm that lands on a send step must be followed by exit/delay/wait_for_event — the runner walks the steps array with a cursor, so a send with nothing terminating it falls straight into whatever step comes next and fires that too. Point then and else at distinct steps (never back at the branch itself), and give each arm its own exitonce it's done.

{
  "trigger": { "eventType": "user.signed_up" },
  "steps": [
    { "kind": "send", "templateSlug": "welcome", "variables": { "fromProfile": ["firstName"] } },
    { "kind": "delay", "ms": 172800000 },
    { "kind": "branch",
      "predicate": { "source": "profile_traits", "field": "plan", "op": "eq", "value": "paid" },
      "then": 5, "else": 3 },
    { "kind": "send", "templateSlug": "free-tier-tip", "variables": {} },
    { "kind": "exit" },
    { "kind": "send", "templateSlug": "paid-tier-tip", "variables": {} },
    { "kind": "exit" }
  ]
}

How it runs

When the trigger event fires for a profile, PAD enqueues a campaign_runs row. The campaign-tick cron (every 5 min) advances runs whose current step is ready. Each delay step sets next_run_at; the cron picks it up on the next tick after the delay elapses.

Per-step analytics

The detail page at /dashboard/campaigns/[slug] groups send analytics by (step_index, template_slug) so multiple sendsteps reusing the same template stay distinct. A "Branch outcomes" panel reads the same aggregation to show how many profiles took each path of every branch step — useful for seeing whether your predicates split the cohort as expected.

Manage actions

  • Duplicate — clones the campaign to a draft with a -copy slug, redirects into the builder. Useful for A/B variants without copy-pasting JSON.
  • Archive — sets status to archived; preserves history but stops firing. Archived campaigns are hidden from the list by default.
  • Delete — hard-delete with cascade to campaign_runs. Refused on active campaigns — pause or archive first. Sends survive the delete because sends.campaign_run_id is a plain uuid, not a FK.

Idempotency

One run per (campaign, profile). Re-firing the trigger event for a profile already in the campaign is a no-op. To re-enroll, duplicate the campaign to a new slug.