Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/lib/api/conversions/track-lead.ts
- apps/web/app/ee/api/track/application/route.ts
- apps/web/lib/api/conversions/track-sale.ts
- apps/web/app/ee/api/track/visit/route.ts
- apps/web/app/ee/api/track/click/route.ts
- apps/web/app/ee/api/appsflyer/webhook/route.ts
- apps/web/lib/middleware/link.ts
- apps/web/lib/api/customers/reattribute-customer.ts
- apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts
- apps/web/lib/api/commissions/create-manual-commissions.ts
- apps/web/app/ee/api/track/lead/client/route.ts
- apps/web/app/ee/api/singular/webhook/route.ts
- apps/web/app/ee/api/track/sale/client/route.ts
- apps/web/app/ee/api/stripe/integration/webhook/checkout-session-completed.ts
- apps/web/scripts/programs/backfill-reuse-commission.ts
- apps/web/app/ee/api/track/lead/route.ts
- apps/web/app/ee/api/track/sale/route.ts
- apps/web/lib/integrations/shopify/attribute-via-discount-code.ts
- apps/web/lib/auth/track-dub-lead.ts
- apps/web/scripts/programs/3-import-customer-leads.ts
- apps/web/lib/integrations/hubspot/track-lead.ts
- apps/web/lib/integrations/singular/track-lead.ts
- apps/web/lib/tinybird/record-click.ts
- apps/web/lib/application-events/mark-application-event-submitted-network.ts
- apps/web/app/ee/api/cron/streams/update-partner-stats/route.ts
- apps/web/lib/integrations/shopify/create-lead.ts
- apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts
- apps/web/lib/api/partners/sync-partner-links-stats.ts
- apps/web/lib/analytics/is-first-conversion.ts
- apps/web/lib/integrations/singular/track-sale.ts
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
| Endpoint | Method | Request Schema | Core Action | Sources |
|---|---|---|---|---|
/api/track/visit | POST | domain, url, referrer (optional) | Tracks client-side page views and logs visits | Sources: apps/web/app/(ee)/api/track/visit/route.ts:17-105 |
/api/track/click | POST | domain, key, url, referrer (optional) | Tracks link clicks, returns partner info and discounts | Sources: 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 / Constant | Value / Limit | Purpose |
|---|---|---|
CUSTOMER_REATTRIBUTION_EVENTS_LIMIT | 500 | Maximum event retrieval ceiling for customer reattribution pipelines |
STATS_LOCK_TTL_SECONDS | 86400 (24 hours) | Redis distributed lock expiration time for stat synchronization runs |
| Max Sale Events Backfill | 5 | Upper limit for existing sale events processed during reuse-commission backfills |
| ClickHouse Partitions | 12 | Maximum 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
Related
On This Page
OverviewEdge ingestion for visits and clicksVisit and Click Tracking EndpointsTelemetry Recording and DeduplicationLead capture and attribution pipelineServer and Client Tracking WorkflowsCustomer Resolution and DeduplicationThird-Party Integrations and Webhook DeliverySale tracking and first conversion detectionSale Event Ingestion and Currency NormalizationFirst Conversion Detection and DeduplicationCommission Dispatch and Metrics UpdatesPromotion codes and merchant integrationsStripe Promotion Code Attribution WorkflowShopify Discount Code AttributionCustomer reattribution and historical reconciliationCustomer Recreation and Reattribution FlowHistorical Backfill and Partition SplittingPartner metrics synchronization and batch processingRedis Streams and Asynchronous Activity UpdatesBackfill Processing and Application TrackingRelated