Skip to main content
doc0blade47/dubdocumentation home
Sign in

Ad Conversion Sync

Last updated Oct 12, 202691% grounded
Relevant source files

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

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

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 PathFilter / ConditionPurpose
conversion_action.idSelectedIdentifies the unique numeric conversion action identifier
conversion_action.nameSelectedProvides the human-readable label shown in settings
conversion_action.resource_nameSelectedProvides the full API resource path (customers/{cid}/conversionActions/{id})
conversion_action.typeEquals UPLOAD_CLICKSRestricts results to offline click upload actions
conversion_action.statusEquals ENABLEDFilters out paused or removed conversion actions

Sources: apps/web/lib/integrations/google-ads/api.ts:415-421

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 NameTypeNullablePurpose
clickIdstringYesAssociates the visitor click identifier with the e-commerce session
checkoutTokenstringYesUnique token identifying the Shopify checkout instance
shopDomainstringYesTarget 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 / FieldTypeNullablePurpose
appIdstringNoMobile application identifier configured in AppsFlyer settings
partnerEventIdstringYesIdentifies the conversion category (lead or sale)
eventValuestringYesAdditional 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 NameMapped Dub Event TypeDescription
activatedleadUser activation event
sng_complete_registrationleadStandard registration completion
sng_subscribesaleSubscription event
sng_ecommerce_purchasesaleE-commerce purchase transaction
__iap__saleIn-app purchase event
Copy GAIDleadSingular Device Assist GAID copy
copy IDFAleadSingular 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

Was this page helpful?