---
title: "Google Ads Attribution"
description: "Google Ads Attribution integrates Dub with Google Ads to bridge referral tracking and campaign optimization by automatically uploading offline click conversions. It solves the fragmentation between..."
last_updated: "2026-10-05T05:07:35.154101+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/google-ads-attribution"
---

<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/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts)
- [apps/web/app/ee/api/google-ads/conversion-actions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts)
- [apps/web/lib/integrations/google-ads/api.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts)
- [apps/web/app/ee/api/google-ads/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/callback/route.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/app/ee/api/track/visit/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/track/lead/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts)
- [apps/web/app/ee/api/google-ads/upload-conversion/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts)
- [apps/web/lib/auth/track-dub-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/track-dub-lead.ts)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/app/ee/api/track/sale/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts)
- [apps/web/lib/api/conversions/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts)
- [apps/web/lib/integrations/google-ads/ui/settings.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/ui/settings.tsx)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts)
- [apps/web/scripts/create-integration.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts)
- [apps/web/app/ee/api/shopify/pixel/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/app/ee/api/track/sale/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts)
- [apps/web/lib/tinybird/record-click.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts)
- [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts)
- [apps/web/lib/integrations/shopify/create-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts)
- [apps/web/lib/integrations/google-ads/oauth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts)
- [apps/web/lib/api/conversions/track-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts)
- [apps/web/lib/api/customers/reattribute-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts)
</details>

## Overview

Google Ads Attribution integrates Dub with Google Ads to bridge referral tracking and campaign optimization by automatically uploading offline click conversions. It solves the fragmentation between click acquisition and downstream conversion data, empowering workspaces to attribute leads and sales back to the specific Google Ads clicks that drove them. The system manages secure OAuth credential lifecycles, maps internal workspace event names to Google Ads conversion actions, extracts and propagates GCLID identifiers across the tracking layer, and executes background offline upload pipelines. By connecting conversion ingestion workflows with automated Google Ads reporting, this integration enables precise ROI measurement and campaign performance tuning.

