---
title: "Affiliate Migration Importers"
description: "Affiliate Migration Importers provide a robust, asynchronous background processing architecture designed to migrate existing affiliate programs, campaign configurations, partners, customers, and hi..."
last_updated: "2026-10-05T05:07:35.154101+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/affiliate-migration-importers"
---

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

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

- [apps/web/lib/rewardful/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts)
- [apps/web/app/ee/api/cron/import/rewardful/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts)
- [apps/web/app/ee/api/cron/import/firstpromoter/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/lib/rewardful/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/importer.ts)
- [apps/web/lib/tolt/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts)
- [apps/web/lib/tapfiliate/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tapfiliate/importer.ts)
- [apps/web/app/ee/api/cron/import/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/bitly/route.ts)
- [apps/web/lib/firstpromoter/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts)
- [apps/web/app/ee/api/cron/import/tapfiliate/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts)
- [apps/web/app/ee/api/cron/import/tolt/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts)
- [apps/web/ui/modals/import-firstpromoter-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx)
- [apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts)
- [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/rebrandly/route.ts)
- [apps/web/lib/rewardful/import-campaigns.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts)
- [apps/web/lib/firstpromoter/import-partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts)
- [apps/web/lib/lemonsqueezy/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts)
- [apps/web/lib/firstpromoter/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/importer.ts)
- [apps/web/lib/rewardful/import-partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts)
- [apps/web/lib/partnerstack/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/importer.ts)
- [apps/web/lib/partnerstack/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts)
- [apps/web/scripts/programs/backfill-reuse-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts)
- [apps/web/lib/rewardful/import-customers.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts)
- [apps/web/ui/modals/import-rewardful-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx)
- [apps/web/ui/modals/import-partnerstack-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx)
- [apps/web/lib/tolt/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/importer.ts)
- [apps/web/lib/actions/partners/start-rewardful-import.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-rewardful-import.ts)
</details>

## Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/importer.ts#L1-L50), [apps/web/app/ee/api/cron/import/rewardful/route.ts:13-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L13-L42), [apps/web/lib/rewardful/import-commissions.ts:35-138](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L35-L138)

## Modal UI and Import Actions

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx#L19-L201), [apps/web/ui/modals/import-rewardful-modal.tsx:39-219](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L39-L219), [apps/web/ui/modals/import-partnerstack-modal.tsx:13-188](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx#L13-L188)

### Modal Component Forms and Parameters

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`.

| Modal Component | Hook Export | Required Form Fields | Success Route |
| :--- | :--- | :--- | :--- |
| `ImportFirstPromoterModal` | `useImportFirstPromoterModal()` | `apiKey`, `accountId` | `/[slug]/program/partners` |
| `ImportRewardfulModal` | *Managed via URL param* | `apiToken`, `campaignIds` | `/[slug]/program/partners` |
| `ImportPartnerStackModal` | `useImportPartnerStackModal()` | `publicKey`, `secretKey` | `/[slug]/program/partners` |

Sources: [apps/web/ui/modals/import-firstpromoter-modal.tsx:66-178](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx#L66-L178), [apps/web/ui/modals/import-firstpromoter-modal.tsx:181-201](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx#L181-L201), [apps/web/ui/modals/import-rewardful-modal.tsx:39-136](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L39-L136), [apps/web/ui/modals/import-rewardful-modal.tsx:180-219](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L180-L219), [apps/web/ui/modals/import-partnerstack-modal.tsx:13-167](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx#L13-L167), [apps/web/ui/modals/import-partnerstack-modal.tsx:169-188](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx#L169-L188)

### Rewardful Import Two-Step Flow

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.

```mermaid
sequenceDiagram
  autonumber
  participant User
  participant Modal as ImportRewardfulModal
  participant TokenAction as setRewardfulTokenAction
  participant SWR as SWRImmutable (/api/.../rewardful/campaigns)
  participant ImportAction as startRewardfulImportAction

  User->>Modal: Enter Rewardful API Token
  Modal->>TokenAction: submit({ workspaceId, token })
  TokenAction-->>Modal: Token saved successfully (onSuccess)
  Modal->>Modal: Advance step to "campaigns"
  Modal->>SWR: Fetch available campaigns
  SWR-->>Modal: RewardfulCampaign[]
  User->>Modal: Select campaigns & submit
  Modal->>ImportAction: startRewardfulImport({ workspaceId, campaignIds })
  ImportAction-->>Modal: Import queued successfully
  Modal->>User: Toast notification & redirect to partners page
