---
title: "Campaign Broadcaster"
description: "The Campaign Broadcaster subsystem provides an authoring and asynchronous delivery engine for partner marketing and transactional email campaigns within Dub. It solves the operational challenge of ..."
last_updated: "2026-10-05T05:07:35.182658+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/campaign-broadcaster"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/app/ee/api/cron/campaigns/broadcast/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts)
- [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts)
- [apps/web/lib/api/workflows/send-campaign/execute.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts)
- [apps/web/app/ee/api/cron/campaigns/queue-scheduled/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts)
- [apps/web/app/ee/api/campaigns/campaignId/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/campaign-editor.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/campaign-editor.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/use-campaign-confirmation-modals.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/use-campaign-confirmation-modals.tsx)
- [apps/web/scripts/send-batch-emails.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/send-batch-emails.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/campaign-controls.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/campaign-controls.tsx)
- [apps/web/lib/partners/create-stripe-transfer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stripe-transfer.ts)
- [packages/email/src/templates/campaign-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx)
- [packages/email/src/templates/broadcasts/dub-product-update-summer26.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/dub-product-update-summer26.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/layout.tsx)
- [packages/email/src/templates/broadcasts/dub-product-update-mar26.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/dub-product-update-mar26.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaigns-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/campaigns-table.tsx)
- [apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/send-email-preview-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx)
- [apps/web/app/ee/api/cron/program-application-reminder/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts)
- [apps/web/app/ee/api/cron/trial-emails/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trial-emails/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaigns-page-content.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/campaigns-page-content.tsx)
- [packages/email/src/templates/broadcasts/launch-week-day-1.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/launch-week-day-1.tsx)
- [apps/web/lib/email/run-trial-email-cron.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/run-trial-email-cron.ts)
- [apps/web/lib/payouts/send-payout-reminder.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/send-payout-reminder.ts)
- [packages/email/src/templates/broadcasts/dub-startup-program-announcement.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/dub-startup-program-announcement.tsx)
- [packages/email/src/templates/program-payout-reminder.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/program-payout-reminder.tsx)
- [packages/email/src/templates/broadcasts/payout-auto-withdrawals.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/payout-auto-withdrawals.tsx)
- [packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx)
- [packages/email/src/templates/broadcasts/launch-week-day-3.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/launch-week-day-3.tsx)
- [packages/email/src/templates/broadcasts/launch-week-day-2.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/launch-week-day-2.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaigns-upsell.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/campaigns-upsell.tsx)
</details>

## Overview

The Campaign Broadcaster subsystem provides an authoring and asynchronous delivery engine for partner marketing and transactional email campaigns within Dub. It solves the operational challenge of scaling high-volume communications to affiliate cohorts by decoupling campaign creation from execution via robust scheduling queues and rate-limited dispatchers. Key design decisions include QStash-driven batch chunking, signature-verified cron triggers, and dynamic HTML template interpolation that maps partner-specific metadata and reward structures into responsive emails. By integrating directly with workspace authorization controls, programmatic REST APIs, and targeted workflow rule evaluators, the broadcaster enables reliable, auditable message delivery across segmented partner networks.

Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:1-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L1-L56), [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:36-63](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L36-L63), [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:18-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L18-L56)

## Campaign Composition and Authoring Surface

### Overview

