Skip to main content
doc0blade47/dubdocumentation home
Sign in

Conversion Tracking

Last updated Oct 12, 2026100% grounded
Relevant source files

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

Overview

Dub implements an end-to-end conversion tracking and attribution engine that ingests edge interactions, records customer lead and sale funnels, and dispatches multi-channel commissions and downstream webhooks. It resolves request identities, attributes events across organic links, discount codes, and merchant platforms like Stripe and Shopify, and synchronizes real-time partner performance metrics via Redis streams and analytical storage. Sources: apps/web/lib/api/conversions/track-lead.ts:112-162

Edge ingestion for visits and clicks

Visit and Click Tracking Endpoints

Dub exposes public edge tracking routes to ingest incoming client visits, record link interactions, and verify workspace hostnames against analytics configuration. These endpoints process incoming telemetry from client-side scripts and redirects, validating request parameters and coordinating with caching layers to deduplicate events and enrich click records. Sources: apps/web/app/ee/api/track/visit/route.ts:17-50

The visit tracking route at /api/track/visit accepts POST requests containing a target domain, URL, and optional referrer. It parses the request body, checks if a click identifier is already cached in Redis via recordClickCache, and retrieves link details from edge storage if necessary. If no cached click identifier exists, a new 16-character identifier is generated using nanoid. Sources: apps/web/app/ee/api/track/visit/route.ts:19-54

Similarly, the click tracking route at /api/track/click validates incoming payloads against trackClickSchema, enforcing domain normalization and presence checks for keys and URLs. It resolves request identity hashes to prevent duplicate logging within hourly windows and evaluates allowed workspace hostnames before recording click telemetry. Sources: apps/web/app/ee/api/track/click/route.ts:25-33

EndpointMethodRequest SchemaCore ActionSources
/api/track/visitPOSTdomain, url, referrer (optional)Tracks client-side page views and logs visitsSources: apps/web/app/(ee)/api/track/visit/route.ts:17-105
/api/track/clickPOSTdomain, key, url, referrer (optional)Tracks link clicks, returns partner info and discountsSources: apps/web/app/(ee)/api/track/click/route.ts:25-55

Note

Requests lacking a domain or URL in the visit route throw a bad_request error, while click tracking requests failing hostname verification return a forbidden error with configuration guidance. Sources: apps/web/app/(ee)/api/track/visit/route.ts:22-27

Telemetry Recording and Deduplication

The underlying recordClick engine manages geolocation extraction, bot detection, and data ingestion into Tinybird Analytics. It evaluates request headers to determine user agents, checks for bot traffic unless triggered via deeplinks, and respects dub-no-track headers or query parameters to bypass collection. Sources: apps/web/lib/tinybird/record-click.ts:53-78

Warning

When executing on Vercel, region codes are extracted from x-vercel-ip-country-region rather than standard geolocation helpers to ensure accurate sub-national geographic attribution. Sources: apps/web/lib/tinybird/record-click.ts:110-116

European Union visitors have their IP addresses scrubbed before being recorded in analytics tables to comply with data privacy requirements. Sources: apps/web/lib/tinybird/record-click.ts:122-137

Lead capture and attribution pipeline

Server and Client Tracking Workflows

Lead conversion events are processed through unified server actions and API routes designed for both direct client integrations and authenticated server requests. The core lead processing function trackLead handles event intake across multiple execution modes: wait, async, and deferred. Client-side tracking operates via /api/track/lead/client, which requires a publishable key and validates incoming request hostnames against the workspace's configured allowedHostnames. Server-side tracking takes place via /api/track/lead, which authenticates requests through workspace membership cookies or API tokens while supporting legacy payload properties for backwards compatibility. Sources: apps/web/app/ee/api/track/lead/route.ts:10-36

const {
  clickId,
  eventName,
  eventQuantity,
  customerExternalId: newExternalId,
  externalId: oldExternalId, // deprecated (but we'll support it for backwards compatibility)
  customerId: oldCustomerId, // deprecated (but we'll support it for backwards compatibility)
  customerName,
  customerEmail,
  customerAvatar,
  mode,
  metadata,
} = trackLeadRequestSchema
  .extend({
    // we if clickId is undefined/nullish, we'll coerce into an empty string
    clickId: z.string().trim().nullish(),
    // add backwards compatibility
    customerExternalId: z.string().trim().nullish(),
    externalId: z.string().trim().nullish(),
    customerId: z.string().trim().nullish(),
  })
  .parse(body);

Sources: apps/web/app/ee/api/track/lead/route.ts:14-35

