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:
Affiliate Migration Importers provide a robust, asynchronous background processing architecture designed to migrate existing affiliate programs, campaign configurations, partners, customers, and historical commissions from external platforms like Rewardful, FirstPromoter, Tolt, PartnerStack, and Lemon Squeezy into Dub. The system solves complex data portability and synchronization challenges during platform transitions by securely caching API credentials, chunking large batch payloads via Upstash Redis, and orchestrating multi-step background jobs through QStash cron route handlers. By automating currency conversion, Stripe coupon mapping, customer external ID reconciliation, and Tinybird event logging, the importer suite ensures data integrity and continuous analytics synchronization without disrupting live workspace operations.
Sources: apps/web/lib/rewardful/importer.ts:1-50, apps/web/app/ee/api/cron/import/rewardful/route.ts:13-42, apps/web/lib/rewardful/import-commissions.ts:35-138
The frontend layer exposes dedicated React modal components and custom hooks for each supported external affiliate platform: FirstPromoter, Rewardful, and PartnerStack. Each modal handles state collection, credential entry, step progression, and server action triggers to initialize background import jobs.
Sources: apps/web/ui/modals/import-firstpromoter-modal.tsx:19-201, apps/web/ui/modals/import-rewardful-modal.tsx:39-219, apps/web/ui/modals/import-partnerstack-modal.tsx:13-188
Each modal component interacts with URL query parameters via useImportModalParam to manage visibility and cleans up parameters upon dismissal. The credential forms capture platform-specific keys and tokens before invoking authenticated server actions using next-safe-action.
Sources: apps/web/ui/modals/import-firstpromoter-modal.tsx:66-178, apps/web/ui/modals/import-firstpromoter-modal.tsx:181-201, apps/web/ui/modals/import-rewardful-modal.tsx:39-136, apps/web/ui/modals/import-rewardful-modal.tsx:180-219, apps/web/ui/modals/import-partnerstack-modal.tsx:13-167, apps/web/ui/modals/import-partnerstack-modal.tsx:169-188
Unlike FirstPromoter and PartnerStack which execute in a single step, the Rewardful import modal features a multi-step wizard interface. Users first submit an API token, fetch their campaigns, and subsequently select specific campaign identifiers for import.
The server action triggers enforce workspace permission checks before validating program prerequisites and queuing the background import job. For instance, startRewardfulImportAction executes the following sequence:
authActionClient → throwIfNoPermission() (checking roles owner or member) → getDefaultProgramIdOrThrow() → getProgramOrThrow() → domain & URL validation checks → rewardfulImporter.queue().
Important
Both workspace domain and program URL must be explicitly set on the target program object before startRewardfulImportAction will successfully dispatch an import job to the queue.
Asynchronous cron route handlers orchestrate step-based data ingestion across all supported affiliate migration providers. Each provider exposes a POST route handler configured with export const dynamic = "force-dynamic" to handle incoming requests dispatched by QStash or internal cron runners. Request authentication and payload validation are strictly enforced before dispatching jobs to respective import handlers via conditional switch statements.
Sources: apps/web/app/ee/api/cron/import/rewardful/route.ts:11-42, apps/web/app/ee/api/cron/import/firstpromoter/route.ts:11-48, apps/web/app/ee/api/cron/import/tapfiliate/route.ts:11-38, apps/web/app/ee/api/cron/import/tolt/route.ts:12-50, apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:8-26
Route verification utilizes two distinct wrapper strategies depending on the migration target. Routes for Rewardful, FirstPromoter, and Tolt read raw request text and explicitly invoke verifyQstashSignature({ req, rawBody }), subsequently parsing request payloads with provider-specific Zod schemas inside a try...catch block wrapped with handleAndReturnErrorResponse. Conversely, Tapfiliate and Lemon Squeezy routes leverage the higher-order wrapper withCron alongside logAndRespond.
Sources: apps/web/app/ee/api/cron/import/rewardful/route.ts:13-42, apps/web/app/ee/api/cron/import/firstpromoter/route.ts:13-48, apps/web/app/ee/api/cron/import/tapfiliate/route.ts:13-38, apps/web/app/ee/api/cron/import/tolt/route.ts:14-50, apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:10-26
Note
verifyQstashSignature requires access to the raw request text (req.text()) prior to JSON parsing to properly validate cryptographic headers supplied by QStash.
Sources: apps/web/app/ee/api/cron/import/rewardful/route.ts:15-16, apps/web/app/ee/api/cron/import/firstpromoter/route.ts:15-20, apps/web/app/ee/api/cron/import/tolt/route.ts:16-21
The migration cron handlers route incoming action strings to specific ingestion modules. Each provider supports a unique subset of import and maintenance routines.
Sources: apps/web/app/ee/api/cron/import/rewardful/route.ts:8-36, apps/web/app/ee/api/cron/import/firstpromoter/route.ts:7-42, apps/web/app/ee/api/cron/import/tapfiliate/route.ts:7-35, apps/web/app/ee/api/cron/import/tolt/route.ts:8-44, apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:5-23
The campaign import subsystem extracts Rewardful campaigns, maps them to Dub partner groups, establishes reward structures, converts associated Stripe coupons into Dub discount configurations, and triggers subsequent background tasks via QStash.
The campaign migration executes through a precise sequence of database queries, API lookups, and transactional upserts:
importCampaigns() → prisma.program.findUniqueOrThrow() → rewardfulImporter.getCredentials() → new RewardfulApi() → rewardfulApi.listCampaigns() → prisma.partnerGroup.upsert() → prisma.reward.create() → stripe.coupons.retrieve() → validateStripeCouponForDubDiscount() → stripeCouponToDubDiscount() → prisma.discount.create() → rewardfulImporter.queue()
Note
Rewardful's API can occasionally return stripe_coupon_id: null even when a campaign possesses a valid Stripe coupon. In such cases, the discount cannot be automatically mapped and must be manually recreated on Dub.
During iteration over campaigns matching the payload's campaignIds, each campaign is upserted into the PartnerGroup table using a generated slug (rewardful-${campaignId}). Default group styles, holding periods, and default referral links are inherited from the program's default partner group.
const createdGroup = await prisma.partnerGroup.upsert({
where: {
programId_slug: {
programId,
slug: groupSlug,
},
},
create: {
id: createId({ prefix: "grp_" }),
programId,
name: `(Rewardful) ${campaign.name}`,
slug: groupSlug,
color: randomValue(RESOURCE_COLORS),
logo,
wordmark,
brandColor,
holdingPeriodDays,
autoApprovePartnersEnabledAt,
...(additionalLinks && {
additionalLinks: sanitizeAdditionalLinks(additionalLinks),
}),
...(maxPartnerLinks && { maxPartnerLinks }),
...(linkStructure && { linkStructure }),
...(applicationFormData && { applicationFormData }),
...(landerData && { landerData }),
partnerGroupDefaultLinks: {
create: {
id: createId({ prefix: "pgdl_" }),
programId,
domain: program.domain,
url: program.url,
},
},
},
update: {},
});The importer maps Rewardful commission parameters directly onto Dub's Reward schema. Max commissions of 1 or max commission periods of 0 map to a maxDuration of 0 (indicating commissions for the first sale only). Flat reward amounts use amountInCents, while percentage rewards use amountInPercentage.
Stripe coupons linked to campaigns are retrieved via Stripe Connect (program.workspace.stripeConnectId), validated, converted using stripeCouponToDubDiscount, and stored in the Discount table.
const createdDiscount = await prisma.discount.create({
data: {
id: createId({ prefix: "disc_" }),
programId,
groupId: createdGroup.id,
amount: dubDiscountAttrs?.amount ?? 0,
type: dubDiscountAttrs?.type ?? "percentage",
maxDuration: dubDiscountAttrs?.maxDuration ?? null,
couponId: campaign.stripe_coupon_id,
defaultForPartnerGroup: {
connect: {
id: createdGroup.id,
},
},
},
});Partner ingestion bridges external affiliate platforms (such as FirstPromoter and Rewardful) into Dub's unified partner and program enrollment architecture. Incoming platform partner entities are mapped to Dub's Partner and ProgramEnrollment models, assigned to specific partner groups, enriched with social handles or reward attributes, approved automatically, and paired with bulk-created referral tracking links.
Sources: apps/web/lib/firstpromoter/import-partners.ts:116-254, apps/web/lib/rewardful/import-partners.ts:164-257
The partner ingestion pipeline iterates through paginated partner lists from the upstream API provider, validating activity states and filtering eligible records before executing core database mutations.
Sources: apps/web/lib/firstpromoter/import-partners.ts:52-101, apps/web/lib/rewardful/import-partners.ts:52-137
For FirstPromoter partners, social platforms are mapped across six core types (website, youtube, twitter, linkedin, instagram, tiktok) and upserted via upsertPartnerPlatform(). Rewardful partners evaluate campaign filters and require active states with positive lead counts before syncing metadata hashes into Redis.
Sources: apps/web/lib/firstpromoter/import-partners.ts:148-181, apps/web/lib/rewardful/import-partners.ts:65-75, apps/web/lib/rewardful/import-partners.ts:117-129
Once the partner and their program enrollment are established with an approved status, tracking links are generated using the program's domain and destination URL. If the program lacks a configured domain or URL, link creation is aborted with an error log.
Sources: apps/web/lib/firstpromoter/import-partners.ts:183-224, apps/web/lib/rewardful/import-partners.ts:198-228
Sources: apps/web/lib/firstpromoter/import-partners.ts:226-238, apps/web/lib/rewardful/import-partners.ts:231-243
Warning
If partner link creation fails during bulk insertion, the database transaction for the partner and program enrollment remains committed. The enrollment ID is still preserved and passed to the search synchronization queue so that partner indexing is not permanently blocked by link generation errors.
Every imported partner's program enrollment is forced to an approved status on creation and update. Immediately following enrollment upsertion, approveLinkedApplication() is invoked using the enrollment's applicationId and the triggering userId.
Sources: apps/web/lib/firstpromoter/import-partners.ts:183-214, apps/web/lib/rewardful/import-partners.ts:198-223
const programEnrollment = await prisma.programEnrollment.upsert({
where: {
partnerId_programId: {
partnerId: partner.id,
programId: program.id,
},
},
create: {
id: createId({ prefix: "pge_" }),
programId: program.id,
partnerId: partner.id,
status: "approved",
groupId: group.id,
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
saleRewardId: group.saleRewardId,
referralRewardId: group.referralRewardId,
customRewardId: group.customRewardId,
discountId: group.discountId,
},
update: {
status: "approved",
},
include: {
links: true,
},
});
await approveLinkedApplication({
applicationId: programEnrollment.applicationId,
userId,
});Once batch page processing concludes, queuePartnerSearchSync() is called to update partner search indexes across the newly enrolled partner records.
Sources: apps/web/lib/firstpromoter/import-partners.ts:97-101, apps/web/lib/rewardful/import-partners.ts:133-136
Customer ingestion processes referral records in batches, validates campaign filters, checks for existing customer mappings via Stripe customer IDs or external IDs, and records synthetic click and lead events into Tinybird.
The import pipeline follows an explicit sequence of API fetches, batch validations, and persistence calls:
importCustomers() → rewardfulApi.listCustomers() → prisma.customer.findMany() → chunk() → createCustomer() → recordClick() → clickEventSchemaTB.parse() → prisma.customer.create() → recordLeadWithTimestamp() & prisma.link.update() & syncPartnerLinksStats().
Before creating individual customer records, the importer queries existing database records matching either the Stripe customer ID (cus_...) or the external customer ID (referral.customer.id) associated with the workspace project.
const existingCustomers =
stripeCustomerIds.length === 0 && externalIds.length === 0
? []
: await prisma.customer.findMany({
where: {
OR: [
...(stripeCustomerIds.length > 0
? [{ stripeCustomerId: { in: stripeCustomerIds } }]
: []),
...(externalIds.length > 0
? [
{
projectId: workspace.id,
externalId: { in: externalIds },
},
]
: []),
],
},
});Warning
If a referral lacks both a link token and a coupon token, or if the associated link cannot be resolved via prisma.link.findFirst, the customer creation is skipped and an error is logged to Tinybird with code LINK_NOT_FOUND.
When a valid customer is processed, a synthetic click request is generated to instantiate click metadata, which is parsed through clickEventSchemaTB with bot: 0 and qr: 0. A customer row is then created in PostgreSQL, followed by asynchronous lead recording and link statistics updates.
const clickData = await recordClick({
req: dummyRequest,
clickId: nanoid(16),
workspaceId: workspace.id,
linkId: link.id,
domain: link.domain,
key: link.key,
url: link.url,
skipRatelimit: true,
timestamp: new Date(referral.created_at).toISOString(),
});
const clickEvent = clickEventSchemaTB.parse({
...clickData,
bot: 0,
qr: 0,
});Commission processing handles historical commission records across external affiliate providers such as Rewardful, Tolt, FirstPromoter, Lemon Squeezy, and PartnerStack. The importer validates transaction IDs, handles foreign currency conversion against cached Redis exchange rates (fxRates:usd), logs import validation errors into Tinybird, and records sales with timestamps. It also reconciles aggregate statistics by updating link stats and partner commission totals.
Sources: apps/web/lib/rewardful/import-commissions.ts:35-232, apps/web/lib/tolt/import-commissions.ts:36-246, apps/web/lib/firstpromoter/import-commissions.ts:35-252, apps/web/lib/lemonsqueezy/import-commissions.ts:50-124, apps/web/lib/partnerstack/import-commissions.ts:38-125
The commission import pipeline coordinates API retrieval, customer data matching, currency normalization, and persistent record creation:
importCommissions() → rewardfulApi.listCommissions() → prisma.customer.findMany() → getLeadEvents() → createCommission() → convertCurrencyWithFxRates() → prisma.commission.findUnique() → recordSaleWithTimestamp() → syncTotalCommissions().
Sources: apps/web/lib/rewardful/import-commissions.ts:35-232, apps/web/lib/tolt/import-commissions.ts:36-246, apps/web/lib/partnerstack/import-commissions.ts:38-121
Warning
If a Rewardful referral lacks a valid Stripe customer ID starting with cus_, the commission is skipped and an error is logged to Tinybird with error code STRIPE_CUSTOMER_NOT_FOUND.
Each integrated platform maps its unique string-based status fields to standard Dub CommissionStatus enums (pending, paid, refunded, fraud, canceled).
Sources: apps/web/lib/rewardful/import-commissions.ts:28-33, apps/web/lib/tolt/import-commissions.ts:28-34, apps/web/lib/firstpromoter/import-commissions.ts:28-33, apps/web/lib/partnerstack/import-commissions.ts:26-36, apps/web/lib/lemonsqueezy/import-commissions.ts:643-660
When processing sales and earnings amounts, the importer normalizes non-USD currencies using exchange rates stored in Redis under fxRates:usd. For Lemon Squeezy events, resolveAmountUsd checks explicit amountUsd fields first, falls back to raw amounts if the currency is USD, or computes conversions via convertCurrencyWithFxRates.
Sources: apps/web/lib/rewardful/import-commissions.ts:205-231, apps/web/lib/tolt/import-commissions.ts:221-245, apps/web/lib/firstpromoter/import-commissions.ts:230-242, apps/web/lib/lemonsqueezy/import-commissions.ts:611-641
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;
}
const converted = convertCurrencyWithFxRates({
currency,
amount,
fxRates,
});
return converted.currency.toUpperCase() === "USD" ? converted.amount : null;
}Sources: apps/web/lib/rewardful/import-commissions.ts:54-203, apps/web/lib/firstpromoter/import-commissions.ts:181-198, apps/web/lib/partnerstack/import-commissions.ts:159-166
Historical migration and maintenance scripts handle importing custom domain links and associated tag groups from external link shorteners like Bitly and Rebrandly, as well as executing complex customer and commission backfills. Workspace administrators initiate imports via API routes that verify tokens, sync missing domains to Vercel and Prisma, and dispatch background jobs via QStash.
Sources: apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts:60-143, apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts:94-165
The workspace import endpoints interact with external APIs to inspect groups, domains, and tags before scheduling asynchronous cron tasks.
GET /api/workspaces/[idOrSlug]/import/bitly: Retrieves active Bitly groups and fetches group tags using a Redis-stored bearer token.POST /api/workspaces/[idOrSlug]/import/bitly: Dispatches QStash JSON jobs to ${APP_DOMAIN_WITH_NGROK}/api/cron/import/bitly after ensuring selected domains exist in the workspace.GET /api/workspaces/[idOrSlug]/import/rebrandly: Fetches Rebrandly domains (excluding rebrand.ly), counts their links, and returns total tags.PUT /api/workspaces/[idOrSlug]/import/rebrandly: Saves or updates the Rebrandly API key in Redis under import:rebrandly:${workspace.id}.POST /api/workspaces/[idOrSlug]/import/rebrandly: Verifies folder access permissions, ensures domains are added to Prisma and Vercel via addDomainToVercel, and triggers import cron jobs via QStash.Sources: apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts:14-143, apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts:14-165
The asynchronous cron route handler for Bitly processes incoming QStash payloads, enforces rate limits, handles tag imports, and delegates link migration.
export async function POST(req: Request) {
try {
const rawBody = await req.text();
await verifyQstashSignature({ req, rawBody });
const body = JSON.parse(rawBody);
const { workspaceId, bitlyGroup, importTags, rateLimited = false } = body;
try {
const bitlyApiKey = await redis.get(`import:bitly:${workspaceId}`);
if (rateLimited) {
const isRateLimited = await checkIfRateLimited(bitlyApiKey, body);
if (isRateLimited) {
return NextResponse.json({
response: "rate_limited",
});
}
}
let tagsToId: Record<string, string> | null = null;
if (importTags === true) {
const tagsImported = await redis.get(
`import:bitly:${workspaceId}:tags`,
);
if (!tagsImported) {
const tags = (await fetch(
`https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/tags`,
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bitlyApiKey}`,
},
},
)
.then((r) => r.json())
.then((r) => r.tags)) as string[];
await prisma.tag.createMany({
data: tags.map((tag) => ({
id: createId({ prefix: "tag_" }),
name: tag,
color: randomBadgeColor(),
projectId: workspaceId,
})),
skipDuplicates: true,
});
await redis.set(`import:bitly:${workspaceId}:tags`, "true");
}
tagsToId = await prisma.tag
.findMany({
where: {
projectId: workspaceId,
},
select: {
id: true,
name: true,
},
})
.then((tags) =>
tags.reduce((acc, tag) => {
acc[tag.name] = tag.id;
return acc;
}, {}),
);
}
await importLinksFromBitly({
...body,
tagsToId,
bitlyApiKey,
});
return NextResponse.json({
response: "success",
});
} catch (error) {
const workspace = await prisma.project.findUnique({
where: {
id: workspaceId,
},
select: {
slug: true,
},
});
throw new DubApiError({
code: "bad_request",
message: `Workspace: ${workspace?.slug || workspaceId}. Error: ${error.message}`,
});
}
} catch (error) {
await log({
message: `Error importing Bitly links: ${error.message}`,
type: "cron",
});
return handleAndReturnErrorResponse(error);
}
}Warning
When importing tags from Bitly groups, the system checks Redis for a cached import:bitly:${workspaceId}:tags flag before fetching. If tags have already been imported for the workspace, the remote fetch is skipped and existing tags are loaded from PostgreSQL into memory.
Maintenance scripts like backfill-reuse-commission.ts, fix-case-a-simple.ts, and fix-case-a-complex.ts rectify affiliate link reassignments, duplicate customer event logs in Tinybird, and payout adjustments.
backfill-reuse-commission.ts: Clones existing customer events from Tinybird under a newly generated duplicate customer identifier, records new click, lead, or sale events with updated link attributes, nullifies old commission event IDs, and triggers affiliate commission creation workflows.fix-case-a-simple.ts: Performs simple link and partner ID swaps for coupon code links where no paid commissions occurred via Dub. It updates non-processed commissions, marks processed commissions as pending after clearing their payoutId, deletes associated activity logs, and retally payouts.fix-case-a-complex.ts: Handles complex transfers where paid commissions via Dub already exist. It creates dummy overpayment commissions and corresponding clawback records to balance accounting books before recreating links for the original partner.Sources: apps/web/scripts/programs/backfill-reuse-commission.ts:36-349, apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts:10-195, apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts:12-272