The Campaign Composition and Authoring Surface provides the user interface and form context for creating and editing affiliate marketing and transactional campaigns. It uses React Hook Form wrapped by specialized form contexts to manage complex state such as rich text composition, audience recipient group and tag selection, and custom sender address formatting. Editors are safeguarded by dynamic state validation rules that prevent unauthorized edits based on live campaign statuses, locking down fields and presenting clear status-driven error messages when modifications are prohibited.

Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx:1-61](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/campaign-editor.tsx#L1-L61), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx:1-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx#L1-L13)

### Form Context and State Management

The authoring surface builds upon `react-hook-form` integrated with state management utilities like `useCampaignFormContext` and confirmation modal hooks (`useCampaignConfirmationModals`). Campaign modifications trigger asynchronous mutations via `useApiMutation` pointing to `/api/campaigns/[campaignId]`, with state cache invalidations handled through SWR prefix mutations (`mutatePrefix`).

> [!NOTE]
> Campaign editability is dynamically controlled by the `status` field. If a campaign enters sending, sent, canceled, or active states, edits are blocked to ensure transactional integrity during delivery.

Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx:1-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx#L1-L50), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx:102-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx#L102-L108)

### Campaign Lifecycle Actions and Confirmations

The authoring interface coordinates critical publication and state transition flows through confirmation modals. The publishing and scheduling workflow evaluates target partner counts derived from selected groups and tags before submitting updates.

```mermaid
sequenceDiagram
    participant User
    participant Editor as Campaign Editor
    participant Modal as Confirmation Modal
    participant API as REST API (/api/campaigns)

    User->>Editor: Click Publish or Schedule
    Editor->>Modal: Open confirmation dialog with recipient count
    User->>Modal: Confirm action
    Modal->>API: PATCH campaign data with new status
    API-->>Editor: Mutation success & SWR cache update
    Editor->>User: Toast notification & redirect to campaign list
```

Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx:51-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx#L51-L112)

### Campaign Status Restrictions

| Campaign Status | Edit Permission | Behavior / Restriction Message |
|-----------------|-----------------|----------------------------------|
| `sending` | Locked | Edits aren't allowed while sending. |
| `sent` | Locked | Edits aren't allowed after sending. |
| `canceled` | Locked | Edits aren't allowed after cancellation. |
| `active` | Locked | Edits aren't allowed while the campaign is active. Pause the campaign to make changes. |
| `draft` / `paused` | Editable | Full form controls and state adjustments permitted. |

Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx:102-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx#L102-L108)

## Campaign Preview and Test Delivery

### Overview

The campaign preview and test delivery subsystem allows workspace members to verify campaign email templates by dispatching live test renders to designated email addresses. Editors interact with the interface via the `SendEmailPreviewModal` component, which manages recipient input and validates form state before submitting requests to the preview REST endpoint.

Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:11-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx#L11-L33)

### Preview Modal and Form Integration

The `SendEmailPreviewModal` hook and component extract current form values (`subject`, `preview`, `bodyJson`, and `from`) directly from the campaign form context using `useWatch`. Users supply comma-separated email addresses, which are parsed and cleaned prior to dispatch.

```typescript
export function useSendEmailPreviewModal({
  campaignId,
}: {
  campaignId: string;
}) {
  const [showSendEmailPreviewModal, setShowSendEmailPreviewModal] =
    useState(false);

  const SendEmailPreviewModalCallback = useCallback(
    () => (
      <SendEmailPreviewModal
        showModal={showSendEmailPreviewModal}
        setShowModal={setShowSendEmailPreviewModal}
        campaignId={campaignId}
      />
    ),
    [showSendEmailPreviewModal, campaignId],
  );

  return {
    showSendEmailPreviewModal,
    setShowSendEmailPreviewModal,
    SendEmailPreviewModal: SendEmailPreviewModalCallback,
  };
}
```

Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:29-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx#L29-L74), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:130-154](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx#L130-L154)

> [!WARNING]
> Preview test requests enforce a strict recipient limit of 10 email addresses per call. Submitting empty subjects or missing body content triggers client-side validation errors via `sonner` toasts.

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:30-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L30-L34), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:37-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx#L37-L47)

### API Route Execution and Template Rendering

The backend route handler validates workspace authentication, plan requirements (`advanced` or `enterprise`), and member roles (`owner` or `member`) via `withWorkspace`. It validates the request body using `sendPreviewEmailSchema`, fetching program and campaign records concurrently.

```mermaid
sequenceDiagram
    participant Modal as SendEmailPreviewModal
    participant API as POST /api/campaigns/[campaignId]/preview
    participant Resend as sendBatchEmail
    participant Template as CampaignEmail

    Modal->>API: POST payload (subject, preview, bodyJson, from, emailAddresses)
    API->>API: Parse body & verify workspace program & campaign
    API->>API: Validate "from" address against verified email domains
    API->>Template: Render CampaignEmail with interpolated variables
    API->>Resend: Dispatch batch email with "[TEST]" subject prefix
    Resend-->>API: Resend response / error status
    API-->>Modal: JSON success or bad_request error
```

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:37-81](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L37-L81), [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:129-133](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L129-L133)

> [!NOTE]
> Custom `from` addresses are parsed with `parseCampaignFromAddress` and checked against the program's verified email domains. If unverified, the request throws a `bad_request` DubApiError.

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:65-81](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L65-L81)

### Campaign Email Template Structure