```

Sources: [apps/web/ui/modals/import-rewardful-modal.tsx:52-136](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L52-L136)

### Server Action Execution Flow

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.

Sources: [apps/web/lib/actions/partners/start-rewardful-import.ts:18-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-rewardful-import.ts#L18-L51)

## Cron Verification and Orchestration Endpoints

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L11-L42), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:11-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L11-L48), [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:11-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L11-L38), [apps/web/app/ee/api/cron/import/tolt/route.ts:12-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L12-L50), [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:8-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L8-L26)

### Route Handler Execution Patterns and Verification

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`.

```mermaid
sequenceDiagram
  autonumber
  participant QStash as QStash / Cron Scheduler
  participant Route as POST /api/cron/import/[provider]
  participant Verify as verifyQstashSignature / withCron
  participant Zod as Zod Schema Parse
  participant Handler as Provider Action Handler

  QStash->>Route: POST Request with raw body
  Route->>Verify: Validate request signature
  Verify-->>Route: Signature valid
  Route->>Zod: Parse JSON body against payload schema
  Zod-->>Route: Validated payload object
  Route->>Handler: Switch on payload.action & execute
  Handler-->>Route: Operation complete
  Route-->>QStash: Return HTTP 200 "OK"
```

Sources: [apps/web/app/ee/api/cron/import/rewardful/route.ts:13-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L13-L42), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:13-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L13-L48), [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:13-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L13-L38), [apps/web/app/ee/api/cron/import/tolt/route.ts:14-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L14-L50), [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:10-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L10-L26)

> [!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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L15-L16), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:15-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L15-L20), [apps/web/app/ee/api/cron/import/tolt/route.ts:16-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L16-L21)

### Supported Import Actions by Provider

The migration cron handlers route incoming action strings to specific ingestion modules. Each provider supports a unique subset of import and maintenance routines.

| Provider | Supported Action Strings | Target Schema |
| :--- | :--- | :--- |
| **Rewardful** | `import-campaigns`, `import-partners`, `import-affiliate-coupons`, `import-customers`, `import-commissions` | `rewardfulImportPayloadSchema` |
| **FirstPromoter** | `import-campaigns`, `import-partners`, `import-customers`, `import-commissions`, `update-stripe-customers` | `firstPromoterImportPayloadSchema` |
| **Tapfiliate** | `import-groups`, `import-partners`, `import-customers`, `import-commissions`, `update-stripe-customers`, `cleanup-partners` | `tapfiliateImportPayloadSchema` |
| **Tolt** | `import-partners`, `import-links`, `import-customers`, `import-commissions`, `update-stripe-customers`, `cleanup-partners` | `toltImportPayloadSchema` |
| **Lemon Squeezy** | `import-partners`, `import-customers`, `import-commissions` | `lemonSqueezyImportPayloadSchema` |

Sources: [apps/web/app/ee/api/cron/import/rewardful/route.ts:8-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L8-L36), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:7-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L7-L42), [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:7-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L7-L35), [apps/web/app/ee/api/cron/import/tolt/route.ts:8-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L8-L44), [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:5-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L5-L23)

## Campaign and Discount Setup

### Overview

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.

Sources: [apps/web/lib/rewardful/import-campaigns.ts:27-250](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L27-L250)

### Campaign Import Call-Chain Execution

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()`

Sources: [apps/web/lib/rewardful/import-campaigns.ts:27-250](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L27-L250)

> [!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.

Sources: [apps/web/lib/rewardful/import-campaigns.ts:177-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L177-L179)

### Campaign to Group Mapping and Reward Configuration

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.

```typescript
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: {},
});
```

Sources: [apps/web/lib/rewardful/import-campaigns.ts:87-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L87-L124)

### Reward Structure and Stripe Coupon Conversion

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`.

| Rewardful Property | Dub Reward Field | Transformation Rule |
| :--- | :--- | :--- |
| `max_commissions === 1` \|\| `max_commission_period_months === 0` | `maxDuration` | Set to `0` ("for the first sale"); otherwise takes `max_commission_period_months`. |
| `reward_type === "amount"` | `type` & `amountInCents` | Set type to `RewardStructure.flat` and assign `commission_amount_cents`. |
| `reward_type !== "amount"` | `type` & `amountInPercentage` | Set type to `RewardStructure.percentage` and assign `new Prisma.Decimal(commission_percent)`. |

Sources: [apps/web/lib/rewardful/import-campaigns.ts:152-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L152-L168)

Stripe coupons linked to campaigns are retrieved via Stripe Connect (`program.workspace.stripeConnectId`), validated, converted using `stripeCouponToDubDiscount`, and stored in the `Discount` table.

```typescript
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,
      },
    },
  },
});
```

