Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/lib/integrations/google-ads/upload-conversion.ts
- apps/web/app/ee/api/singular/webhook/route.ts
- apps/web/app/ee/api/appsflyer/webhook/route.ts
- apps/web/app/ee/api/track/application/route.ts
- apps/web/app/ee/api/google-ads/upload-conversion/route.ts
- apps/web/lib/integrations/google-ads/api.ts
- apps/web/app/ee/api/google-ads/conversion-actions/route.ts
- apps/web/app/ee/api/track/lead/client/route.ts
- apps/web/app/ee/api/google-ads/callback/route.ts
- apps/web/lib/auth/track-dub-lead.ts
- apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts
- apps/web/app/ee/api/shopify/pixel/route.ts
- apps/web/app/ee/api/track/sale/client/route.ts
- apps/web/app/ee/api/track/lead/route.ts
- apps/web/app/api/dub/webhook/route.ts
- apps/web/scripts/dev/simulate-shopify-conversion.ts
- apps/web/app/ee/api/track/sale/route.ts
- apps/web/scripts/programs/3-import-customer-leads.ts
- apps/web/lib/api/conversions/track-sale.ts
- apps/web/scripts/dev/data.json
- apps/web/lib/api/conversions/track-lead.ts
- apps/web/scripts/create-integration.ts
- apps/web/lib/api/commissions/create-manual-commissions.ts
- apps/web/lib/dub.ts
- apps/web/lib/postback/postback-adapters.ts
- apps/web/lib/postback/record-postback-event.ts
- apps/web/ui/guides/integrations.ts
- apps/web/lib/api/customers/reattribute-customer.ts
- apps/web/lib/integrations/hubspot/track-lead.ts
- apps/web/lib/integrations/singular/track-lead.ts
Overview
Ad Conversion Sync synchronizes offline conversion events, mobile app attribution postbacks, and e-commerce checkouts back to advertising networks like Google Ads apps/web/lib/integrations/google-ads/upload-conversion.ts, AppsFlyer apps/web/app/ee/api/appsflyer/webhook/route.ts, and Singular apps/web/app/ee/api/singular/webhook/route.ts. It solves attribution fragmentation by bridging server-side conversion funnels, web pixel tracking apps/web/app/ee/api/shopify/pixel/route.ts, and external marketing platforms into unified referral and partner commissions. Key design decisions include QStash-driven asynchronous queueing apps/web/lib/integrations/google-ads/upload-conversion.ts, strict IP range validation for webhooks [[apps/web/app/ee/api/appsflyer/webhook/route.ts], [apps/web/app/ee/api/singular/webhook/route.ts]], and custom postback adapters with cryptographic signing apps/web/lib/postback/postback-adapters.ts. This subsystem interacts directly with Dub's core conversion and customer management modules to record and verify downstream event loops [[apps/web/lib/api/conversions/track-lead.ts], [apps/web/lib/api/conversions/track-sale.ts]].
Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:1-177, apps/web/app/ee/api/shopify/pixel/route.ts:1-83, apps/web/app/ee/api/singular/webhook/route.ts:1-113, apps/web/lib/api/conversions/track-lead.ts:1-63, apps/web/lib/api/conversions/track-sale.ts:1-62, apps/web/lib/integrations/google-ads/upload-conversion.ts:1-98, apps/web/lib/postback/postback-adapters.ts:1-69
Google Ads OAuth and Configuration
Authorization and Account Discovery
The Google Ads integration follows an OAuth 2.0 authorization code flow initiated by workspace owners on Advanced and Enterprise plans. When a user authorizes the application, the callback route exchanges the authorization code for access and refresh tokens, encrypts the credentials using workspace secrets, and persists them via the integration installation module.
During the OAuth callback execution, the system instantiates the Google Ads API client using the newly acquired access token and invokes listAccessibleCustomers to retrieve all accessible customer accounts and manager hierarchies. If exactly one customer account is available, the integration auto-selects its descriptive name, account ID, and infers the correct login customer ID.
const googleAdsApi = new GoogleAdsApi({
accessToken: token.access_token,
});
const customers = await googleAdsApi.listAccessibleCustomers();
let customerName: string | null = null;
let customerId: string | null = null;
let loginCustomerId: string | null = null;
// Just one customer, so we can use the first one
if (customers.length === 1) {
customerName = customers[0].descriptiveName;
customerId = customers[0].id;
loginCustomerId = inferLoginCustomerId({
customers,
selectedCustomerId: customerId,
});
}Sources: apps/web/app/ee/api/google-ads/callback/route.ts:92-110
Note
Only workspace members with the owner role can install or modify the Google Ads integration, and the workspace must possess an active Advanced or Enterprise plan capability.
Sources: apps/web/app/ee/api/google-ads/callback/route.ts:63-83
Login Customer ID Inference
Manager accounts (MCC) and client accounts require precise handling of the login-customer-id header to avoid USER_PERMISSION_DENIED (403) errors. The system infers and tests multiple candidate identifiers when resolving accounts or listing conversion actions.
export const inferLoginCustomerId = ({
customers,
selectedCustomerId,
}: {
customers: {
id: string;
manager: boolean;
loginCustomerId?: string | null;
}[];
selectedCustomerId: string;
}) => {
const normalizedSelectedId = selectedCustomerId.replace(/-/g, "");
const selectedCustomer = customers.find(
(customer) => customer.id.replace(/-/g, "") === normalizedSelectedId,
);
if (!selectedCustomer) {
return null;
}
if (selectedCustomer.loginCustomerId) {
return selectedCustomer.loginCustomerId.replace(/-/g, "");
}
if (selectedCustomer.manager) {
return normalizedSelectedId;
}
return null;
};Sources: apps/web/lib/integrations/google-ads/api.ts:509-538
Warning
Sub-managers discovered via account hierarchy traversal that have independent customer login identifiers are excluded from automatic manager candidate lists to prevent invalid permission headers.
Sources: apps/web/lib/integrations/google-ads/api.ts:563-570
Conversion Action Selection
Workspaces query available conversion actions through an API endpoint that verifies workspace-level installation, retrieves fresh OAuth tokens, and iterates through login customer ID candidates. It performs a streaming search query filtering specifically for enabled upload click conversion actions.
for (const loginCustomerId of candidates) {
try {
const googleAdsApi = new GoogleAdsApi({
accessToken: token.access_token,
loginCustomerId,
customerId,
});
const conversionActions =
await googleAdsApi.listUploadClickConversionActions(customerId);
return NextResponse.json({
conversionActions,
loginCustomerId,
});
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("USER_PERMISSION_DENIED")) {
throw error;
}
}
}Sources: apps/web/app/ee/api/google-ads/conversion-actions/route.ts:65-90
The conversion action query retrieves specific resource fields from the Google Ads API schema:
| Field Path | Filter / Condition | Purpose |
|---|---|---|
conversion_action.id | Selected | Identifies the unique numeric conversion action identifier |
conversion_action.name | Selected | Provides the human-readable label shown in settings |
conversion_action.resource_name | Selected | Provides the full API resource path (customers/{cid}/conversionActions/{id}) |
conversion_action.type | Equals UPLOAD_CLICKS | Restricts results to offline click upload actions |
conversion_action.status | Equals ENABLED | Filters out paused or removed conversion actions |
Sources: apps/web/lib/integrations/google-ads/api.ts:415-421
Google Ads Conversion Upload Pipeline
Click ID Extraction and Queueing
When a lead or sale event is tracked via Conversion Tracking, the conversion pipeline extracts promotional click identifiers from the visitor's landing URL to establish attribution. The extractGoogleAdsClickId function parses URL query parameters using getSearchParams and checks for three supported click identifier variants: gclid (Google Click ID), gbraid (App-to-web click ID), and wbraid (Web-to-app click ID). If none of these parameters are present, conversion queueing is immediately aborted.
const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => {
try {
const queryParams = getSearchParams(url);
if (queryParams.gclid) {
return {
gclid: queryParams.gclid,
};
}
if (queryParams.gbraid) {
return {
gbraid: queryParams.gbraid,
};
}
if (queryParams.wbraid) {
return {
wbraid: queryParams.wbraid,
};
}
return null;
} catch {
return null;
}
};Sources: apps/web/lib/integrations/google-ads/upload-conversion.ts:21-47
Before publishing the payload to QStash, queueGoogleAdsConversionUpload verifies whether the target workspace has installed the Google Ads integration. It also handles currency normalization: because the Google Ads Data Manager API expects major currency units, conversion values are divided by 100 for any currency that is not a zero-decimal currency (such as JPY). The payload is subsequently published asynchronously to the ingestion endpoint with up to 3 retries and a unique deduplication identifier constructed from the workspace ID and event ID.
export const queueGoogleAdsConversionUpload = async (
payload: z.infer<typeof googleAdsConversionUploadSchema>,
) => {
if (!extractGoogleAdsClickId(payload.click.url)) {
return;
}
if (!(await googleAdsInstalledWorkspaces.has(payload.workspaceId))) {
return;
}
// Data Manager expects major currency units, so we need to divide by 100
// if the currency is NOT a zero decimal currency (e.g. JPY doesn't have cents)
if (
payload.conversionValue &&
payload.currencyCode &&
!isZeroDecimalCurrency(payload.currencyCode)
) {
payload.conversionValue = payload.conversionValue / 100;
}
try {
const response = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/upload-conversion`,
body: payload,
retries: 3,
deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`,
});
if (!response.messageId) {
throw new Error("Failed to queue Google Ads conversion upload");
}
return response;
} catch (error) {
logger.error("google-ads.queue_conversion_failed", {
service: "google-ads",
error: toErrorFields(error),
correlation: {
workspaceId: payload.workspaceId,
eventId: payload.eventId,
eventType: payload.eventType,
clickId: payload.click.id,
},
});
await logger.flush();
throw error;
}
};Sources: apps/web/lib/integrations/google-ads/upload-conversion.ts:49-98
Note
QStash message deduplication relies on the deterministic deduplication ID format google-ads-{workspaceId}-{eventId}, preventing duplicate ingestion of the same offline conversion event if retried.
Sources: apps/web/lib/integrations/google-ads/upload-conversion.ts:75-75
API Upload Execution and Mapping
The scheduled cron route handles incoming POST requests at /api/google-ads/upload-conversion using the withCron wrapper. It parses the incoming body against googleAdsConversionUploadSchema and delegates execution to uploadGoogleAdsConversion.
Sources: apps/web/app/ee/api/google-ads/upload-conversion/route.ts:9-20
The upload handler queries the InstalledIntegration table for the specified workspace using the GOOGLE_ADS_INTEGRATION_ID constant. It parses workspace settings and resolves the appropriate conversion mapping by matching the incoming event type (lead or sale) and event name against configured mappings. If the customer ID or conversion mapping is missing, or if the click URL lacks a valid click ID, the upload operation returns a skipped status.
export const uploadGoogleAdsConversion = async (
payload: z.infer<typeof googleAdsConversionUploadSchema>,
): Promise<GoogleAdsConversionUploadResult> => {
const {
workspaceId,
eventType,
click,
conversionDateTime,
eventId,
eventName,
conversionValue,
currencyCode,
conversionCount,
} = googleAdsConversionUploadSchema.parse(payload);
try {
const installedIntegration = await prisma.installedIntegration.findFirst({
where: {
integrationId: GOOGLE_ADS_INTEGRATION_ID,
projectId: workspaceId,
},
select: {
id: true,
settings: true,
credentials: true,
},
});
if (!installedIntegration) {
return {
message: `Google Ads integration not installed for workspace ${workspaceId}. Skipping...`,
status: "skipped",
};
}
const settings = googleAdsSettingsSchema.parse(
installedIntegration.settings ?? {},
);
const mappings =
eventType === "lead" ? settings.leadMappings : settings.saleMappings;
const mapping = resolveGoogleAdsConversionMapping({
mappings,
eventName,
});
if (!settings.customerId) {
return {
message: `Missing customerId. Skipping...`,
status: "skipped",
};
}
if (!mapping) {
return {
message: mappings.length
? `No ${eventType} conversion mapping matched event name "${eventName ?? ""}". Skipping...`
: `Missing ${eventType} conversion mapping. Skipping...`,
status: "skipped",
};
}
const conversionAction = mapping.conversionAction;
const googleClickId = extractGoogleAdsClickId(click.url);
if (!googleClickId) {
return {
message: `No gclid/gbraid/wbraid found on click ${click.id}. Skipping...`,
status: "skipped",
};
}Sources: apps/web/lib/integrations/google-ads/upload-conversion.ts:105-176
Once validated, the pipeline retrieves a fresh OAuth access token via googleAdsOAuthProvider.getAccessToken and instantiates the GoogleAdsApi client. It invokes uploadClickConversion against the Google Ads Data Manager API endpoint (events:ingest), wrapping the call in an exponential backoff retry loop with a maximum of 3 retries and delays scaled by 1000 * Math.pow(2, attempt).
const token =
await googleAdsOAuthProvider.getAccessToken(installedIntegration);
let loginCustomerId: string | null = null;
if (
settings.loginCustomerId &&
settings.loginCustomerId !== settings.customerId
) {
loginCustomerId = settings.loginCustomerId;
}
const googleAdsApi = new GoogleAdsApi({
accessToken: token.access_token,
loginCustomerId,
customerId: settings.customerId,
});
const maxRetries = 3;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await googleAdsApi.uploadClickConversion({
customerId: settings.customerId,
conversionAction,
googleClickId,
conversionDateTime,
conversionValue,
currencyCode,
conversionCount,
eventId,
});
return {
message: `Uploaded ${eventType} conversion for workspace ${workspaceId} (requestId: ${response.requestId})`,
status: "uploaded",
};
} catch (error) {
if (attempt < maxRetries) {
await sleep(1000 * Math.pow(2, attempt));
continue;
}
throw error;
}
}Sources: apps/web/lib/integrations/google-ads/upload-conversion.ts:178-223
Warning
New integrations must utilize the Data Manager API (events:ingest) rather than the legacy ConversionUploadService.UploadClickConversions service when submitting offline click conversions.
Sources: apps/web/lib/integrations/google-ads/api.ts:441-443
Shopify Pixel Order Attribution Sync
Pixel Webhook Ingestion and Validation
The Shopify Pixel event endpoint processes incoming telemetry via POST /api/shopify/pixel. Request bodies are validated using a Zod schema requiring clickId, checkoutToken, and shopDomain. CORS headers from COMMON_CORS_HEADERS are attached to all responses, including preflight OPTIONS requests returning status 204.
import { COMMON_CORS_HEADERS } from "@/lib/api/cors";
import { parseRequestBody } from "@/lib/api/utils";
import {
shopifyCheckoutCache,
tryDispatchShopifyOrderJob,
} from "@/lib/integrations/shopify/checkout-cache";
import { getClickEvent } from "@/lib/tinybird";
import { ratelimit } from "@/lib/upstash";
import { LOCALHOST_IP } from "@dub/utils";
import { ipAddress, waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
import * as z from "zod/v4";
const inputSchema = z.object({
clickId: z.string().nullish(),
checkoutToken: z.string().nullish(),
shopDomain: z.string().nullish(),
});
// POST /api/shopify/pixel – Handle the Shopify Pixel events
export const POST = async (req: Request) => {
const response = NextResponse.json("OK", {
headers: COMMON_CORS_HEADERS,
});Sources: apps/web/app/ee/api/shopify/pixel/route.ts:1-25
Incoming payloads are parsed, logged, and filtered for required identification tokens. If either checkoutToken or clickId is absent, the endpoint logs an error and skips processing while returning an "OK" response.
Sources: apps/web/app/ee/api/shopify/pixel/route.ts:26-46
Warning
Requests lacking either a checkoutToken or a clickId are dropped immediately without initiating cache updates or dispatching order background jobs.
Sources: apps/web/app/ee/api/shopify/pixel/route.ts:37-45
Rate Limiting and Click Event Retrieval
Prior to executing attribution logic, requests undergo rate limiting keyed by client IP. When executing on Vercel (process.env.VERCEL === "1"), the IP is resolved using ipAddress(req); otherwise, LOCALHOST_IP is used. The limiter evaluates against the key prefix shopify-track-pixel:{ip}.
Sources: apps/web/app/ee/api/shopify/pixel/route.ts:47-55
Once rate limiting passes, the route queries Tinybird via getClickEvent using the provided clickId. If no matching click event exists in analytics storage, the request is aborted.
Sources: apps/web/app/ee/api/shopify/pixel/route.ts:56-62
Checkout Cache Lookups and Job Dispatch
Validated pixel events are committed asynchronously using waitUntil. The checkout token is mapped to its associated clickId inside shopifyCheckoutCache, after which tryDispatchShopifyOrderJob triggers downstream e-commerce order attribution processing.
Sources: apps/web/app/ee/api/shopify/pixel/route.ts:63-76
| Field Name | Type | Nullable | Purpose |
|---|---|---|---|
clickId | string | Yes | Associates the visitor click identifier with the e-commerce session |
checkoutToken | string | Yes | Unique token identifying the Shopify checkout instance |
shopDomain | string | Yes | Target Shopify merchant storefront domain |
Sources: apps/web/app/ee/api/shopify/pixel/route.ts:14-18
Mobile Attribution with AppsFlyer
Webhook Verification and IP Range Enforcement
The AppsFlyer postback webhook endpoint listens for incoming events at GET /api/appsflyer/webhook. Unless executing in a local development environment (isLocalDev), incoming requests have their client IP verified against allowed APPSFLYER_IP_RANGES using isIpInRange. If an address falls outside the designated ranges, a DubApiError with code forbidden is thrown.
if (!isLocalDev) {
const ip = await getIP();
const isAllowed = APPSFLYER_IP_RANGES.some((range) =>
isIpInRange(ip, range),
);
if (!isAllowed) {
throw new DubApiError({
code: "forbidden",
message: `IP address ${ip} is not allowed.`,
});
}
}Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:37-49
Warning
Requests originating from unverified IP addresses outside APPSFLYER_IP_RANGES are rejected with a 403 Forbidden response code in production environments.
Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:43-48
Query parameters are parsed using querySchema to extract appId, partnerEventId, and eventValue. The partnerEventId must explicitly match either "lead" or "sale" values, otherwise processing is skipped and an acknowledgment response is returned.
const querySchema = z.object({
appId: z.string(),
partnerEventId: z.string().nullish(),
eventValue: z.string().nullish(),
});
…
queryParams = getSearchParams(req.url);
const { appId, partnerEventId } = querySchema.parse(queryParams);
if (!partnerEventId || !["lead", "sale"].includes(partnerEventId)) {
return logAndRespond(
`"${partnerEventId}" is not a valid partner event ID (accepted values: "lead", "sale"). Skipping...`,
);
}Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:20-24, apps/web/app/ee/api/appsflyer/webhook/route.ts:51-59
Workspace Installation Lookup
Once the query parameters are validated, the route queries Prisma's installedIntegration model to find an active configuration matching the APPSFLYER_INTEGRATION_ID constant and the specified appId stored within integration settings JSON paths.
// Find the installation
const installation = await prisma.installedIntegration.findFirst({
where: {
integrationId: APPSFLYER_INTEGRATION_ID,
settings: {
path: "$.appIds",
array_contains: appId,
},
},
select: {
project: {
select: {
id: true,
stripeConnectId: true,
webhookEnabled: true,
},
},
},
});Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:61-80
If no matching installation is found for the given app ID, a bad_request error is raised requiring configuration verification.
if (!installation) {
throw new DubApiError({
code: "bad_request",
message: `AppsFlyer integration is not configured for the appId: "${appId}". Did you add the app ID to the AppsFlyer integration settings?`,
});
}Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:81-86
Conversion Currency Transformation and Event Recording
When partnerEventId is "lead", parameters are parsed via trackLeadRequestSchema and dispatched to trackLead with commissionSource set to CommissionSource.appsflyer.
if (partnerEventId === "lead") {
const {
clickId,
eventName,
customerExternalId,
customerName,
customerEmail,
customerAvatar,
} = trackLeadRequestSchema.parse(queryParams);
const leadResponse = await trackLead({
clickId,
eventName,
customerExternalId,
customerName,
customerEmail,
customerAvatar,
eventQuantity: undefined,
mode: undefined,
metadata: null,
workspace,
commissionSource: CommissionSource.appsflyer,
});
response = JSON.stringify(leadResponse);
}Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:91-116
When partnerEventId is "sale", raw financial amounts undergo currency transformation via appsflyerAmountToDubCents before being validated through trackSaleRequestSchema. The resulting conversion record is handled by trackSale using a paymentProcessor of "custom" and CommissionSource.appsflyer.
// Track sale event
else if (partnerEventId === "sale") {
const amountInCents = appsflyerAmountToDubCents(queryParams.amount);
const { eventName, customerExternalId, amount, currency, invoiceId } =
trackSaleRequestSchema.parse({
...queryParams,
...(amountInCents !== undefined && { amount: amountInCents }),
});
const saleResponse = await trackSale({
customerExternalId,
amount,
currency,
eventName,
paymentProcessor: "custom",
invoiceId,
leadEventName: undefined,
metadata: null,
workspace,
commissionSource: CommissionSource.appsflyer,
});
response = JSON.stringify(saleResponse);
}Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:118-141
| Query Parameter / Field | Type | Nullable | Purpose |
|---|---|---|---|
appId | string | No | Mobile application identifier configured in AppsFlyer settings |
partnerEventId | string | Yes | Identifies the conversion category (lead or sale) |
eventValue | string | Yes | Additional metadata payload associated with the postback event |
Sources: apps/web/app/ee/api/appsflyer/webhook/route.ts:20-24
Singular Mobile Attribution Ingestion
Event Mapping and Workspace Resolution
Incoming mobile postbacks from Singular are received at the endpoint GET /api/singular/webhook. When requests arrive, the client IP address is checked against SINGULAR_IP_RANGES using isIpInRange unless the environment flag isLocalDev is active. If the incoming IP does not match any authorized range, a DubApiError with code forbidden is thrown.
import { isLocalDev } from "@/lib/api/environment";
import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
import { getIP } from "@/lib/api/utils/get-ip";
import { normalizeWorkspaceId } from "@/lib/api/workspaces/workspace-id";
import { withAxiom } from "@/lib/axiom/server";
import { SINGULAR_IP_RANGES } from "@/lib/integrations/singular/singular-ip-ranges";
import { trackSingularLeadEvent } from "@/lib/integrations/singular/track-lead";
import { trackSingularSaleEvent } from "@/lib/integrations/singular/track-sale";
import { isIpInRange } from "@/lib/middleware/utils/is-ip-in-range";
import { prisma } from "@/lib/prisma";
import { getSearchParams } from "@dub/utils";
import { NextResponse } from "next/server";
import * as z from "zod/v4";
…
// GET /api/singular/webhook – listen to Postback events from Singular
export const GET = withAxiom(async (req) => {
try {
if (!isLocalDev) {
const ip = await getIP();
const isAllowed = SINGULAR_IP_RANGES.some((range) =>
isIpInRange(ip, range),
);
if (!isAllowed) {
throw new DubApiError({
code: "forbidden",
message: `IP address ${ip} is not allowed.`,
});
}
}Sources: apps/web/app/ee/api/singular/webhook/route.ts:1-13, apps/web/app/ee/api/singular/webhook/route.ts:37-52
Warning
Production webhook calls originating outside SINGULAR_IP_RANGES are immediately rejected with a 403 Forbidden error status via handleAndReturnErrorResponse.
Sources: apps/web/app/ee/api/singular/webhook/route.ts:40-51, apps/web/app/ee/api/singular/webhook/route.ts:110-112
Query parameters are inspected to extract dub_workspace_id, which is validated and normalized using normalizeWorkspaceId. The incoming event_name parameter is checked against a supported event dictionary to map Singular postback actions into Dub conversion types. Unsupported events are logged to the console and acknowledged with an "OK" response without throwing an error.
const singularToDubEvent = {
activated: "lead",
sng_complete_registration: "lead",
sng_subscribe: "sale",
sng_ecommerce_purchase: "sale",
__iap__: "sale", // In-app purchase
"Copy GAID": "lead", // Singular Device Assist
"copy IDFA": "lead", // Singular Device Assist
};
const supportedEvents = Object.keys(singularToDubEvent);
const querySchema = z.object({
dub_workspace_id: z
.string()
.min(1, "dub_workspace_id is required")
.describe(
"The Singular advertiser's workspace ID on Dub (see https://d.to/id).",
)
.transform((v) => normalizeWorkspaceId(v)),
});
…
const queryParams = getSearchParams(req.url);
const { dub_workspace_id: workspaceId } = querySchema.parse(queryParams);
const { event_name: eventName } = queryParams;
if (!eventName) {
throw new DubApiError({
code: "bad_request",
message: "event_name is required.",
});
}
if (!supportedEvents.includes(eventName)) {
console.error(
`Event ${eventName} is not supported by Singular <> Dub integration.`,
);
return NextResponse.json("OK");
}Sources: apps/web/app/ee/api/singular/webhook/route.ts:15-35, apps/web/app/ee/api/singular/webhook/route.ts:54-73
| Singular Event Name | Mapped Dub Event Type | Description |
|---|---|---|
activated | lead | User activation event |
sng_complete_registration | lead | Standard registration completion |
sng_subscribe | sale | Subscription event |
sng_ecommerce_purchase | sale | E-commerce purchase transaction |
__iap__ | sale | In-app purchase event |
Copy GAID | lead | Singular Device Assist GAID copy |
copy IDFA | lead | Singular Device Assist IDFA copy |
Sources: apps/web/app/ee/api/singular/webhook/route.ts:15-23
Prisma queries the project model using the resolved workspace ID to retrieve workspace attributes including stripeConnectId and webhookEnabled. If the workspace cannot be found, a not_found error is raised.
const workspace = await prisma.project.findUnique({
where: {
id: workspaceId,
},
select: {
id: true,
stripeConnectId: true,
webhookEnabled: true,
},
});
if (!workspace) {
throw new DubApiError({
code: "not_found",
message: `Workspace ${workspaceId} not found.`,
});
}Sources: apps/web/app/ee/api/singular/webhook/route.ts:75-91
Lead Event Bridging
Once the workspace is authenticated, lead events are processed by deleting the dub_workspace_id parameter and dispatching the payload to trackSingularLeadEvent. The helper parses dub_id as the click ID and extracts nested parameters from event_attributes containing customer details, before invoking trackLead with a commissionSource of CommissionSource.singular.
const dubEvent = singularToDubEvent[eventName];
delete queryParams.dub_workspace_id;
if (dubEvent === "lead") {
await trackSingularLeadEvent({
queryParams,
workspace,
});
} else if (dubEvent === "sale") {
await trackSingularSaleEvent({
queryParams,
workspace,
});
}Sources: apps/web/app/ee/api/singular/webhook/route.ts:93-108
export const trackSingularLeadEvent = async ({
queryParams,
workspace,
}: {
queryParams: Record<string, string>;
workspace: Pick<WorkspaceProps, "id" | "stripeConnectId" | "webhookEnabled">;
}) => {
const {
dub_id: clickId,
event_name: eventName,
event_attributes: {
customer_external_id: customerExternalId,
customer_name: customerName,
customer_email: customerEmail,
customer_avatar: customerAvatar,
event_quantity: eventQuantity,
mode,
},
} = singularLeadEventSchema.parse(queryParams);
return await trackLead({
clickId,
eventName,
customerEmail,
customerAvatar,
customerExternalId,
customerName,
eventQuantity,
mode,
metadata: null,
workspace,
commissionSource: CommissionSource.singular,
});
};Sources: apps/web/lib/integrations/singular/track-lead.ts:31-64
Partner Postback and Conversion Relaying
Downstream postback dispatching is handled by concrete subclasses extending PostbackAdapter, which manage custom event transformer registration upon initialization and execute asynchronous delivery via QStash. The abstract base class instantiates a PostbackEventTransformers registry and exposes an execute method that transforms raw event payloads before signing and publishing them to configured endpoints.
export interface PostbackPayload {
eventId: string;
event: PostbackTrigger;
createdAt: string;
data: unknown;
}
export abstract class PostbackAdapter {
protected eventTransformers = new PostbackEventTransformers();
constructor(protected postback: Postback) {
this.registerEventTransformers();
}
protected abstract registerEventTransformers(): void;Sources: apps/web/lib/postback/postback-adapters.ts:8-23
async execute(payload: PostbackPayload) {
const transformedPayload = this.eventTransformers.transform(payload);
if (!transformedPayload) {
return;
}
const searchParams = {
postbackId: this.postback.id,
eventId: payload.eventId,
event: payload.event,
};
const callbackUrl = buildCallbackUrl(
`${APP_DOMAIN_WITH_NGROK}/api/postbacks/callback`,
searchParams,
);Sources: apps/web/lib/postback/postback-adapters.ts:24-41
When dispatching postback requests, execute generates an HMAC webhook signature using the postback secret and attaches it to the request header Dub-Signature. It also sets Upstash-Hide-Headers to "true" before publishing the payload via qstash.publishJSON.
const signature = await createWebhookSignature(
this.postback.secret,
transformedPayload,
);
const response = await qstash.publishJSON({
callback: callbackUrl.href,
failureCallback: callbackUrl.href,
url: this.postback.url,
body: transformedPayload,
headers: {
"Dub-Signature": signature,
"Upstash-Hide-Headers": "true",
},
});
if (!response.messageId) {
console.error("Failed to publish postback event to QStash", response);
}
if (process.env.NODE_ENV === "development") {
console.debug("Published postback event to QStash", {
...response,
payload: transformedPayload,
});
}
}Sources: apps/web/lib/postback/postback-adapters.ts:42-68
Warning
If qstash.publishJSON returns a response object lacking a messageId, an error is logged to console.error, though execution continues without throwing.
Sources: apps/web/lib/postback/postback-adapters.ts:58-60
Postback event telemetry and audit logging are recorded using Tinybird through the recordPostbackEvent ingestion helper, which targets the dub_postback_events data source using the postbackEventInputSchemaTB schema while omitting the timestamp field.
import { postbackEventInputSchemaTB } from "@/lib/postback/schemas";
import { tb } from "../tinybird/client";
export const recordPostbackEvent = tb.buildIngestEndpoint({
datasource: "dub_postback_events",
event: postbackEventInputSchemaTB.omit({ timestamp: true }),
});Sources: apps/web/lib/postback/record-postback-event.ts:1-7
Related
On This Page
OverviewGoogle Ads OAuth and ConfigurationAuthorization and Account DiscoveryLogin Customer ID InferenceConversion Action SelectionGoogle Ads Conversion Upload PipelineClick ID Extraction and QueueingAPI Upload Execution and MappingShopify Pixel Order Attribution SyncPixel Webhook Ingestion and ValidationRate Limiting and Click Event RetrievalCheckout Cache Lookups and Job DispatchMobile Attribution with AppsFlyerWebhook Verification and IP Range EnforcementWorkspace Installation LookupConversion Currency Transformation and Event RecordingSingular Mobile Attribution IngestionEvent Mapping and Workspace ResolutionLead Event BridgingPartner Postback and Conversion RelayingRelated