The `CampaignEmail` component uses `@react-email/components` and Tailwind styling to construct responsive email markup. It dynamically inserts program logos, program names, campaign preview text, and rendered body HTML.

| Template Property | Source Path | Behavior |
|-------------------|-------------|----------|
| `CAMPAIGN_EMAIL_TEXT_COLOR` | `packages/email/src/templates/campaign-email.tsx` | Fixed hex color `#000000` applied to email body text and list items. |
| `Preview` | `campaign.preview` | Injected as preheader snippet text if provided. |
| `Logo` | `program.logo` | Defaults to `https://assets.dub.co/wordmark.png` if null. |
| `Reply in Dub` | `program.messagingEnabledAt` & campaign type | Appends a reply CTA block for transactional campaigns when messaging is active. |
| Footer Link | Campaign type check (`marketing`) | Appends marketing unsubscription notification link for marketing campaigns. |

Sources: [packages/email/src/templates/campaign-email.tsx:18-124](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx#L18-L124)

## Campaign API Surface and Lifecycle

### Overview

The Campaign API surface manages campaign configuration, audience groups, partner tags, schedule triggers, and transactional workflows through REST endpoints secured by workspace authentication. Handlers enforce required plans (`advanced` or `enterprise`) and member roles (`owner` or `member`) via `withWorkspace`, resolving the default program ID before executing database operations.

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:1-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L1-L50), [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:216-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L216-L220)

### Route Lifecycle and State Transitions

The `PATCH /api/campaigns/[campaignId]` route validates incoming campaign payloads, checks workflow conditions, manages partner groups and tags, and updates the database inside a Prisma transaction. When a campaign transitions into a due marketing broadcast state, it enqueues a QStash background job via Vercel `waitUntil`.

```mermaid
sequenceDiagram
    participant Client as REST Client
    participant PATCH as PATCH /api/campaigns/[campaignId]
    participant DB as Prisma Transaction
    participant QStash as QStash Publisher

    Client->>PATCH: Request payload (name, status, scheduledAt, groupIds, etc.)
    PATCH->>PATCH: validateCampaign() & validateWorkflowConditions()
    PATCH->>PATCH: Check groupIds & partnerTagIds array equality
    PATCH->>DB: Execute transaction (update workflow & campaign records)
    DB-->>PATCH: Return updatedCampaign
    PATCH->>PATCH: shouldEnqueueDueMarketingBroadcast(previous, next)
    alt Due marketing broadcast triggered
        PATCH->>QStash: qstash.publishJSON() via waitUntil()
    end
    PATCH-->>Client: Return JSON CampaignSchema response
```

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:52-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L52-L220)

> [!NOTE]
> Group ID and partner tag updates evaluate array equality against existing relations using `arrayEqual` and `pluck`. Passing `null` for `groupIds` is explicitly treated as an empty array targeting all groups, whereas `null` for `partnerTagIds` signifies no tag restrictions.

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:100-130](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L100-L130)

### Marketing Broadcast Evaluation

The broadcast scheduling logic relies on helper functions in `marketing-campaign-broadcast.ts` to determine whether a campaign qualifies as a due marketing broadcast that requires immediate queue dispatching.

| Function Name | Parameters | Return Condition |
|---------------|------------|------------------|
| `isDueMarketingCampaign` | `{ campaign: MarketingBroadcastCampaign, now?: Date }` | Returns true if `campaign.type === CampaignType.marketing`, `campaign.status === CampaignStatus.scheduled`, and `(!campaign.scheduledAt || campaign.scheduledAt <= now)`. |
| `shouldEnqueueDueMarketingBroadcast` | `{ previous: MarketingBroadcastCampaign, next: MarketingBroadcastCampaign, now?: Date }` | Returns true if `next` is a due marketing campaign while `previous` was not. |

Sources: [apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts:8-35](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts#L8-L35)

> [!TIP]
> Background dispatching uses QStash flow control with a parallelism limit of 1 keyed by `broadcast-marketing-campaign-${campaignId}` to prevent duplicate broadcast executions during concurrent schedule updates.

Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:201-204](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L201-L204)

## Scheduled Campaign Queue Dispatching

### Overview