Sources: [apps/web/lib/rewardful/import-campaigns.ts:208-224](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L208-L224)

## Partner Ingestion and Link Creation

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L116-L254), [apps/web/lib/rewardful/import-partners.ts:164-257](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L164-L257)

### Partner Ingestion and Entity Mapping

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.

```mermaid
sequenceDiagram
    participant Importer as importPartners()
    participant FP as External API
    participant DB as Prisma DB
    participant Sync as queuePartnerSearchSync()

    Importer->>FP: listPartners({ page })
    FP-->>Importer: affiliates[]
    loop For each affiliate
        Importer->>DB: prisma.partner.upsert()
        DB-->>Importer: partner record
        Importer->>DB: prisma.programEnrollment.upsert()
        DB-->>Importer: programEnrollment record
        Importer->>DB: approveLinkedApplication()
        Importer->>DB: bulkCreateLinks()
    end
    Importer->>Sync: queuePartnerSearchSync()
```

Sources: [apps/web/lib/firstpromoter/import-partners.ts:52-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L52-L101), [apps/web/lib/rewardful/import-partners.ts:52-137](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L52-L137)

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L148-L181), [apps/web/lib/rewardful/import-partners.ts:65-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L65-L75), [apps/web/lib/rewardful/import-partners.ts:117-129](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L117-L129)

### Bulk Referral Link Creation

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L183-L224), [apps/web/lib/rewardful/import-partners.ts:198-228](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L198-L228)

| Platform | Key Generation Source | Default Link Assignment |
| :--- | :--- | :--- |
| FirstPromoter | `campaign.ref_token || nanoid()` | Assigned to `partnerGroupDefaultLinkId` for the first campaign index (`idx === 0`). |
| Rewardful | `link.token || nanoid()` | Assigned to `partnerGroupDefaultLinkId` for the first index (`idx === 0`). |

Sources: [apps/web/lib/firstpromoter/import-partners.ts:226-238](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L226-L238), [apps/web/lib/rewardful/import-partners.ts:231-243](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L231-L243)

> [!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.

Sources: [apps/web/lib/firstpromoter/import-partners.ts:240-252](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L240-L252)

### Application Status Approval and Index Synchronization

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L183-L214), [apps/web/lib/rewardful/import-partners.ts:198-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L198-L223)

```typescript
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,
});
```

Sources: [apps/web/lib/firstpromoter/import-partners.ts:183-214](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L183-L214)

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L97-L101), [apps/web/lib/rewardful/import-partners.ts:133-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L133-L136)

## Customer Ingestion and Activity Tracking

### Overview

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.

Sources: [apps/web/lib/rewardful/import-customers.ts:15-105](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L15-L105)

### Call-Chain Execution Walkthrough

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()`.

Sources: [apps/web/lib/rewardful/import-customers.ts:15-297](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L15-L297)

### Customer External ID and Stripe ID Deduplication

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.

Sources: [apps/web/lib/rewardful/import-customers.ts:45-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L45-L75)

```typescript
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 },
                  },
                ]
              : []),
          ],
        },
      });
```

Sources: [apps/web/lib/rewardful/import-customers.ts:56-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L56-L75)

> [!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`.

Sources: [apps/web/lib/rewardful/import-customers.ts:143-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L143-L179)

### Tinybird Event Recording and Link Stats

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.

Sources: [apps/web/lib/rewardful/import-customers.ts:210-296](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L210-L296)

```typescript
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,
});
```

Sources: [apps/web/lib/rewardful/import-customers.ts:210-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L210-L236)

## Commission Processing and Analytics Sync

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L35-L232), [apps/web/lib/tolt/import-commissions.ts:36-246](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L36-L246), [apps/web/lib/firstpromoter/import-commissions.ts:35-252](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L35-L252), [apps/web/lib/lemonsqueezy/import-commissions.ts:50-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L50-L124), [apps/web/lib/partnerstack/import-commissions.ts:38-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L38-L125)

### Call-Chain Execution Walkthrough

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L35-L232), [apps/web/lib/tolt/import-commissions.ts:36-246](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L36-L246), [apps/web/lib/partnerstack/import-commissions.ts:38-121](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L38-L121)