Sources: [apps/web/scripts/create-integration.ts:13-22](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts#L13-L22), [apps/web/lib/integrations/google-ads/upload-conversion.ts:105-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L105-L223), [apps/web/lib/integrations/google-ads/oauth.ts:11-220](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L11-L220)

## OAuth Flow and Credential Management

### Overview

The Google Ads OAuth subsystem oversees the complete installation lifecycle, secure token exchange, encrypted credential persistence, and concurrent token refreshing using distributed locking via Upstash Redis. When a user initiates the installation process, the application constructs an authorization request URL with offline access and consent prompts, storing the request state in Redis with a 30-minute expiration. Upon callback completion, the route validates session ownership, enforces workspace owner permissions and plan capabilities, and persists encrypted tokens alongside inferred customer settings.

Sources: [apps/web/app/ee/api/google-ads/callback/route.ts:30-135](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/callback/route.ts#L30-L135), [apps/web/lib/integrations/google-ads/oauth.ts:21-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L21-L38)

### Installation Lifecycle Call-Chain

When a user completes authorization, Google redirects to the callback route where a sequence of validation, token processing, and database installation steps execute.

```
GET(req) → googleAdsOAuthProvider.exchangeCodeForToken() → prisma.project.findUniqueOrThrow() → googleAdsAuthTokenSchema.parse() → GoogleAdsApi.listAccessibleCustomers() → prisma.installedIntegration.findFirst() → googleAdsSettingsSchema.parse() → installIntegration() → waitUntil(googleAdsInstalledWorkspaces.add()) → redirect()
```

1. **`GET(req)`**: Entry point handling the incoming OAuth redirect request.
2. **`googleAdsOAuthProvider.exchangeCodeForToken()`**: Exchanges the authorization code for an OAuth token payload.
3. **`prisma.project.findUniqueOrThrow()`**: Retrieves workspace metadata and verifying user membership roles.
4. **`googleAdsAuthTokenSchema.parse()`**: Validates token structures and encrypts both `access_token` and `refresh_token` fields.
5. **`GoogleAdsApi.listAccessibleCustomers()`**: Queries accessible accounts to infer login customer identifiers.
6. **`prisma.installedIntegration.findFirst()`**: Checks for existing integration installation records.
7. **`googleAdsSettingsSchema.parse()`**: Parses combined existing and new customer settings.
8. **`installIntegration()`**: Persists the encrypted credentials and workspace settings to the database.
9. **`waitUntil(googleAdsInstalledWorkspaces.add())`**: Registers the newly installed workspace in the background.
10. **`redirect()`**: Navigates the user back to the workspace Google Ads settings page.

Sources: [apps/web/app/ee/api/google-ads/callback/route.ts:25-142](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/callback/route.ts#L25-L142)

### Token Refreshing and Concurrency Locking

When an access token expires or nears expiration, `getAccessToken()` evaluates token validity using a 60-second buffer. If expired, it attempts to acquire a Redis-backed lock before hitting the Google OAuth token endpoint.

> [!WARNING]
> If a refresh lock cannot be acquired on the first attempt, the process does not fail immediately. Instead, it enters a polling loop using `waitForRefreshedCredentials()` that waits between 200ms and 400ms per iteration up to a 5-second deadline to catch a token refreshed concurrently by another worker thread.

Sources: [apps/web/lib/integrations/google-ads/oauth.ts:40-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L40-L78), [apps/web/lib/integrations/google-ads/oauth.ts:160-177](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L160-L177), [apps/web/lib/integrations/google-ads/oauth.ts:210-219](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L210-L219)

### Configuration and Provider Parameters

The Google Ads OAuth provider is initialized with fixed configuration parameters defining the authorization endpoints, scope, and Redis key prefixes.

| Configuration Key | Value / Source | Purpose |
| :--- | :--- | :--- |
| `name` | `"Google Ads"` | Provider identifier used in error logging |
| `clientId` | `process.env.GOOGLE_ADS_CLIENT_ID!` | Google API OAuth client identifier |
| `clientSecret` | `process.env.GOOGLE_ADS_CLIENT_SECRET!` | Google API OAuth client secret |
| `authUrl` | `"https://accounts.google.com/o/oauth2/v2/auth"` | Initial user authorization endpoint |
| `tokenUrl` | `"https://oauth2.googleapis.com/token"` | Token exchange and refresh endpoint |
| `redirectUri` | `${APP_DOMAIN_WITH_NGROK}/api/google-ads/callback` | OAuth redirect callback URI |
| `redisStatePrefix` | `"google-ads:oauth:state"` | Redis key prefix for storing CSRF state |
| `bodyFormat` | `"form"` | Request payload format for token endpoints |
| `authorizationMethod` | `"body"` | Method for passing client credentials |

Sources: [apps/web/lib/integrations/google-ads/oauth.ts:222-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L222-L233)

## Google Ads API Client Surface

### Overview

The low-level Google Ads API client wrapper manages request signing, customer hierarchy inference, search queries, and conversion uploads. It provides methods for interacting directly with the Google Ads REST endpoints and Data Manager API.

Sources: [apps/web/lib/integrations/google-ads/api.ts:32-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L32-L503)

### Request Signing and Headers

All requests made through `googleAdsFetch` attach required authorization headers, developer credentials, and optional login customer context. The `getGoogleAdsHeaders` function constructs these headers from request options.

| Header Key | Source / Value | Purpose |
| :--- | :--- | :--- |
| `Authorization` | `Bearer ${accessToken}` | OAuth access token for authentication |
| `developer-token` | `process.env.GOOGLE_ADS_DEVELOPER_TOKEN!` | Google Ads developer token |
| `Content-Type` | `application/json` | Specifies JSON payload formatting |
| `login-customer-id` | `loginCustomerId` (hyphens removed) | Manager customer ID context when querying client accounts |

Sources: [apps/web/lib/integrations/google-ads/api.ts:32-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L32-L47)

> [!WARNING]
> If a request returns a non-OK status, `googleAdsFetch` parses the response text and error details, throwing an error containing the request path, status code, and formatted API error message.

Sources: [apps/web/lib/integrations/google-ads/api.ts:81-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L81-L88)

### Conversion Actions and Data Manager Uploads

The client wrapper interacts with both the Google Ads API for querying resources and the Data Manager API for event ingestion.

* **`listUploadClickConversionActions(customerId)`**: Executes a search stream query filtering for `UPLOAD_CLICKS` conversion actions with an `ENABLED` status, mapping the results through validation schemas.
* **`uploadClickConversion(...)`**: Constructs destination and event payloads before submitting offline click conversions to the Data Manager API endpoint (`events:ingest`).

Sources: [apps/web/lib/integrations/google-ads/api.ts:415-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L415-L503)

> [!TIP]
> New integrations must use the Data Manager API (`events:ingest`) rather than `ConversionUploadService.UploadClickConversions` when uploading offline click conversions.

Sources: [apps/web/lib/integrations/google-ads/api.ts:441-443](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L441-L443)

## Conversion Action and Event Mapping

### Overview

The conversion action and event mapping subsystem allows workspaces to bridge internal business telemetry with Google Ads conversion reporting. Through the workspace integration settings interface and accompanying server actions, administrators associate Dub lead and sale event names with verified Google Ads conversion actions.

Sources: [apps/web/lib/integrations/google-ads/ui/settings.tsx:72-343](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/ui/settings.tsx#L72-L343), [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts:26-141](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts#L26-L141)

### API Endpoint and Client Workflow

When an administrator selects a Google Ads customer account within the settings interface, the client fetches available conversion actions by invoking the workspace API route. 

The call-chain execution order for listing conversion actions follows this sequence:
1. `GET` route handler (`apps/web/app/ee/api/google-ads/conversion-actions/route.ts`) validates workspace permissions and installed integration status.
2. `googleAdsOAuthProvider.getAccessToken()` retrieves a valid OAuth token for the installation.
3. `getLoginCustomerIdCandidates()` computes manager hierarchy options based on customer settings.
4. `GoogleAdsApi` constructor instantiates the API client with the token, login customer context, and target customer ID.
5. `googleAdsApi.listUploadClickConversionActions(customerId)` queries the Google Ads API for enabled upload-click conversion actions.

Sources: [apps/web/app/ee/api/google-ads/conversion-actions/route.ts:15-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/conversion-actions/route.ts#L15-L89)

> [!WARNING]
> If a candidate login customer ID throws a `USER_PERMISSION_DENIED` error during conversion action retrieval, the iteration catches the error and tests the next candidate ID in the list before failing.

Sources: [apps/web/app/ee/api/google-ads/conversion-actions/route.ts:65-88](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/conversion-actions/route.ts#L65-L88)

### Validation and Server Action Execution

Once lead and sale event mappings are configured in the settings form, submitting changes triggers the `updateGoogleAdsSettingsAction` server action. This action enforces strict validation checks on customer association, format prefixes, and mapping uniqueness.

| Validation Check | Trigger Condition | Error Message / Outcome |
| :--- | :--- | :--- |
| Plan Capability | Workspace plan lacks advanced features | `"Google Ads integration is only available on Advanced and Enterprise plans."` |
| Installation Check | Integration record not found in database | `"Google Ads integration is not installed on your workspace."` |
| Customer Selection | `customerId` is missing while mappings are populated | `"A Google Ads account is required to configure conversion actions."` |
| Resource Prefix | Mapping `conversionAction` does not start with `customers/{id}/conversionActions/` | `"Invalid lead conversion action."` or `"Invalid sale conversion action."` |
| Event Uniqueness | Duplicate event names assigned across mappings | Handled by `getGoogleAdsEventMappingsError` |

Sources: [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts:43-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts#L43-L122)

> [!IMPORTANT]
> The helper function `uniqueMappingEventNames` automatically sanitizes event name arrays by passing them through `new Set()` to remove duplicate entries prior to persisting settings in PostgreSQL.

Sources: [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts:18-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts#L18-L24)

## Click Identifier Capture and Propagation

### Overview

The tracking layer extracts and propagates tracking identifiers and request metadata during click and visit ingestion. Incoming requests to tracking routes capture query parameters, perform identity hashing, verify workspace allowed hostnames, and persist rich telemetry records to Tinybird and Upstash Redis.

Sources: [apps/web/app/ee/api/track/click/route.ts:25-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L25-L158), [apps/web/app/ee/api/track/visit/route.ts:18-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/visit/route.ts#L18-L104), [apps/web/lib/tinybird/record-click.ts:22-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L22-L236)

### Click Ingestion and Caching Flow

When a click tracking request arrives at the `/api/track/click` endpoint, it parses domain and key arguments, validates request parameters via Zod schemas, and executes concurrent cache lookups for click identifiers and link metadata.

The call-chain execution order for recording a click event follows this sequence:
1. `POST` route handler (`apps/web/app/ee/api/track/click/route.ts`) parses the request body using `trackClickSchema`.
2. `getIdentityHash(req)` computes a unique visitor identity hash.
3. `redisGlobalWithTimeout.mget()` checks `recordClickCache` and `linkCache` in Upstash Redis.
4. `getLinkWithPartner()` queries Planetscale if the link is not cached.
5. `verifyAnalyticsAllowedHostnames()` validates the request origin against workspace allowed hostnames.
6. `recordClick()` ingests the click event into Tinybird and publishes streams to Upstash.

Sources: [apps/web/app/ee/api/track/click/route.ts:58-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L58-L158)

> [!NOTE]
> If a cached click identifier exists in Redis for the given identity hash, the endpoint reuses `cachedClickId` instead of allocating a new nanoid or duplicating the Tinybird ingestion record.

Sources: [apps/web/app/ee/api/track/click/route.ts:78-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L78-L80), [apps/web/app/ee/api/track/click/route.ts:114-114](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L114-L114)

### Telemetry Record Construction

The `recordClick` function aggregates geographical data, user-agent details, and request headers into a comprehensive click payload before dispatching it to external analytics sinks.

| Payload Field | Source / Derivation | Fallback Value |
| :--- | :--- | :--- |
| `timestamp` | Explicit parameter or current ISO string | `new Date().toISOString()` |
| `identity_hash` | `getIdentityHash(req)` | `""` |
| `click_id` | Passed `clickId` parameter | `null` (aborts recording) |
| `ip` | `ipAddress(req)` (Vercel) or `LOCALHOST_IP` | `""` (omitted for EU countries) |
| `continent` | `x-vercel-ip-continent` header | `""` |
| `country` | `geolocation(req).country` | `"Unknown"` |
| `device` | Capitalized `ua.device.type` | `"Desktop"` |
| `referer` | `referrer` param or `referer` header | `"(direct)"` |

Sources: [apps/web/lib/tinybird/record-click.ts:53-161](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L53-L161)

> [!WARNING]
> Requests originating from European Union country codes (`EU_COUNTRY_CODES`) automatically have their IP address stripped and recorded as an empty string to comply with privacy regulations.

Sources: [apps/web/lib/tinybird/record-click.ts:122-137](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L122-L137)

### Asynchronous Event Ingestion

Once the telemetry object is compiled, `recordClick` caches the click ID in Redis for 5 minutes and dispatches background tasks via `waitUntil` to ensure non-blocking response delivery.

```typescript
waitUntil(
  (async () => {
    const response = await Promise.allSettled([
      fetchWithRetry(
        `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
          },
          body: JSON.stringify(clickData),
        },
      ).then((res) => res.json()),
      recordClickCache.set({
        domain,
        key,
        identityHash,
        clickId,
      }),
      publishLinkClickEvent({
        linkId,
        timestamp: clickData.timestamp,
        ...(workspaceId && url && { workspaceId }),
        ...(programId && partnerId && { programId, partnerId }),
      }),
      publishWorkspaceClickEvent(clickData),
    ]);
  })(),
);
```

Sources: [apps/web/lib/tinybird/record-click.ts:169-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L169-L233)

## Downstream Conversion Tracking Triggers

### Overview

Downstream conversion tracking triggers bridge lead generation and sale ingestion workflows with offline upload pipelines. Whenever a lead or sale occurs via API endpoints, Shopify webhooks, or Stripe event integrations, the underlying application logic packages conversion attributes and invokes `queueGoogleAdsConversionUpload` inside Vercel's `waitUntil` asynchronous execution context. This architecture ensures that ingestion routes return HTTP responses immediately while offline conversion payloads are queued for delivery.

Sources: [apps/web/lib/api/conversions/track-lead.ts:226-231](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L226-L231), [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:213-297](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L213-L297), [apps/web/lib/integrations/shopify/create-lead.ts:139-165](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts#L139-L165), [apps/web/lib/api/conversions/track-sale.ts:348-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L348-L667)

### Lead and Sale Ingestion Hooks

Conversion triggers are embedded directly into core domain workflows. For leads, conversion hooks execute after verifying click metadata, recording the event in Tinybird, incrementing link statistics, and updating workspace usage. For sales, conversion hooks execute alongside partner commission creation and revenue metrics updates.

```typescript
queueGoogleAdsConversionUpload({
  workspaceId: workspace.id,
  eventType: EventType.lead,
  eventId: leadData.event_id,
  eventName: leadData.event_name,
  conversionDateTime: new Date().toISOString(),
  conversionCount: 1,
  click: {
    id: clickData.click_id,
    url: clickData.url,
  },
})
```

Sources: [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:284-295](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L284-L295), [apps/web/lib/integrations/shopify/create-lead.ts:153-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts#L153-L164), [apps/web/lib/api/conversions/track-sale.ts:655-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L655-L667)

> [!NOTE]
> Sales upload triggers pass financial metrics including `conversionValue` (derived from `saleData.amount`) and `currencyCode` (derived from `saleData.currency`), whereas lead triggers pass `conversionCount: 1` and omit currency details.

Sources: [apps/web/lib/api/conversions/track-sale.ts:655-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L655-L667)

### Trigger Workflow Parameters

The fields transmitted from ingestion workflows to the conversion upload queue vary depending on whether the event represents a lead or a sale transaction.

| Parameter | Type | Lead Workflow Source | Sale Workflow Source |
| :--- | :--- | :--- | :--- |
| `workspaceId` | `string` | `workspace.id` | `workspace.id` |
| `eventType` | `EventType` | `EventType.lead` | `EventType.sale` |
| `eventId` | `string` | `leadData.event_id` | `saleData.event_id` |
| `eventName` | `string` | `leadData.event_name` | `saleData.event_name` |
| `conversionDateTime` | `string` | `new Date().toISOString()` | `new Date().toISOString()` |
| `conversionCount` | `number` | `1` | Omitted |
| `conversionValue` | `number` | Omitted | `saleData.amount` |
| `currencyCode` | `string` | Omitted | `saleData.currency` |
| `click.id` | `string` | `clickData.click_id` | `saleData.click_id` |
| `click.url` | `string` | `clickData.url` | `saleData.url` |

Sources: [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:284-295](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L284-L295), [apps/web/lib/integrations/shopify/create-lead.ts:153-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts#L153-L164), [apps/web/lib/api/conversions/track-sale.ts:655-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L655-L667)

## Offline Conversion Upload Pipeline

### Overview

The offline conversion upload pipeline handles queueing conversion payloads, publishing them via QStash, executing background worker tasks, formatting currency values, and logging errors. When a conversion payload is prepared, `queueGoogleAdsConversionUpload()` first verifies the presence of valid click identifiers (`gclid`, `gbraid`, or `wbraid`) on the click URL using `extractGoogleAdsClickId()`. It checks if the workspace has installed the Google Ads integration via `googleAdsInstalledWorkspaces.has()`. If the currency is not a zero-decimal currency, it normalizes major currency units by dividing `conversionValue` by `100`.

Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:21-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L21-L68)

> [!NOTE]
> Non-zero-decimal currencies such as USD and EUR are divided by 100 before queueing because Google Ads Data Manager expects amounts in major currency units rather than minor currency subunits (cents).

Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:60-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L60-L68)

### Background Execution and QStash Dispatch

Once validated and normalized, the payload is published to QStash targeting `/api/google-ads/upload-conversion`. The publish operation specifies a maximum of 3 retries and a deterministic deduplication ID formatted as `google-ads-${payload.workspaceId}-${payload.eventId}`.

```typescript
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}`,
});
```

Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:71-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L71-L76)