Cron-driven scheduled campaign queue dispatching handles fan-out operations for due marketing broadcasts and scheduled transactional workflows. The cron endpoint `GET /api/cron/campaigns/queue-scheduled` runs under `withCron` with a forced dynamic configuration and a maximum execution duration of 600 seconds. It fans out concurrently using `Promise.allSettled` to execute `queueTransactionalCampaigns(now)` and `queueMarketingCampaigns(now)`.

Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:1-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L1-L26)

### Call-Chain Execution Walkthrough

The cron handler evaluates both campaign categories, accumulating any failures via `isRejected` and `serializeError`. If both queues return zero items, it reports no campaigns to queue; otherwise, it reports the count of successfully queued marketing and transactional campaigns.

```mermaid
sequenceDiagram
    participant Cron as GET /api/cron/campaigns/queue-scheduled
    participant Trans as queueTransactionalCampaigns()
    participant Mkt as queueMarketingCampaigns()
    participant DB as Prisma Database
    participant QStash as enqueueBatchJobs()

    Cron->>Trans: queueTransactionalCampaigns(now)
    Trans->>Trans: isTransactionalTick(now)
    alt Within 12h tick window
        loop Paginated by CRON_BATCH_SIZE
            Trans->>DB: prisma.campaign.findMany(transactional, active)
            DB-->>Trans: campaigns list
            Trans->>Trans: filter scheduled workflows via isScheduledWorkflow()
            Trans->>QStash: enqueueBatchJobs(scheduledWorkflows)
        end
    end
    Cron->>Mkt: queueMarketingCampaigns(now)
    loop Paginated by CRON_BATCH_SIZE
        Mkt->>DB: prisma.campaign.findMany(marketing, scheduled)
        DB-->>Mkt: campaigns list
        Mkt->>QStash: enqueueBatchJobs(broadcast-marketing-campaign)
    end
    Cron-->>Cron: Aggregate status and return logAndRespond
```

Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:20-178](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L20-L178)

> [!WARNING]
> Marketing campaigns with a `sending` status are never reclaimed by the cron queue. Failures trigger Slack alerts so administrators can resume them manually.

Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:139-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L139-L141)

### Queue Batching and Flow Control Configuration

Both queue functions page through database records using `CRON_BATCH_SIZE` and process items in ascending campaign ID order. When batching jobs for QStash delivery via `enqueueBatchJobs`, each queue item configures specific target endpoints, deduplication IDs, and flow control limits.

| Queue Operation | Target URL | Flow Control Key | Parallelism | Deduplication ID |
|-----------------|------------|------------------|-------------|------------------|
| Transactional Workflow | `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${workflow.id}` | `execute-scheduled-workflow` | 10 | `workflow.id` |
| Marketing Broadcast | `${APP_DOMAIN_WITH_NGROK}/api/cron/campaigns/broadcast` | `broadcast-marketing-campaign-${campaign.id}` | 1 | None |

Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:111-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L111-L121), [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:159-170](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L159-L170)

> [!TIP]
> Transactional campaigns check `isTransactionalTick(now)`, which restricts execution to the first 5 minutes of 00:00 or 12:00 UTC. QStash deduplication lasts 10 minutes, absorbing Vercel cron jitter without leaking duplicate publishes.

Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:58-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L58-L69)

## Broadcast Execution and Email Rendering

### Overview

The broadcast execution engine handles the ingestion, sender verification, database batching, and template rendering of marketing campaigns triggered by QStash. Operating under a force-dynamic route configuration, the broadcast endpoint parses the incoming request body against a strict Zod schema enforcing `campaignId`, optional `startingAfter` cursor pagination, and a `batchNumber` defaulting to `1`.

Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:28-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L28-L36), [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:43-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L43-L56)

### Call-Chain Execution Walkthrough

When QStash triggers `/api/cron/campaigns/broadcast`, the handler executes a structured validation, concurrency claim, and batch processing sequence.

