Getting Started
Core Architecture
Link Engine
Analytics & Attribution
Partners & Affiliates
Third-Party Integrations
Identity & Security
Automation & Messaging
Developer Tools
The following files were used as context for generating this wiki page:
The Commission Rules and Rewards engine manages partner compensation structures by defining flexible earning models, evaluation criteria, and automated payout pipelines across referral programs. It solves the complexity of multi-tiered affiliate incentives by supporting both percentage-based revenue shares and flat-fee payouts, combined with conditional logic modifiers based on partner performance metrics, geographic attributes, or specific product IDs. The system ensures precise financial tracking through automated asynchronous workflows that enforce spend limits, duration caps, and strict eligibility checks prior to recording commissions. Furthermore, it integrates cron-driven periodic execution for custom retainers alongside native reconciliation mechanisms for importing foreign platform commissions, providing comprehensive administrative control over partner earnings and lifecycle adjustments. Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:366-408, apps/web/lib/zod/schemas/rewards.ts:113-173, apps/web/lib/partners/determine-partner-reward.ts:118-150, and apps/web/lib/api/rewards/create-custom-reward-commissions.ts:150-178
The reward configuration and schema validation engine structures partner compensation parameters using Zod schemas and Prisma models. Payout structures divide into two primary models: flat incentives and percentage revenue shares, defined alongside commission types that determine whether payouts run as recurring ongoing rewards or single one-off transactions. Sources: apps/web/lib/zod/schemas/rewards.ts:19-32, apps/web/ui/partners/rewards/rewards-logic.tsx:74-83
The system supports five distinct event types for triggering compensation: sale, lead, click, referral, and custom. Each event type maintains specialized metadata and target use cases, ranging from high-DR traffic publishers to multi-month B2B sales cycles and recurring retainers. Sources: apps/web/ui/partners/rewards/reward-event-descriptions.tsx:11-60
When configuring sale rewards, administrators choose between two commission structures: recurring ongoing payouts or one-off single payouts. Selecting recurring rewards enables duration configuration options, defaulting to lifetime or bounded month intervals. Sources: apps/web/lib/zod/schemas/rewards.ts:19-32, apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx:254-354
Note
Values of 0 and 1 month intervals are automatically filtered out from selectable recurring max durations in the form UI, as 1-month periods are restricted exclusively to discount rules.
Sources: apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx:346-347
Reward conditions evaluate nested entity attributes across four distinct domain entities: partner, customer, sale, and lead. Each entity exposes typed attributes for constructing rule modifiers and criteria groups. Sources: apps/web/lib/zod/schemas/rewards.ts:34-111
country, totalClicks, totalLeads, totalConversions, totalSaleAmount (currency), and totalCommissions (currency). Sources: apps/web/lib/zod/schemas/rewards.ts:64-98country, alongside event-specific source channels such as tracked APIs, submitted partner leads, Stripe free trials, and HubSpot integrations. Sources: apps/web/lib/zod/schemas/rewards.ts:101-111, apps/web/lib/zod/schemas/rewards.ts:131-167productId, amount (currency), type (new versus recurring), subscription duration months, subscription start dates, and signup dates. Sources: apps/web/lib/zod/schemas/rewards.ts:229-253Evaluating commission rules and determining final reward amounts involves resolving context-specific modifiers, checking event column mappings, and applying rate structures against sales volumes. When an event occurs, the system maps the event type (click, lead, or sale) to its corresponding reward database column using REWARD_EVENT_COLUMN_MAPPING. Sources: apps/web/lib/partners/determine-partner-reward.ts:14-18, apps/web/lib/partners/determine-partner-reward.ts:89
The execution path for resolving an applicable partner reward coordinates link-level overrides, program enrollment defaults, metric aggregation, condition evaluation, and final parsing.
determinePartnerReward() — Entry point that accepts an event, programEnrollment, linkId, and optional context. Sources: apps/web/lib/partners/determine-partner-reward.ts:78-88getLinkRewards() — Queries prisma.linkReward.findUnique() using the linkId to check if a specific link overrides the program's default reward. Sources: apps/web/lib/partners/determine-partner-reward.ts:91-94, apps/web/lib/partners/determine-partner-reward.ts:261-282aggregatePartnerLinksStats() — Aggregates link metrics to construct the partner statistics context, including total commissions and country codes. Sources: apps/web/lib/partners/determine-partner-reward.ts:104-114evaluateRewardConditions() — Safely parses reward modifiers via Zod and evaluates them against the enriched context to discover matching rule tiers. Sources: apps/web/lib/partners/determine-partner-reward.ts:118-128getRewardAmount() — Computes the numeric reward value from the serialized reward definition. Sources: apps/web/lib/partners/determine-partner-reward.ts:152RewardSchema.parse() — Validates and serializes the final partner reward object before returning it alongside any matched condition. Sources: apps/web/lib/partners/determine-partner-reward.ts:158-161Warning
When Stripe line items include a productId modifier, determinePartnerRewards splits the evaluation per product by iterating over context.sale.products, assigning product.amount while forcing quantity to 1 for each product iteration.
Sources: apps/web/lib/partners/determine-partner-reward.ts:166-237
Once a reward is determined, actual earnings are calculated via calculateSaleEarnings, which distinguishes between flat-fee and percentage models. Flat-fee rewards multiply sale quantity by the reward amount, whereas percentage rewards calculate rounded integer cents from the sale amount. Sources: apps/web/lib/api/sales/calculate-sale-earnings.ts:8-28
export const calculateSaleEarnings = ({
reward,
sale,
}: {
reward: Pick<RewardProps, "type" | "amountInCents" | "amountInPercentage">;
sale: Pick<Commission, "quantity" | "amount">;
}) => {
if (!reward) {
return 0;
}
const amount = getRewardAmount(reward);
if (reward.type === "flat") {
return sale.quantity * amount;
} else if (reward.type === "percentage") {
return Math.round((sale.amount * amount) / 100);
}
return 0;
};For user-facing displays, constructRewardAmount evaluates modifier ranges. If every modifier matches the primary reward's type and maximum duration, it constructs a range string formatted as "Up to X%", or formats flat values using currencyFormatter. If modifiers do not match the primary reward parameters, it falls back to displaying the primary reward value directly. Sources: apps/web/lib/api/sales/construct-reward-amount.ts:6-79
Tip
When constructing display ranges in constructRewardAmount, modifiers with undefined amounts fall back to Infinity for minimum bounds and 0 for maximum bounds during calculation.
Sources: apps/web/lib/api/sales/construct-reward-amount.ts:40-56
The partner commission workflow execution pipeline processes newly generated commissions through a series of asynchronous validation steps, duration checks, earnings clamping rules, and post-processing triggers. When a commission workflow is invoked, raw earnings are computed based on event types (such as multiplying lead quantities or reducing sale items), evaluated against max duration constraints for recurring or first-sale subscriptions, checked against spend limits, validated for fraud or custom reward enrollment status, and finally dispatched alongside webhook and cron sync tasks.
Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:366-470, apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:16-33
Before commission creation is finalized, clampEarningsToSpendLimit() calculates whether a partner has exceeded configured spend limits for a specific reward interval. The reward cap scope differs by event type: sales are evaluated at both the partner and customer level, whereas clicks and leads are restricted at the partner level only.
Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:773-775, apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:802-838
The clamping helper queries existing commission records matching the criteria, sums their earnings, and clamps the current earnings between zero and the remaining spend limit budget.
async function clampEarningsToSpendLimit({
reward,
earnings,
programId,
partnerId,
customerId,
referenceDate,
}: {
reward: Pick<
RewardProps,
"event" | "spendLimitAmount" | "spendLimitInterval"
>;
earnings: number;
programId: string;
partnerId: string;
customerId: string;
referenceDate: Date;
}) {
if (
earnings === 0 ||
!reward.spendLimitAmount ||
!reward.spendLimitInterval
) {
return earnings;
}
const { startDate, endDate } = getRewardSpendLimitWindow({
spendLimitInterval: reward.spendLimitInterval,
referenceDate,
});
const {
_sum: { earnings: totalEarnings },
} = await prisma.commission.aggregate({
where: {
programId,
partnerId,
...(reward.event === "sale" ? { customerId } : {}),
type: reward.event,
status: {
in: ["pending", "processed", "paid", "hold"],
},
...(startDate && endDate
? {
createdAt: {
gte: startDate,
lte: endDate,
},
}
: {}),
},
_sum: {
earnings: true,
},
});
return Math.max(
0,
Math.min(earnings, reward.spendLimitAmount - (totalEarnings ?? 0)),
);
}Caution
Custom reward jobs are queued from a snapshot of eligible enrollments. The system re-checks at write time (programEnrollment.customRewardId === rewardId and status === "approved") to prevent paying out partners who were banned, deactivated, or moved off a reward before the workflow executed.
Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:446-463
Once earnings are verified, the execution pipeline wraps up by mapping asynchronous notifications and synchronization actions into an array of execution results. Concurrently, due commissions are aggregated into payouts via Upstash QStash job batches.
Sources: apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:760-771, apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:38-58
The table below outlines the post-processing steps mapped during the final workflow return phase.
The custom reward subsystem relies on daily UTC cron executions to evaluate scheduled payouts. The cron entry point at /api/cron/rewards/queue-custom-commissions initializes by retrieving the current period date in UTC using getUtcPeriodDate(), which parses date boundaries via toUtcDateOnly() and formats them into a standardized YYYY-MM-DD string.
Sources: apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:12-13, apps/web/lib/api/rewards/custom-reward-utils.ts:22-42
Cadence evaluation is governed by isCadenceDue(), which compares the target date against a reward's anchor date across four distinct frequency tiers. If the target date precedes the anchor date, evaluation immediately returns false.
Note
When evaluating monthly and yearly cadences, expectedUtcDayOfMonth safely clamps anchor dates (such as the 31st) to the final day of shorter months, preventing skipped or mismatched intervals during edge-case periods.
Sources: apps/web/lib/api/rewards/custom-reward-utils.ts:48-60
Once due rewards are identified via findDueCustomRewards(), the cron job fans out execution using createCustomCommissionJob.dispatchBatch(). Each batch entry assigns a deterministic deduplication identifier (custom-commission-${rewardId}-${periodDate}) and enforces flow control with a parallelism limit of 5.
The execution call chain processes custom commissions through a pagination and recursion pipeline:
GET /api/cron/rewards/queue-custom-commissions → createCustomCommissionJob.handle() → createCustomRewardCommissions() → dispatchWorkflows()
Sources: apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:12-35, apps/web/lib/jobs/handlers/create-custom-commission-job.ts:17-33, apps/web/lib/api/rewards/create-custom-reward-commissions.ts:19-190
During createCustomRewardCommissions(), partner enrollments are fetched in pages of 100. For rewards configured with a maxDuration, the handler batch-fetches the earliest custom commission per partner using Prisma's groupBy and _min aggregation, evaluating hasRewardMaxDurationElapsed() to filter out partners who have exceeded their lifetime earning window.
Sources: apps/web/lib/api/rewards/create-custom-reward-commissions.ts:17-148, apps/web/lib/api/rewards/custom-reward-utils.ts:171-189
Eligible enrollments generate workflow jobs configured with an idempotency key built via buildCommissionIdempotencyKey(). If a result set reaches PAGE_SIZE, nextCursor returns the final partner identifier, prompting createCustomCommissionJob to re-dispatch itself with a 1-second delay to process the subsequent page.
Sources: apps/web/lib/jobs/handlers/create-custom-commission-job.ts:18-32, apps/web/lib/api/rewards/create-custom-reward-commissions.ts:150-189
Sources: apps/web/lib/api/rewards/create-custom-reward-commissions.ts:17-124, apps/web/lib/api/rewards/custom-reward-utils.ts:157-169, apps/web/lib/jobs/handlers/create-custom-commission-job.ts:20-31
The manual commission subsystem provides programmatic endpoints to retrieve, generate, and update partner commissions outside of standard automated tracking events. Workspace administrators can list commissions via the GET /api/commissions endpoint or submit manual commission creations and adjustments via POST /api/commissions. When a request hits the POST route, it validates the request body using createManualCommissionBodySchema and invokes createManualCommissions(). Depending on whether the manual entry type is configured as a custom adjustment or a specific conversion event, the creation logic branches into direct queueing or event resolution.
Sources: apps/web/app/(ee)/api/commissions/route.ts:18-80, apps/web/lib/api/commissions/create-manual-commissions.ts:83-113
When creating manual commissions via createManualCommissions(), the execution path follows a sequence of validations, event insertions, and background tasks:
getProgramEnrollmentOrThrow() verifies that the partner is enrolled in the target program and retrieves their associated links.type === "custom", queuePartnerCommissionCreation() directly queues a custom commission with CommissionSource.user and returns immediately.resolveLinkAndCustomer() resolves the appropriate target link and customer record.type === "sale" and importStripeInvoices is requested, the system verifies workspace Stripe connection settings and customer Stripe IDs, throwing a DubApiError if validation fails. It also checks for invoice collisions using prisma.commission.findUnique().recordEvents() logs the underlying click, lead, or sale events.commissionsToCreate), then sequentially queued via queuePartnerCommissionCreation(), where only the final iteration triggers aggregate due commissions (triggerAggregateDueCommissions: index === commissionsToCreate.length - 1).waitUntil(executeSideEffects(...)) dispatches any secondary side effects in the background.Partner commissions can be modified via updatePartnerCommission(), which handles altering sale amounts, converting currencies using convertCurrency(), recalculating earnings via calculateSaleEarnings(), and transitioning commission statuses.
// Call-chain for updating a partner commission:
// updatePartnerCommission() → prisma.commission.findUnique() → convertCurrency() (if non-USD)
// → determinePartnerReward() → calculateSaleEarnings() → prisma.commission.update()
// → reconcilePayoutAmounts() → waitUntil(syncTotalCommissions() + trackCommissionActivityLog())Caution
Commissions that have already been paid (commission.status === "paid") or belong to locked payouts (!MUTABLE_PAYOUT_STATUSES.includes(commission.payout.status)) cannot be updated. Attempting to modify them throws a DubApiError with a bad_request or not_found code.
Important
When updating a commission's status to fraud or canceled with updateHistoricalCommissions enabled, the system automatically sweeps un-paid historical commissions for the same customer and partner combination, updates their status, nullifies their payoutId, and triggers payout reconciliation across all affected payout identifiers.
The manual commission and adjustments routes support specific query parameters and body schemas for interacting with partner financial records.
Sources: apps/web/app/(ee)/api/commissions/route.ts:22-100, apps/web/lib/api/commissions/update-partner-commission.ts:41-285
External platform commission ingestion pipelines allow historical or live commissions from foreign affiliate networks (such as Rewardful, FirstPromoter, PartnerStack, and Lemon Squeezy) to be synchronized and reconciled into Dub's core commission database. These ingestion modules parse foreign webhook payloads or API export responses, validate customer attribution via Tinybird click and lead events, handle foreign currency conversions using cached FX rates, and prevent double-crediting through dedoorprinting strategies like invoice ID lookups and ±1-hour sliding-window checks.
Sources: apps/web/lib/rewardful/import-commissions.ts:1-404, apps/web/lib/lemonsqueezy/import-commissions.ts:263-610, apps/web/lib/partnerstack/import-commissions.ts:136-397, apps/web/lib/firstpromoter/import-commissions.ts:35-422
When ingesting commissions from external platforms, records pass through validation, conversion, attribution verification, and persistence steps.
// Call-chain for importing external commissions:
// importCommissions() → FirstPromoterApi.listCommissions() / equivalent → prisma.customer.findMany()
// → getLeadEvents() → createCommission() → prisma.commission.findUnique() (deduplication check)
// → convertCurrencyWithFxRates() → prisma.commission.create() → recordSaleWithTimestamp()
// → prisma.link.update() → syncPartnerLinksStats() → syncTotalCommissions()Sources: apps/web/lib/rewardful/import-commissions.ts:141-403, apps/web/lib/lemonsqueezy/import-commissions.ts:377-609, apps/web/lib/firstpromoter/import-commissions.ts:35-421
Warning
If an external platform commission does not provide a direct Stripe invoice ID, importers fall back to deduplicating against existing Dub records using the customer's Stripe customer ID or external key combined with a $\pm 1$-hour creation timestamp window (createdAt between $t - 3600000$ and $t + 3600000$). This protects against duplicate commission records during transition periods where both systems record charges simultaneously.
Sources: apps/web/lib/rewardful/import-commissions.ts:237-251, apps/web/lib/partnerstack/import-commissions.ts:294-306, apps/web/lib/firstpromoter/import-commissions.ts:251-263
External transactions originating in non-USD currencies are normalized using exchange rates retrieved from Redis caches (fxRates:usd). Lemon Squeezy integration logic explicitly checks alternative pricing structures if order subtotals are zero during initial subscription checkouts.
function resolveAmountUsd({
amount,
amountUsd,
currency,
fxRates,
}: {
amount: number;
amountUsd: number | null | undefined;
currency: string;
fxRates: Record<string, string> | null;
}): number | null {
if (amountUsd != null) {
return amountUsd;
}
if (currency.toUpperCase() === "USD") {
return amount;
}
if (!fxRates) {
return null;
}
return convertCurrencyWithFxRates({ currency, amount, fxRates }).amount;
}Tip
Lemon Squeezy imports prioritize platform-provided USD totals (amountUsd). If the order subtotal evaluates to zero on a subscription's first charge, the importer inspects firstOrderItemPrice before attempting FX conversion.
Partner dashboard interfaces and embed views render referral earnings by fetching paginated commission records via SWR hooks and presenting status badges, customer attribution identifiers, and formatted currency values.
Sources: apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx:23-120, apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx:42-166