The background API route handler `POST` is wrapped with `withCron` and parses the incoming request body against `googleAdsConversionUploadSchema`, immediately invoking `uploadGoogleAdsConversion(payload)`.

```typescript
export const POST = withCron(async ({ rawBody }) => {
  const payload = googleAdsConversionUploadSchema.parse(JSON.parse(rawBody));
  const { message, status } = await uploadGoogleAdsConversion(payload);

  if (status === "failed") {
    return logAndRespond(message, { status: 500, logLevel: "error" });
  }

  return logAndRespond(message, {
    logLevel: status === "skipped" ? "warn" : "info",
  });
});
```

Sources: [apps/web/app/ee/api/google-ads/upload-conversion/route.ts:9-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/upload-conversion/route.ts#L9-L21)

### Conversion Upload Execution Lifecycle

The core upload worker function `uploadGoogleAdsConversion()` executes a robust sequence of checks, token acquisition, and retry-backed API transmissions.

```mermaid
graph TD
    A[Parse Payload] --> B[Find Installed Integration]
    B --> C{Integration Found?}
    C -- No --> D[Return Skipped]
    C -- Yes --> E[Parse Settings & Resolve Mapping]
    E --> F{Mapping & Customer ID Valid?}
    F -- No --> G[Return Skipped]
    F -- Yes --> H[Extract Click ID & Fetch OAuth Token]
    H --> I[Instantiate GoogleAdsApi Client]
    I --> J[Execute uploadClickConversion with Retries]
    J --> K{Success?}
    K -- Yes --> L[Return Uploaded Status]
    K -- No --> M[Log Error & Flush Logger]
```

Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:105-244](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L105-L244)

The worker queries Prisma for the `InstalledIntegration` matching `GOOGLE_ADS_INTEGRATION_ID` and the workspace ID, parses its settings, and resolves the conversion mapping based on `eventType` (`lead` or `sale`) and `eventName`. It acquires an OAuth access token, sets up the `GoogleAdsApi` instance with credentials and optional `loginCustomerId`, and loops up to 3 retry attempts with exponential backoff (`1000 * Math.pow(2, attempt)`).

Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:121-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L121-L223)

### Pipeline Error Handling and Logging

Errors encountered during queueing or upload execution are captured by Axiom loggers with structured correlation metadata. If queueing fails, `queueGoogleAdsConversionUpload()` logs the error under `google-ads.queue_conversion_failed`, flushes the logger, and rethrows the error. If upload processing encounters an unhandled exception after exhausting all retry attempts, `uploadGoogleAdsConversion()` records an error log under `google-ads.upload_conversion_failed` and flushes the logger.

Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:83-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L83-L97), [apps/web/lib/integrations/google-ads/upload-conversion.ts:229-244](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L229-L244)

## Related

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


## Sitemap

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