```mermaid
sequenceDiagram
    participant QStash as QStash Webhook
    participant Route as POST /api/cron/campaigns/broadcast
    participant DB as Prisma Database
    participant Email as @dub/email (sendBatchEmail)

    QStash->>Route: POST request with rawBody & QStash-Signature
    Route->>Route: verifyQstashSignature()
    Route->>Route: schema.parse(JSON.parse(rawBody))
    Route->>DB: prisma.campaign.findUnique(campaignId)
    DB-->>Route: campaign & program emailDomains
    Route->>Route: Validate status (scheduled/sending) & schedule time (< 5 min diff)
    alt First batch (no startingAfter)
        Route->>DB: prisma.campaign.updateMany (claim status to sending & qstashMessageId)
        DB-->>Route: claimed count
    end
    Route->>Route: cancelCampaignIfInvalidFromAddress()
    Route->>DB: prisma.programEnrollment.findMany (take: EMAIL_BATCH_SIZE, skip cursor)
    DB-->>Route: programEnrollments list
    loop For each enrollment recipient
        Route->>Route: resolveCampaignEmailVariables() & renderCampaignEmailHTML()
    end
    Route->>Email: sendBatchEmail(renderedBatches)
    alt More enrollments remain
        Route->>QStash: qstash.publishJSON() for next batch
    end
    Route-->>QStash: Log and respond success
```

Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:45-227](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L45-L227)

> [!WARNING]
> If a campaign is scheduled to broadcast 5 or more minutes in the future, the broadcast execution skips immediately to prevent premature dispatching caused by scheduling errors.

Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:94-106](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L94-L106)

### Broadcast Configuration Constants and Limits

The broadcast subsystem relies on fixed batch sizes and delay thresholds to maintain deliverability and respect rate limits when sending bulk partner communications.

| Constant Name | Value | Purpose |
|---------------|-------|---------|
| `EMAIL_BATCH_SIZE` | `100` | Number of partner enrollments retrieved and processed per execution batch. |
| `BATCH_DELAY_SECONDS` | `2` | Standard delay interval scheduled between consecutive broadcast batches. |
| `EXTENDED_DELAY_SECONDS` | `30` | Extended delay interval applied after reaching the batch threshold. |
| `EXTENDED_DELAY_INTERVAL` | `25` | Number of batches after which the extended delay is triggered. |

Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:38-41](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L38-L41)

> [!NOTE]
> Concurrency collisions are prevented when `startingAfter` is absent by checking the `Upstash-Message-Id` header and claiming the campaign status from `scheduled` to `sending` via an atomic `updateMany` query.

Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:114-136](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L114-L136)

### Email Template Rendering and Styling

The campaign email template uses `@react-email/components` paired with Tailwind CSS styling to render responsive marketing and transactional emails. The base color constant `CAMPAIGN_EMAIL_TEXT_COLOR` is explicitly set to `#000000` and applied inline to list items and HTML body containers.