if (!isDuplicateEvent) {
  // First, we need to find the click event
  const clickData = await getClickEvent({ clickId });
 
  // if there is no click data, throw an error
  if (!clickData) {
    throw new DubApiError({
      code: "not_found",
      message: `Click event not found for clickId: ${clickId}`,
    });
  }
 

Sources: apps/web/lib/api/conversions/track-lead.ts:111-122

Warning

If a click identifier fails to resolve to a valid, enabled link owned by the target workspace, trackLead throws a not_found DubApiError and halts lead registration. Sources: apps/web/lib/api/conversions/track-lead.ts:115-149

Customer Resolution and Deduplication

Incoming leads are verified against Redis cache layers to prevent duplicate processing within a 7-week window by testing keys against clickId, eventName, and customerExternalId. When an event is new, the system queries click data and validates link ownership before provisioning or fetching customer records via getOrCreateCustomer. For authenticated Next.js users, trackDubLead extracts the dub_id cookie, submits a sign-up lead event with user metadata, and subsequently clears the tracking cookies. Sources: apps/web/lib/api/conversions/track-lead.ts:91-106

export const trackDubLead = async (user: User) => {
  const cookieStore = await cookies();
  const clickId = cookieStore.get("dub_id")?.value;
 
  if (!clickId) {
    console.log("No dub_id cookie found, skipping lead tracking...");
    return;
  }
 
  // send the lead event to Dub
  await dub.track.lead({
    clickId,
    eventName: "Sign Up",
    customerExternalId: user.id,
    customerName: user.name,
    customerEmail: user.email,
    customerAvatar: user.image,
  });
 

Sources: apps/web/lib/auth/track-dub-lead.ts:5-23

if (!customer) {
  const { customer: existingOrNewCustomer } = await getOrCreateCustomer({
    where: {
      projectId_externalId: {
        projectId: workspace.id,
        externalId: customerExternalId,
      },
    },
    create: {
      id: finalCustomerId,
      name: finalCustomerName,
      email: customerEmail,
      avatar: finalCustomerAvatar,
      externalId: customerExternalId,
      projectId: workspace.id,
      projectConnectId: workspace.stripeConnectId,
      clickId: clickData.click_id,
      linkId: link.id,
      programId: link.programId,
      partnerId: link.partnerId,
      country: clickData.country,
      clickedAt: new Date(clickData.timestamp + "Z"),
    },
  });
 
  customer = existingOrNewCustomer;
}

Sources: apps/web/lib/api/conversions/track-lead.ts:174-200

Tip

In wait execution mode, lead event payloads are cached in Redis for 5 minutes under keys scoped to both customer ID and specific event names to bridge telemetry read-after-write delays from Tinybird Analytics. Sources: apps/web/lib/api/conversions/track-lead.ts:203-224

Third-Party Integrations and Webhook Delivery

Lead attribution extends to external merchant platforms like HubSpot, where trackHubSpotLeadEvent maps contact creations, property changes, and deal stage transitions into conversion events. When HubSpot objects meet criteria such as lifecycle stage progression or deal creation, deferred or asynchronous leads are recorded, and corresponding contact properties (dub_link and dub_partner_email) are updated back on HubSpot. Sources: apps/web/lib/integrations/hubspot/track-lead.ts:25-32

if (objectTypeId === "0-1" && subscriptionType === "object.creation") {
  const contactInfo = await hubSpotApi.getContact(objectId);
 
  if (!contactInfo) {
    return `No contact info found for contact ${objectId}.`;
  }
 
  const { properties } = contactInfo;
 
  if (!properties.dub_id) {
    return `No dub_id found for contact ${objectId}.`;
  }
 
  const customerName =
    [properties.firstname, properties.lastname].filter(Boolean).join(" ") ||
    null;
 
  const trackLeadResult = await trackLead({
    clickId: properties.dub_id,
    eventName: "Sign up",
    customerEmail: properties.email,
    customerExternalId: properties.email,
    customerName,
    mode: "deferred",
    workspace,
    source: "hubspot",
    commissionSource: CommissionSource.hubspot,
  });
 
  if (trackLeadResult) {
    await updateHubSpotContact({
      contact: contactInfo,
      trackLeadResult,
      hubSpotApi,
    });
  }
 
  return `Deferred lead tracked for contact ${objectId}.`;
}

Sources: apps/web/lib/integrations/hubspot/track-lead.ts:34-72

export const updateHubSpotContact = async ({
  hubSpotApi,
  contact,
  trackLeadResult,
}: {
  hubSpotApi: HubSpotApi;
  contact: HubSpotContact;
  trackLeadResult: TrackLeadResponse;
}) => {
  if (contact.properties.dub_link && contact.properties.dub_partner_email) {
    console.log(
      `[HubSpot] Contact ${contact.id} already has dub_link and dub_partner_email. Skipping update.`,
    );
    return;
  }
 
  const properties: Record<string, string> = {};
 
  if (trackLeadResult.link?.partnerId) {
    const partner = await prisma.partner.findUniqueOrThrow({
      where: {
        id: trackLeadResult.link.partnerId,
      },
      select: {
        email: true,
      },
    });
 
    if (partner.email) {
      properties["dub_partner_email"] = partner.email;
    }
  }
 
  if (trackLeadResult.link?.shortLink) {
    properties["dub_link"] = trackLeadResult.link.shortLink;
  }
 

Sources: apps/web/lib/integrations/hubspot/track-lead.ts:251-287

Sale tracking and first conversion detection

Sale Event Ingestion and Currency Normalization

Sale conversions are ingested via dedicated server-side endpoints and client-side publishable key routes. The primary server route /api/track/sale authenticates via workspace membership, parses requests using trackSaleRequestSchema, and handles legacy parameters with backwards compatibility for externalId and customerId. The client endpoint /api/track/sale/client verifies request hostnames against allowedHostnames stored on the workspace before passing payloads to trackSale. Sources: apps/web/app/ee/api/track/sale/route.ts:10-36

export const POST = withWorkspace(
  async ({ req, workspace }) => {
    const body = await parseRequestBody(req);
 
    let {
      customerExternalId: newExternalId,
      externalId: oldExternalId, // deprecated
      customerId: oldCustomerId, // deprecated
      customerName,
      customerEmail,
      customerAvatar,
      clickId,
      amount,
      currency,
      eventName,
      paymentProcessor,
      invoiceId,
      leadEventName,
      metadata,
    } = trackSaleRequestSchema
      .extend({
        // add backwards compatibility
        customerExternalId: z.string().nullish(),
        externalId: z.string().nullish(),
        customerId: z.string().nullish(),
      })
      .parse(body);
 
    const customerExternalId = newExternalId || oldExternalId || oldCustomerId;
 
    if (!customerExternalId) {
      throw new DubApiError({
        code: "bad_request",
        message: "customerExternalId is required",
      });
    }

Sources: apps/web/app/ee/api/track/sale/route.ts:10-45

export const POST = withPublishableKey(
  async ({ req, workspace }) => {
    const body = await parseRequestBody(req);
 
    const allowRequest = verifyAnalyticsAllowedHostnames({
      allowedHostnames: (workspace?.allowedHostnames ?? []) as string[],
      req,
    });
 
    if (!allowRequest) {
      throw new DubApiError({
        code: "forbidden",
        message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`,
      });
    }
 
    const {
      customerExternalId,
      customerName,
      customerEmail,
      customerAvatar,
      clickId,
      amount,
      currency,
      eventName,
      paymentProcessor,
      invoiceId,
      leadEventName,
      metadata,
    } = trackSaleRequestSchema.parse(body);
 
    if (!customerExternalId) {
      throw new DubApiError({
        code: "bad_request",
        message: "customerExternalId is required",
      });
    }

Sources: apps/web/app/ee/api/track/sale/client/route.ts:14-50

Within _trackSale, incoming transactions are evaluated and normalized before data recording. If the transaction amount is zero or negative, processing terminates early. Transactions denominated in non-USD currencies are converted to USD using current foreign exchange rates via convertCurrency. Sources: apps/web/lib/api/conversions/track-sale.ts:469-490

// Skip if amount is 0 or less
if (amount <= 0) {
  return {
    eventName,
    customer: null,
    sale: null,
  };
}
 
// if currency is not USD, convert it to USD based on the current FX rate
// TODO: allow custom "defaultCurrency" on workspace table in the future
if (currency !== "usd") {
  const { currency: convertedCurrency, amount: convertedAmount } =
    await convertCurrency({
      currency,
      amount,
    });
 
  currency = convertedCurrency;
  amount = convertedAmount;
}
 

Sources: apps/web/lib/api/conversions/track-sale.ts:493-514

Warning

If leadEventData cannot be resolved for a customer during sale ingestion, _trackSale throws a not_found error indicating that lead event data is missing. Sources: apps/web/lib/api/conversions/track-sale.ts:486-491

First Conversion Detection and Deduplication

First conversion detection determines whether a transaction represents an initial acquisition or a recurring customer purchase. The helper isFirstConversion evaluates customer metrics against the incoming linkId. A transaction is categorized as a first conversion if the customer has recorded zero previous sales, or if the customer's original referral link identifier differs from the current transaction link. Sources: apps/web/lib/analytics/is-first-conversion.ts:3-10

export const isFirstConversion = ({
  customer,
  linkId,
}: {
  customer: Pick<Customer, "sales" | "linkId">;
  linkId?: string;
}) => {
  // if this is the first sale for the customer, it's a first conversion
  if (customer.sales === 0) {
    return true;
  }
 
  // if customer has sales, but the original referral link is not the same as the current link
  // it is most likely a first conversion
  if (customer.linkId !== linkId) {
    // TODO: fix edge case where customer was brought in by a different link, but then had recurring sales on the current link
    return true;
  }
 
  return false;
};

Sources: apps/web/lib/analytics/is-first-conversion.ts:3-23

To prevent race conditions where concurrent requests attempt to record initial purchases simultaneously, first conversion claims are deduplicated using a Redis key with a 30-second expiry and an nx: true constraint. Sources: apps/web/lib/api/conversions/track-sale.ts:535-546

let firstConversionFlag = isFirstConversion({
  customer,
  linkId: saleData.link_id,
});
 
// Deduplicate concurrent first sales for the same customer + link so only one
// request is counted as the first conversion.
if (firstConversionFlag) {
  const claim = await redis.set(
    `firstConversion:${customer.id}:${saleData.link_id}`,
    1,
    {
      ex: 30,
      nx: true,
    },
  );
 
  firstConversionFlag = claim !== null;
}
 

Sources: apps/web/lib/api/conversions/track-sale.ts:528-547

Tip

The Redis deduplication lock uses the key pattern firstConversion:{customerId}:{linkId} to ensure only a single concurrent request is counted as the first conversion for a given customer and link pairing. Sources: apps/web/lib/api/conversions/track-sale.ts:536-543

Commission Dispatch and Metrics Updates

Once sale data and conversion flags are established, asynchronous background processing updates link statistics and dispatches partner commissions. Prisma updates increment link metrics such as sales and saleAmount, while conditionally incrementing conversion counts and setting lastConversionAt when firstConversionFlag evaluates to true. Sources: apps/web/lib/api/conversions/track-sale.ts:548-570

waitUntil(
  (async () => {
    // Update link conversions, sales, and saleAmount
    const link = await prisma.link.update({
      where: {
        id: saleData.link_id,
      },
      data: {
        ...(firstConversionFlag && {
          conversions: {
            increment: 1,
          },
          lastConversionAt: new Date(),
        }),
        sales: {
          increment: 1,
        },
        saleAmount: {
          increment: amount,
        },
      },
      include: includeTags,
    });

Sources: apps/web/lib/api/conversions/track-sale.ts:548-570

If the associated link contains valid partner and program identifiers, queuePartnerCommissionCreation is invoked to calculate earnings through the Commission Engine. The payload bundles customer country, signup date, source provenance, product metadata, and the first conversion status. Sources: apps/web/lib/api/conversions/track-sale.ts:576-608

if (link.programId && link.partnerId) {
  result = await queuePartnerCommissionCreation({
    event: "sale",
    programId: link.programId,
    partnerId: link.partnerId,
    linkId: link.id,
    customerId: customer.id,
    eventId: saleData.event_id,
    amount: saleData.amount,
    quantity: 1,
    invoiceId,
    currency,
    source: commissionSource,
    userId,
    ...(metadata != null && { metadata }),
    context: {
      customer: {
        country: customer.country,
        signupDate: customer.createdAt,
        source,
      },
      sale: {
        productId: metadata?.productId,
        amount: saleData.amount,
        ...(metadata != null && { metadata }),
      },
    },
    clickEvent: {
      url: saleData.url,
      referer: saleData.referer,
    },
    isFirstConversion: firstConversionFlag,
  });

Sources: apps/web/lib/api/conversions/track-sale.ts:576-608

Promotion codes and merchant integrations

Stripe Promotion Code Attribution Workflow

When a Stripe checkout session completes without a direct dubCustomerExternalId or matching customer record, Dub evaluates whether a Stripe promotion code identifier (promotionCodeId) was attached to the checkout session. The function attributeViaPromotionCodeId queries the connected Stripe account via getPromotionCode to resolve the promo code string. It then searches the database for a matching DiscountCode record within the workspace's default program. Sources: apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:30-85

export async function attributeViaPromotionCodeId({
  promotionCodeId,
  workspace,
  mode,
  customerDetails,
}: {
  promotionCodeId: string; // must be Stripe's promotion code ID `promo_xxx`, not the actual promo code
  workspace: Pick<
    Project,
    "id" | "defaultProgramId" | "stripeConnectId" | "webhookEnabled"
  >;
  mode: StripeMode;
  customerDetails: PromoCodeCustomerDetails;
}) {
  console.log(
    `Attributing via promotion code ID ${promotionCodeId} for customer ${customerDetails.stripeCustomerId} in workspace ${workspace.id}`,
  );
 
  const stripeAccountId = workspace.stripeConnectId!;
  const stripeCustomerId = customerDetails.stripeCustomerId;
 
  if (!workspace.defaultProgramId) {
    console.log(
      `Workspace with stripeConnectId ${stripeAccountId} has no default program, skipping...`,
    );
    return null;
  }
 
  // Find the promotion code for the promotion code id
  const promotionCode = await getPromotionCode({
    promotionCodeId,
    stripeAccountId,
    mode,
  });
 
  if (!promotionCode) {
    console.log(
      `Promotion code ${promotionCodeId} not found in connected account ${stripeAccountId}, skipping...`,
    );
    return null;
  }
 
  console.log(`Promotion code found: ${promotionCode.code}`);
 
  const discountCode = await prisma.discountCode.findUnique({
    where: {
      programId_code: {
        programId: workspace.defaultProgramId,
        code: promotionCode.code,
      },
    },
    include: {
      link: true,
    },
  });
 
  if (!discountCode) {
    console.log(
      `Couldn't find discount code "${promotionCode.code}" in program "${workspace.defaultProgramId}", skipping...`,
    );
    return null;

Sources: apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:30-90

Once the discount code and its associated link are identified and verified as active (not disabled), Dub simulates attribution by executing a fake click event using customer billing address geographic properties. It resolves or creates the customer record and records a lead event in Tinybird, while caching the lead payload in Redis with a five-minute expiration window to support immediate subsequent sale lookups. Sources: apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:93-195

const link = discountCode.link;
const linkId = link.id;
const customerAddress = customerDetails.address;
const customerCountry = customerAddress?.country?.toUpperCase();
 
// Record a fake click for this event
const clickEvent = await recordFakeClick({
  link,
  customer: {
    continent: customerCountry
      ? COUNTRIES_TO_CONTINENTS[customerCountry] ?? "Unknown"
      : "Unknown",
    country: customerCountry ?? "Unknown",
    region: customerAddress?.state ?? "Unknown",
  },
});
 
const { customer, created } = await getOrCreateCustomer({
  findMode: "first",
  where: {
    OR: [
      {
        stripeCustomerId,
      },
      {
        projectId: workspace.id,
        externalId: clickEvent.click_id,
      },
    ],
  },
  create: {
    id: createId({ prefix: "cus_" }),
    name:
      customerDetails.name || customerDetails.email || generateRandomName(),
    email: customerDetails.email,
    externalId: clickEvent.click_id,
    stripeCustomerId,
    linkId: clickEvent.link_id,
    clickId: clickEvent.click_id,
    clickedAt: new Date(clickEvent.timestamp + "Z"),
    country: customerAddress?.country,
    projectId: workspace.id,
    projectConnectId: workspace.stripeConnectId,
    programId: link.programId,
    partnerId: link.partnerId,
  },
});
 
// Concurrent webhook already created this customer — continue sale without re-recording lead
if (!created) {
  let existingLead = await redis.get<LeadEventTB>(`leadCache:${customer.id}`);
 
  if (!existingLead) {
    existingLead = await getLeadEvent({
      customerId: customer.id,
    });
  }
 
  if (!existingLead) {
    console.log(
      `Customer with stripeCustomerId ${stripeCustomerId} already exists but no lead event found, skipping promo code attribution...`,
    );
    return null;
  }
 
  return {
    linkId,
    customer,
    clickEvent,
    leadEvent: {
      ...existingLead,
      workspace_id: workspace.id,
    },
  };
}
 
// Prepare the payload for the lead event
const { timestamp, ...rest } = clickEvent;
 
const leadEvent = {

Sources: apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:100-195

Note

If a concurrent webhook execution has already created the customer record, attributeViaPromotionCodeId bypasses duplicate lead recording and retrieves the existing lead event from Redis or Tinybird. Sources: apps/web/app/(ee)/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:148-174

Shopify Discount Code Attribution

Shopify orders containing discount codes are handled by attributeViaDiscountCode, which extracts customer billing information and maps country codes to continents. A fake click event is generated via recordFakeClick, establishing geographic provenance for the order before a customer record is created with a cus_ prefix. Sources: apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:28-65

export async function attributeViaDiscountCode({
  order,
  workspace,
  link,
}: {
  order: ShopifyOrder;
  workspace: Pick<WorkspaceProps, "id" | "defaultProgramId" | "webhookEnabled">;
  link: Link;
}) {
  const { customer: orderCustomer, billing_address: billingAddress } = order;
 
  const billingAddressCountry = billingAddress?.country_code?.toUpperCase();
 
  // Record a fake click for this event
  const clickEvent = await recordFakeClick({
    link,
    customer: {
      continent: billingAddressCountry
        ? COUNTRIES_TO_CONTINENTS[billingAddressCountry] ?? "Unknown"
        : "Unknown",
      country: billingAddressCountry ?? "Unknown",
      region: billingAddress?.province ?? "Unknown",
    },
  });
 
  const customerId = createId({ prefix: "cus_" });
  const clickId = clickEvent.click_id;
 
  // Create the customer before recording the fake click so a P2002 on
  // projectId_externalId never leaves an orphaned Tinybird click behind.
  const customer = await prisma.customer.create({
    data: {
      id: customerId,
      name: orderCustomer
        ? `${orderCustomer.first_name} ${orderCustomer.last_name}`.trim()
        : generateRandomName(),
      email: orderCustomer?.email,
      externalId: orderCustomer?.id?.toString() || customerId,
      linkId: link.id,
      clickId,
      clickedAt: new Date(),
      country: billingAddress?.country_code,
      projectId: workspace.id,
      programId: link.programId,
      partnerId: link.partnerId,
    },
  });

Sources: apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:19-65

After the customer record is established, a lead event named "Checkout with discount code" is recorded, link lead metrics are incremented, and partner commission queues, workflow executions, and Google Ads conversion uploads are triggered asynchronously. Sources: apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:67-179

// Prepare the payload for the lead event
const { timestamp, ...rest } = clickEvent;
 
const leadEvent = {
  ...rest,
  workspace_id: clickEvent.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id
  event_id: nanoid(16),
  event_name: "Checkout with discount code",
  customer_id: customer.id,
  metadata: "",
};
 
await recordLead(leadEvent);
 
const linkUpdated = await prisma.link.update({
  where: {
    id: link.id,
  },
  data: {
    leads: {
      increment: 1,
    },
    lastLeadAt: new Date(),
  },
  include: includeTags,
});
 
let result:
  | Awaited<ReturnType<typeof queuePartnerCommissionCreation>>
  | undefined = undefined;
 
if (link.programId && link.partnerId) {
  result = await queuePartnerCommissionCreation({
    event: "lead",
    programId: link.programId,
    partnerId: link.partnerId,
    linkId: link.id,
    eventId: leadEvent.event_id,
    customerId: customer.id,
    quantity: 1,
    source: CommissionSource.shopify,
    context: {
      customer: {
        country: customer.country,
      },
    },
  });
 
  await Promise.allSettled([
    executeWorkflows({
      event: "leadRecorded",
      identity: {
        workspaceId: workspace.id,
        programId: link.programId,
        partnerId: link.partnerId,
      },
      metrics: {
        current: {
          leads: 1,
        },
      },
    }),
 
    syncPartnerLinksStats({
      partnerId: link.partnerId,
      programId: link.programId,
      eventType: "lead",
    }),
  ]);
}
 
await Promise.allSettled([
  sendWorkspaceWebhook({
    trigger: "lead.created",
    workspace,
    data: transformLeadEventData({
      ...clickEvent,
      eventName: "Checkout with discount code",
      link: linkUpdated,
      customer,

Sources: apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:67-185

Caution

When creating Shopify customers during discount code attribution, the customer record is inserted before recording the fake click to ensure a unique constraint violation (P2002 on projectId_externalId) never leaves an orphaned Tinybird click behind. Sources: apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:47-49

Customer reattribution and historical reconciliation

Customer Recreation and Reattribution Flow

When a customer needs to be reattributed across links, Dub executes recreateCustomerForReattribution inside a Prisma transaction. The original customer record is updated by appending (old) to its name and assigning a randomized external identifier prefix (reattributed_) while nullifying all partnership, link, click, and Stripe billing properties. Simultaneously, a new customer record is created with the target link, program, partner, and click identifiers. Sources: apps/web/lib/api/customers/reattribute-customer.ts:126-170

export async function recreateCustomerForReattribution({
  customer,
  link,
  newCustomerId,
  newClickId,
}: {
  customer: Customer;
  link: Pick<Link, "id" | "programId" | "partnerId">;
  newCustomerId: string;
  newClickId: string;
}) {
  return await prisma.$transaction(async (tx) => {
    await tx.customer.update({
      where: {
        id: customer.id,
      },
      data: {
        name: customer.name ? `${customer.name} (old)` : undefined,
        externalId: `reattributed_${nanoid(32)}`,
        stripeCustomerId: null,
        linkId: null,
        clickId: null,
        clickedAt: null,
        programId: null,
        partnerId: null,
        sales: 0,
        saleAmount: 0,
        firstSaleAt: null,
        subscriptionCanceledAt: null,
      },
    });
 
    return await tx.customer.create({
      data: {
        id: newCustomerId,
        name: customer.name,
        email: customer.email,
        avatar: customer.avatar,
        externalId: customer.externalId,
        stripeCustomerId: customer.stripeCustomerId,
        clickedAt: customer.clickedAt,
        country: customer.country,
        sales: customer.sales,
        saleAmount: customer.saleAmount,
        firstSaleAt: customer.firstSaleAt,
        subscriptionCanceledAt: customer.subscriptionCanceledAt,
        projectId: customer.projectId,
        projectConnectId: customer.projectConnectId,
        createdAt: customer.createdAt,
        linkId: link.id,
        programId: link.programId,
        partnerId: link.partnerId,
        clickId: newClickId,
      },
    });
  });
}

Sources: apps/web/lib/api/customers/reattribute-customer.ts:115-171

Note

Reattribution stubs are identified strictly by checking if the external identifier starts with reattributed_, dummy_, or retired_ while holding null values for partnerId, linkId, and programId. Sources: apps/web/lib/api/customers/reattribute-customer.ts:77-96

Event reconciliation reads historical customer event telemetry from Tinybird via getCustomerReattributeEvents, enforcing an upper limit defined by CUSTOMER_REATTRIBUTION_EVENTS_LIMIT set to 500 events. If event counts breach this threshold, reattribution halts to prevent memory exhaustion during event processing plans. Sources: apps/web/lib/api/customers/reattribute-customer.ts:98-103

export const CUSTOMER_REATTRIBUTION_EVENTS_LIMIT = 500;
const UNPAID_COMMISSION_STATUSES = ["pending", "hold", "processed"] as const;
const STATS_LOCK_TTL_SECONDS = 60 * 60 * 24;

Sources: apps/web/lib/api/customers/reattribute-customer.ts:22-24

Historical Backfill and Partition Splitting

Historical backfills for leads and clicks duplicate events under new customer identifiers while transferring existing event references. In migration scripts such as backfill-reuse-commission.ts, existing sale events are capped at a maximum limit of 5 entries per execution to prevent batch timeout failures. Sources: apps/web/scripts/programs/backfill-reuse-commission.ts:145-150

When importing customer leads in bulk, click payloads are grouped by calendar year to satisfy ClickHouse partition boundaries, since partition ingestion supports a maximum of 12 partitions per backfill batch. Sources: apps/web/scripts/programs/3-import-customer-leads.ts:157-169

Parameter / ConstantValue / LimitPurpose
CUSTOMER_REATTRIBUTION_EVENTS_LIMIT500Maximum event retrieval ceiling for customer reattribution pipelines
STATS_LOCK_TTL_SECONDS86400 (24 hours)Redis distributed lock expiration time for stat synchronization runs
Max Sale Events Backfill5Upper limit for existing sale events processed during reuse-commission backfills
ClickHouse Partitions12Maximum monthly partitions allowed for a single event backfill batch
export const CUSTOMER_REATTRIBUTION_EVENTS_LIMIT = 500;
const UNPAID_COMMISSION_STATUSES = ["pending", "hold", "processed"] as const;
const STATS_LOCK_TTL_SECONDS = 60 * 60 * 24;

Sources: apps/web/lib/api/customers/reattribute-customer.ts:22-24

Caution

Distributed stat updates during reattribution utilize Redis keys guarded by runOnce, which immediately deletes the lock key if the execution function throws an uncaught exception. Sources: apps/web/lib/api/customers/reattribute-customer.ts:58-75

Partner metrics synchronization and batch processing

Redis Streams and Asynchronous Activity Updates

Partner activity updates are published to an asynchronous Redis stream via publishPartnerActivityEvent in Commission Engine, capturing events with programId, partnerId, eventType, and timestamp. The cron route GET /api/cron/streams/update-partner-stats processes these streams in batches governed by BATCH_SIZE set to 6000, running with deleteAfterRead: true. Sources: apps/web/app/ee/api/cron/streams/update-partner-stats/route.ts:15

When a partner activity stream batch is processed, unique program-partner pairs are extracted to query grouped link metrics and commission totals in parallel via Prisma. Derived metrics such as net revenue, earnings per click, and consistency scores are computed before executing updates in sub-batches of size 50 against PlanetScale using raw SQL execution via conn.execute. If publishing to the Redis stream fails, syncPartnerLinksStats falls back directly to an atomic Prisma transaction executing tx.link.aggregate and updating tx.programEnrollment. Sources: apps/web/app/ee/api/cron/streams/update-partner-stats/route.ts:77-107

Note

Consistency scores default to 50 and scale dynamically based on daysSinceLastConversion: 100 for 7 days or less, 85 for up to 30 days, 70 for up to 90 days, 55 for up to 180 days, and 40 for longer periods. Sources: apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts:210-229

Backfill Processing and Application Tracking

The backfill cron route apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts records batch click telemetry, leads, zero-amount sale events, and paid commissions simultaneously while caching external ID and event name pairs in Redis using redis.sadd. Concurrently, short link click, lead, and sale counters are incremented using link.updateMany grouped by key occurrences. Sources: apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts:295-329

await Promise.all([
  // Record clicks
  fetch(
    `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
        "Content-Type": "application/x-ndjson",
      },
      body: dataArray.map((d) => JSON.stringify(d.clickData)).join("\n"),
    },
  ),
 
  // Record leads
  recordLeadWithTimestamp(dataArray.map((d) => d.leadEventData)),
 
  // Record commissions
  prisma.commission.createMany({
    data: dataArray.map((d) => d.commissionData),
    skipDuplicates: true,
  }),
 
  // Record sales
  recordSaleWithTimestamp(dataArray.map((d) => d.saleEventData)),
 
  // Cache the externalId:eventName pairs
  redis.sadd(
    CACHE_KEY,
    ...(dataArray.map(
      ({ payload: { externalId, eventName } }) =>
        `${externalId}:${eventName}`,
    ) as [string]),
  ),
]);
…
await Promise.all(
  Object.entries(groupedLinks).map(([count, linkKeys]) =>
    prisma.link.updateMany({
      where: {
        shortLink: {
          in: linkKeys.map((key) =>
            linkConstructorSimple({
              domain: DOMAIN,
              key,
            }),
          ),
        },
      },
      data: {
        clicks: {
          increment: parseInt(count),
        },
        leads: {
          increment: parseInt(count),
        },
        sales: {
          increment: parseInt(count),
        },
      },
    }),
  ),

Sources: apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts:295-329, apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts:351-376

Application events are managed via markApplicationEventSubmittedNetwork, which validates network program cookies, updates application records within a transaction, creates customer profiles, and triggers syncPartnerLinksStats with an event type of "lead". Sources: apps/web/lib/application-events/mark-application-event-submitted-network.ts:17-72

const cookieStore = await cookies();
const cookieName = getApplicationEventCookieName(NETWORK_PROGRAM_ID);
const applicationEventId = cookieStore.get(cookieName)?.value;
 
if (!applicationEventId) {
  console.log(
    `No application event ID found for network program + new partner ${partner.id}, skipping...`,
  );
  return;
}
 
const applicationEvent = await prisma.programApplicationEvent.findUnique({
  where: {
    id: applicationEventId,
    programId: NETWORK_PROGRAM_ID,
  },
});
 
if (!applicationEvent) {
  console.log(
    `No application event found for new partner ${partner.id}, skipping...`,
  );
  return;
}
 
if (applicationEvent.submittedAt) {
  console.log(
    `Application event already submitted for new partner ${partner.id}, skipping...`,
  );
  return;
}
 
await prisma.$transaction(async (tx) => {
  await tx.programApplicationEvent.update({
    where: {
      id: applicationEventId,
    },
    data: {
      // assuming application starts after 15-45 seconds of visiting the page
      startedAt: addSeconds(applicationEvent.visitedAt, randomInt(15, 45)),
      submittedAt: new Date(),
      partnerId: partner.id,
    },
  });
 
  if (applicationEvent.referredByPartnerId) {
    await tx.partner.update({
      where: {
        id: partner.id,
      },
      data: {
        referredByPartnerId: applicationEvent.referredByPartnerId,
      },
    });
  }
});
…
await prisma.link.update({
  where: {
    id: networkPartnerLink.id,
  },
  data: {
    clicks: {
      increment: 1,
    },
    lastClicked: applicationEvent.visitedAt,
    leads: {
      increment: 1,
    },
    lastLeadAt: new Date(),
  },
});
console.log(`Updated stats for link ${networkPartnerLink.id}`);
 
await syncPartnerLinksStats({
  partnerId: networkPartnerLink.partnerId!,
  programId: networkPartnerLink.programId!,
  eventType: "lead",
});

Sources: apps/web/lib/application-events/mark-application-event-submitted-network.ts:17-72, apps/web/lib/application-events/mark-application-event-submitted-network.ts:136-157

Was this page helpful?