> [!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`.

Sources: [apps/web/lib/rewardful/import-commissions.ts:178-189](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L178-L189)

### Status Mappings Across Providers

Each integrated platform maps its unique string-based status fields to standard Dub `CommissionStatus` enums (`pending`, `paid`, `refunded`, `fraud`, `canceled`).

| Provider | Platform Status Key | Dub CommissionStatus | Sources |
| :--- | :--- | :--- | :--- |
| **Rewardful** | `pending` / `due` | `pending` / `pending` | [apps/web/lib/rewardful/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L28-L33) |
| **Rewardful** | `paid` / `voided` | `paid` / `canceled` | [apps/web/lib/rewardful/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L28-L33) |
| **Tolt** | `pending` / `approved` | `pending` / `pending` | [apps/web/lib/tolt/import-commissions.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L28-L34) |
| **Tolt** | `paid` / `rejected` / `refunded` | `paid` / `canceled` / `refunded` | [apps/web/lib/tolt/import-commissions.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L28-L34) |
| **FirstPromoter** | `pending` / `approved` / `denied` | `pending` / `pending` / `canceled` | [apps/web/lib/firstpromoter/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L28-L33) |
| **PartnerStack** | `hold` / `pending` / `approved` | `fraud` / `pending` / `pending` | [apps/web/lib/partnerstack/import-commissions.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L26-L36) |
| **PartnerStack** | `declined` / `paid` / `scheduled` | `canceled` / `paid` / `pending` | [apps/web/lib/partnerstack/import-commissions.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L26-L36) |
| **Lemon Squeezy** | `paid` / `pending` / `refunded` | `paid` / `pending` / `refunded` | [apps/web/lib/lemonsqueezy/import-commissions.ts:643-660](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L643-L660) |
| **Lemon Squeezy** | `fraudulent` / `void` / `failed` | `fraud` / `canceled` / `canceled` | [apps/web/lib/lemonsqueezy/import-commissions.ts:643-660](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L643-L660) |

Sources: [apps/web/lib/rewardful/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L28-L33), [apps/web/lib/tolt/import-commissions.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L28-L34), [apps/web/lib/firstpromoter/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L28-L33), [apps/web/lib/partnerstack/import-commissions.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L26-L36), [apps/web/lib/lemonsqueezy/import-commissions.ts:643-660](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L643-L660)

### Currency Conversion and USD Resolution

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](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L205-L231), [apps/web/lib/tolt/import-commissions.ts:221-245](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L221-L245), [apps/web/lib/firstpromoter/import-commissions.ts:230-242](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L230-L242), [apps/web/lib/lemonsqueezy/import-commissions.ts:611-641](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L611-L641)

```typescript
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/lemonsqueezy/import-commissions.ts:611-641](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L611-L641)

### Design Trade-Offs in Commission Importers

| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| **Batch pagination with Redis state queueing** | Prevents serverless timeout limits during large historical imports | Requires state payload serialization via QStash cron queues | [apps/web/lib/rewardful/import-commissions.ts:54-107](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L54-L107), [apps/web/lib/tolt/import-commissions.ts:57-117](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L57-L117) |
| **Deduplication via composite keys (`invoiceId_programId`)** | Avoids duplicate commission records on retries or overlapping webhook periods | Relies on provider transaction identifiers or fallback string keys | [apps/web/lib/rewardful/import-commissions.ts:191-203](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L191-L203), [apps/web/lib/partnerstack/import-commissions.ts:159-166](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L159-L166) |
| **Asynchronous Tinybird error logging** | Captures invalid rows (e.g. missing Stripe IDs, self-referrals) without halting batch execution | Errors remain decoupled from primary PostgreSQL transactions | [apps/web/lib/rewardful/import-commissions.ts:182-188](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L182-L188), [apps/web/lib/firstpromoter/import-commissions.ts:181-198](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L181-L198) |

Sources: [apps/web/lib/rewardful/import-commissions.ts:54-203](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L54-L203), [apps/web/lib/firstpromoter/import-commissions.ts:181-198](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L181-L198), [apps/web/lib/partnerstack/import-commissions.ts:159-166](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L159-L166)

## Historical Link Migration and Backfills

### Overview

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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts#L60-L143), [apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts:94-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/rebrandly/route.ts#L94-L165)

### Bitly and Rebrandly Import Endpoints

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](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts#L14-L143), [apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts:14-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/rebrandly/route.ts#L14-L165)

### Cron Execution and Tag Import Workflow

The asynchronous cron route handler for Bitly processes incoming QStash payloads, enforces rate limits, handles tag imports, and delegates link migration.

```typescript
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);
  }
}
```

Sources: [apps/web/app/ee/api/cron/import/bitly/route.ts:14-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/bitly/route.ts#L14-L113)

> [!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.

### Historical Data Backfill and Maintenance Scripts

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](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts#L36-L349), [apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts:10-195](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts#L10-L195), [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts:12-272](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts#L12-L272)

## Related

- [Partner Program Management](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-program-management)
- [Link Creation and Builder UI](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-management/link-creation-and-builder-ui)


## Sitemap

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