---
title: "Ad Conversion Sync"
description: "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-a..."
last_updated: "2026-10-12T07:00:10.142659+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/integrations-subsystem/ad-conversion-sync"
---

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

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

- [apps/web/lib/integrations/google-ads/upload-conversion.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/google-ads/upload-conversion/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts)
- [apps/web/lib/integrations/google-ads/api.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/api.ts)
- [apps/web/app/ee/api/google-ads/conversion-actions/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts)
- [apps/web/app/ee/api/track/lead/client/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/lead/client/route.ts)
- [apps/web/app/ee/api/google-ads/callback/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/callback/route.ts)
- [apps/web/lib/auth/track-dub-lead.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/track-dub-lead.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/app/ee/api/shopify/pixel/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts)
- [apps/web/app/ee/api/track/sale/client/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/sale/client/route.ts)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/lead/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/dev/simulate-shopify-conversion.ts)
- [apps/web/app/ee/api/track/sale/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/sale/route.ts)
- [apps/web/scripts/programs/3-import-customer-leads.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/programs/3-import-customer-leads.ts)
- [apps/web/lib/api/conversions/track-sale.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/conversions/track-sale.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/dev/data.json)
- [apps/web/lib/api/conversions/track-lead.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/conversions/track-lead.ts)
- [apps/web/scripts/create-integration.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/create-integration.ts)
- [apps/web/lib/api/commissions/create-manual-commissions.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/commissions/create-manual-commissions.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/dub.ts)
- [apps/web/lib/postback/postback-adapters.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/postback-adapters.ts)
- [apps/web/lib/postback/record-postback-event.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/record-postback-event.ts)
- [apps/web/ui/guides/integrations.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/ui/guides/integrations.ts)
- [apps/web/lib/api/customers/reattribute-customer.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/customers/reattribute-customer.ts)
- [apps/web/lib/integrations/hubspot/track-lead.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/hubspot/track-lead.ts)
- [apps/web/lib/integrations/singular/track-lead.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/singular/track-lead.ts)
</details>

## 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts), AppsFlyer [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts), and Singular [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L1-L177), [apps/web/app/ee/api/shopify/pixel/route.ts:1-83](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L1-L83), [apps/web/app/ee/api/singular/webhook/route.ts:1-113](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L1-L113), [apps/web/lib/api/conversions/track-lead.ts:1-63](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/conversions/track-lead.ts#L1-L63), [apps/web/lib/api/conversions/track-sale.ts:1-62](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/conversions/track-sale.ts#L1-L62), [apps/web/lib/integrations/google-ads/upload-conversion.ts:1-98](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts#L1-L98), [apps/web/lib/postback/postback-adapters.ts:1-69](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/postback-adapters.ts#L1-L69)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/callback/route.ts#L92-L110)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/callback/route.ts#L63-L83)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/api.ts#L509-L538)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/api.ts#L563-L570)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts#L65-L90)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/api.ts#L415-L421)

## Google Ads Conversion Upload Pipeline

### Click ID Extraction and Queueing

When a lead or sale event is tracked via [Conversion Tracking](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts#L21-L47)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts#L49-L98)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts#L75-L75)

### 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts#L9-L20)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts#L105-L176)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/upload-conversion.ts#L178-L223)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/google-ads/api.ts#L441-L443)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L1-L25)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L26-L46)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L37-L45)

### 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L47-L55)

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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L56-L62)

### 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L63-L76)

| 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/shopify/pixel/route.ts#L14-L18)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L37-L49)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L43-L48)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L20-L24), [apps/web/app/ee/api/appsflyer/webhook/route.ts:51-59](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L51-L59)

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

```typescript
// 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L61-L80)

If no matching installation is found for the given app ID, a `bad_request` error is raised requiring configuration verification.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L81-L86)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L91-L116)

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

```typescript
// 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L118-L141)

| 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L20-L24)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L1-L13), [apps/web/app/ee/api/singular/webhook/route.ts:37-52](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L37-L52)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L40-L51), [apps/web/app/ee/api/singular/webhook/route.ts:110-112](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L110-L112)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L15-L35), [apps/web/app/ee/api/singular/webhook/route.ts:54-73](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L54-L73)

| 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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L15-L23)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L75-L91)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/singular/webhook/route.ts#L93-L108)

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/integrations/singular/track-lead.ts#L31-L64)

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

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/postback-adapters.ts#L8-L23)

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/postback-adapters.ts#L24-L41)

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

```typescript
  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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/postback-adapters.ts#L42-L68)

> [!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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/postback-adapters.ts#L58-L60)

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.

```typescript
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](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/postback/record-postback-event.ts#L1-L7)

## Related

- [Conversion Tracking](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/conversion-tracking)


## Sitemap

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