Getting Started
Core Architecture
Link Engine
Analytics & Attribution
Partners & Affiliates
Third-Party Integrations
Identity & Security
Automation & Messaging
Developer Tools
<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](/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/email-templates-and-delivery)
- [Partner Program Management](/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-program-management)