```tsx
export const CAMPAIGN_EMAIL_TEXT_COLOR = "#000000";

export default function CampaignEmail({
  program = {
    name: "Acme",
    slug: "acme",
    logo: "https://assets.dub.co/misc/acme-logo.png",
    messagingEnabledAt: new Date(),
  },
  campaign = {
    type: "marketing",
    preview: "Test Preview",
    body: `<p xmlns="http://www.w3.org/1999/xhtml">Hi <span class="px-1 py-0.5 bg-blue-100 text-blue-700 rounded font-semibold" data-type="mention" data-id="PartnerName">{{PartnerName}}</span>,</p>`,
  },
}: {
  program?: {
    name: string;
    slug: string;
    logo: string | null;
    messagingEnabledAt?: Date | null | undefined;
  };
  campaign?: {
    type: "transactional" | "marketing";
    preview?: string | null;
    body: string;
  };
}) {
  const styledHtml = `
    <div style="max-width: 100%; overflow: hidden;">
      ${campaign.body}
    </div>
  `;

  return (
    <Html>
      <Head />
      {campaign.preview && <Preview>{campaign.preview}</Preview>}
      <Tailwind>
        <Body className="mx-auto my-auto bg-white font-sans">
          <Container className="mx-auto my-10 max-w-[600px] px-10 py-5">
            <Section className="my-8">
              <div className="flex items-center">
                <Img
                  src={program.logo || "https://assets.dub.co/wordmark.png"}
                  width="32"
                  height="32"
                  alt={program.name}
                  className="rounded-full"
                />
                <Section className="ml-4">
                  <Heading className="my-0 text-lg font-semibold text-black">
                    {program.name}
                  </Heading>
                  <Link
                    className="text-[13px] font-medium text-neutral-500 underline"
                    href={`https://partners.dub.co/programs/${program.slug}`}
                  >
                    View program in Dub
                  </Link>
                </Section>
              </div>
            </Section>

            <Section>
              <div
                style={{
                  fontSize: "14px",
                  lineHeight: 1.7142857,
                  color: CAMPAIGN_EMAIL_TEXT_COLOR,
                }}
                dangerouslySetInnerHTML={{ __html: styledHtml }}
              />
            </Section>

            {campaign.type === "marketing" && (
              <Section className="border-t border-neutral-200">
                <Hr className="mx-0 my-3 w-full border border-neutral-200" />
                <Text className="text-[12px] leading-6 text-neutral-500">
                  Don't want to receive marketing emails from any programs on
                  Dub?{" "}
                  <Link
                    className="text-neutral-700 underline"
                    href="https://partners.dub.co/profile/notifications"
                  >
                    Update your notification settings here.
                  </Link>
                </Text>
              </Section>
            )}
          </Container>
        </Body>
      </Tailwind>
    </Html>
  );
}
```

Sources: [packages/email/src/templates/campaign-email.tsx:18-124](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx#L18-L124)

> [!TIP]
> Transactional campaigns with `messagingEnabledAt` render an explicit "Reply in Dub" button block, whereas marketing campaigns append a standard unsubscribe and notification preferences footer.

Sources: [packages/email/src/templates/campaign-email.tsx:92-118](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx#L92-L118)

## Targeted Workflow Campaign Conditions

### Overview

Targeted campaigns executed through automated workflow steps rely on granular condition evaluation, cohort group mapping, and partner link aggregation. When an execution context targets specific partner identities or broad program enrollments, campaign routing matches recipient attributes against campaign configuration parameters.

### Workflow Execution and Rule Evaluation

The execution pipeline for send-campaign workflows validates action types, retrieves campaign eligibility parameters, and resolves target enrollments. The call chain for dispatching targeted campaign steps follows a precise resolution sequence:

`executeSendCampaignWorkflow()` → `parseWorkflowConfig()` → `prisma.campaign.findUnique()` → `resolveProgramEnrollment()` / `resolveProgramEnrollments()` → deduplication against `prisma.notificationEmail.findMany()` → `chunk()` → `sendBatchEmail()`.

```typescript
export const executeSendCampaignWorkflow = async ({
  workflow,
  context,
}: {
  workflow: Workflow;
  context?: WorkflowContext;
}) => {
  const { conditions, action } = parseWorkflowConfig(workflow);

  if (action.type !== WORKFLOW_ACTION_TYPES.SendCampaign) {
    console.log(
      `Workflow ${workflow.id} is not a send campaign workflow: ${action.type}`,
    );
    return;
  }

  const { campaignId } = action.data;
  const { programId, partnerId } = context?.identity || {
    programId: workflow.programId,
    partnerId: undefined,
  };
...
```

Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:43-63](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L43-L63)

> [!WARNING]
> Duplicate prevention is enforced prior to batch rendering by querying existing `NotificationEmail` records with type `"Campaign"` for the target campaign and partner IDs, removing any previously notified partners from the active execution chunk.

Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:122-149](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L122-L149)

### Cohort Filtering and Partner Link Parameters

Workflows extract group IDs and partner tag IDs directly from campaign relations to filter matching program enrollments. The table below outlines the core components and payloads governing workflow campaign evaluations.

| Parameter / Type | Source Entity | Purpose |
| :--- | :--- | :--- |
| `conditions` | `WorkflowCondition[]` | Evaluates custom rules via `evaluateWorkflowConditions` during enrollment resolution. |
| `campaignGroupIds` | `string[]` | Extracted via `pluck(campaign.groups, "groupId")` to scope recipients to specific partner groups. |
| `campaignPartnerTagIds` | `string[]` | Extracted via `pluck(campaign.partnerTags, "partnerTagId")` to target tagged partner cohorts. |
| `alreadySentPartnerIds` | `string[]` | Dedupes recipients by checking existing `NotificationEmail` records for the target campaign. |
| `programEnrollmentsChunks` | `Prisma.ProgramEnrollmentGetPayload[][]` | Splits resolved enrollments into batches of 100 via `chunk(programEnrollments, 100)` for delivery. |

Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:3-110](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L3-L110), [apps/web/lib/api/workflows/send-campaign/execute.ts:123-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L123-L168)

> [!TIP]
> Partner links and performance metrics can be aggregated alongside campaign runs using `aggregatePartnerLinksStats` to feed downstream analytics and variable interpolation.

Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:4](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L4)

## Related

- [Email Templates and Delivery](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/email-templates-and-delivery)
- [Partner Program Management](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-program-management)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
