DEV FIELDNOTES
Advertising systems field guide 010Updated September 4, 2026

Offline Conversion Tracking for CRM Leads: GCLID, GBRAID, and WBRAID

Capture GCLID, GBRAID, and WBRAID safely, preserve attribution through a CRM, and upload qualified lead conversions with Google Data Manager API.

Three attribution signals joining a protected lead record that moves through CRM stages and produces one qualified conversion with diagnostic feedback

A prospect clicks an ad on Monday, submits a form on Tuesday, speaks with sales next week, and becomes a customer a month later. Google Ads receives the form submission but never sees the qualified lead or closed deal. The campaign optimizes for people who complete forms, while the CRM knows which leads create revenue.

Offline conversion tracking closes that gap by carrying attribution from the landing page into the lead record and sending a later CRM milestone back to the advertising platform. The fragile part is not the final API request. It is preserving the right identifiers, consent state, conversion time, action, value, and idempotency key through weeks of systems and human updates.

For Google Ads, the main URL identifiers are GCLID, GBRAID, and WBRAID. Capture every supported signal that arrives, preserve its value exactly, bind it to the lead, and upload the business milestone through the current Data Manager API. Do not decide on the landing page that one identifier is the universal winner.

Use Data Manager for new implementations

Since June 15, 2026, developer tokens without qualifying prior offline-upload activity are restricted from the legacy Google Ads API UploadClickConversions method. Google directs current and future offline conversion and enhanced-conversions-for-leads integrations to the Data Manager API.

Know what the three identifiers represent

GCLID is Google’s click identifier for many ad interactions. GBRAID and WBRAID are privacy-preserving attribution identifiers used in environments where a traditional click ID might not be available. In the Data Manager schema, GBRAID is associated with app events originating from iOS 14 and later, while WBRAID is associated with web events originating from those environments.

Attribution identifiers
gclid   Google click ID
gbraid  privacy-preserving identifier for app-related iOS flows
wbraid  privacy-preserving identifier for web-related iOS flows

All are opaque values.
GCLID and GBRAID are explicitly case-sensitive.
Preserve WBRAID exactly as received too.
Never lowercase, uppercase, hash, parse, or regenerate them.

Do not make the database choose exactly one. Google recommends including GCLID whenever available and adding GBRAID and WBRAID when present. GCLID, user-provided data, and supported braid signals can contribute to the same conversion upload.

Google’s offline-import upgrade guide recommends sending GCLID, user-provided data, an order or event ID, and braid parameters whenever each is available.

Capture without mutating the value

Treat the landing URL as untrusted input. Allowlist only the attribution keys you support, reject control characters and unreasonable lengths, and preserve the accepted value byte-for-byte. Trimming or changing case can turn a usable click ID into a non-match.

lib/attribution/google-click-ids.ts
const CLICK_ID_KEYS = ['gclid', 'gbraid', 'wbraid'] as const
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/

type ClickIds = Partial<Record<(typeof CLICK_ID_KEYS)[number], string>>

function acceptOpaqueId(value: string | null): string | undefined {
  if (!value) return undefined
  if (value.length > 2048) return undefined
  if (value !== value.trim()) return undefined
  if (CONTROL_CHARACTER.test(value)) return undefined
  return value
}

export function readGoogleClickIds(url: URL): ClickIds {
  return Object.fromEntries(
    CLICK_ID_KEYS.flatMap((key) => {
      const value = acceptOpaqueId(url.searchParams.get(key))
      return value ? [[key, value]] : []
    }),
  )
}

Wait for the approved consent decision

If your policy requires advertising storage consent before persisting click identifiers, do not write them before that state is known. A consent-aware client can send the allowlisted values to a same-origin Route Handler after the consent manager resolves. The server should verify the request origin and bind the data to a server-issued session reference.

app/api/attribution/route.ts
import {cookies, headers} from 'next/headers'
import {NextResponse} from 'next/server'
import {saveAttributionTouch} from '@/lib/attribution/store'

export async function POST(request: Request) {
  const requestHeaders = await headers()
  assertSameOrigin(requestHeaders)

  const cookieStore = await cookies()
  const sessionId = requireServerSessionId(cookieStore)
  const consent = await resolveConsent(cookieStore)

  if (consent.adStorage !== 'granted') {
    return new NextResponse(null, {status: 204})
  }

  const body = await request.json()
  const clickIds = readGoogleClickIds(new URL(body.landingUrl))

  await saveAttributionTouch({sessionId, clickIds, consent})
  return new NextResponse(null, {status: 204})
}
Capture session attributes too

When GCLID or WBRAID is unavailable, Google recommends sending captured session_attributes for offline conversions and enhanced conversions for leads. Store the encoded value beside the click IDs instead of trying to reconstruct it when the lead qualifies.

Choose and document an attribution overwrite rule

A visitor can arrive through several paid clicks before submitting a form. Decide whether the lead uses first paid touch, last paid touch, or a separate multi-touch model. The important property is determinism: the CRM conversion exporter must not silently pick whichever browser cookie happens to exist weeks later.

First-paid-touch storage rule
on an eligible landing:
  preserve any supported identifiers already stored
  fill only identifiers that are currently missing
  record captured_at and landing host

on lead submission:
  copy the attribution snapshot into the lead record
  freeze the snapshot used for conversion uploads

for multi-touch analysis:
  store additional touches in a separate append-only table

Do not use the full landing URL as an attribution field. Query strings can contain emails, internal tokens, or unrelated campaign data. Store the specific allowlisted identifiers, the landing hostname or normalized path if needed, capture time, consent reference, and attribution-model version.

Lead attribution schema
create table lead_attribution (
  lead_id uuid primary key references lead(id),
  model_version text not null,
  captured_at timestamptz not null,
  gclid_encrypted bytea,
  gbraid_encrypted bytea,
  wbraid_encrypted bytea,
  session_attributes_encrypted bytea,
  consent_snapshot_id uuid not null,
  landing_host text,
  created_at timestamptz not null default now()
);

Freeze attribution when the lead is created

The form submission is the bridge between an anonymous session and a CRM entity. Create the lead and copy the eligible attribution snapshot in one transaction. If the CRM record is created through an asynchronous integration, pass an internal lead ID—not raw click IDs—through the queue.

app/actions/create-lead.ts
'use server'

import {cookies} from 'next/headers'
import {db} from '@/lib/db'

export async function createLead(formData: FormData) {
  const input = validateLeadForm(formData)
  const sessionId = requireServerSessionId(await cookies())

  return db.transaction(async (tx) => {
    const lead = await tx.lead.create(input)
    const touch = await tx.attribution.findEligible(sessionId)

    if (touch) {
      await tx.leadAttribution.freezeForLead(lead.id, touch)
    }

    await tx.crmOutbox.enqueue({
      key: 'lead-created:' + lead.id,
      leadId: lead.id,
    })

    return {leadId: lead.id}
  })
}

Turn CRM milestones into separate conversion actions

A submitted lead, qualified lead, sales opportunity, and closed deal are different facts. Google recommends separate conversion actions for distinct funnel stages. That makes reporting understandable and lets the bidding strategy optimize toward the stage that actually represents value.

Lead milestone model
lead_submitted
  source: website form
  primary for bidding: usually no

lead_qualified
  source: CRM qualification decision
  primary for bidding: often yes

sale_closed
  source: signed contract or paid invoice
  primary for bidding: depends on volume and conversion lag

Create a separate Google Ads conversion action for each exported stage.
Choose one primary optimization stage deliberately.

Google’s offline conversion FAQ recommends separate conversion actions for separate funnel stages so reporting and bidding can distinguish them.

Make the CRM transition idempotent. A salesperson saving the same stage twice, a webhook retry, and a nightly reconciliation job must all resolve to the same milestone event and transaction ID.

CRM milestone transaction
await db.transaction(async (tx) => {
  const changed = await tx.lead.transition({
    leadId,
    from: 'contacted',
    to: 'qualified',
  })

  if (!changed) return

  const transactionId = 'qualified-lead:' + leadId

  await tx.conversionOutbox.insert({
    sourceEventId: transactionId,
    destination: 'google-qualified-lead',
    transactionId,
    leadId,
    occurredAt: new Date().toISOString(),
  })
})

Build the Data Manager event from the frozen snapshot

The conversion worker loads the milestone, frozen attribution, permitted user data, and consent snapshot. It maps them to a Google Ads destination whose productDestinationId is the UPLOAD_CLICKS conversion action. The event needs its real conversion timestamp and an eventSource that describes where the milestone occurred.

Data Manager request shape
{
  "destinations": [
    {
      "operatingAccount": {
        "accountType": "GOOGLE_ADS",
        "accountId": "1234567890"
      },
      "productDestinationId": "987654321"
    }
  ],
  "events": [
    {
      "eventTimestamp": "2026-09-04T18:42:10Z",
      "transactionId": "qualified-lead:lead_01K4M9",
      "eventSource": "WEB",
      "adIdentifiers": {
        "gclid": "PRESERVE_EXACT_CASE",
        "gbraid": "PRESERVE_EXACT_CASE",
        "wbraid": "PRESERVE_EXACT_CASE"
      },
      "userData": {
        "userIdentifiers": [
          {"emailAddress": "SHA256_HEX_OR_BASE64"},
          {"phoneNumber": "SHA256_HEX_OR_BASE64"}
        ]
      }
    }
  ],
  "consent": {
    "adUserData": "CONSENT_GRANTED",
    "adPersonalization": "CONSENT_GRANTED"
  },
  "encoding": "HEX",
  "validateOnly": true
}

The example uses WEB for a lead that began on the website. A qualification created from a phone call might use PHONE; a store purchase can use IN_STORE. Do not set every CRM event to WEB merely because the upload job itself runs in a web application.

Request-level consent applies to the batch

Do not batch records with different consent decisions under one request-level consent object. Group compatible events or use the precise per-event mechanism supported by your client and approved data policy.

The Data Manager send-events guide documents valid destinations, required identifiers, transaction IDs, event sources, session attributes, consent, and validate-only operation.

Add user-provided data without inventing a hash format

Enhanced conversions for leads can supplement click identifiers with first-party email and phone data when the approved consent state permits it. Format each identifier independently. Google supports hexadecimal or Base64 encoding, but the request encoding must match the values you send.

User-data formatting rules
email
  remove leading, trailing, and intermediate whitespace
  lowercase
  for gmail.com and googlemail.com only:
    remove dots before @
    remove + and the following local-part suffix
  SHA-256
  encode as HEX or Base64 to match the request encoding

phone
  normalize to E.164, including leading + and country code
  trim leading and trailing whitespace
  SHA-256
  encode as HEX or Base64 to match the request encoding

Use Google’s current Data Manager user-data formatting reference or its UserDataFormatter utility instead of reusing a UID2, Meta, or homegrown hash function.

Send each identifier as its own object

A UserData object contains separate UserIdentifier objects. Do not concatenate email and phone before hashing, and do not put two identifier fields in a single one-of object.

Keep the real conversion time

eventTimestamp is when the lead qualified or the deal closed, not when the worker uploaded it. Store the CRM transition as an RFC 3339 timestamp with an explicit offset or UTC Z suffix. A timezone-free database string can make a valid conversion appear to happen before the click.

Timestamp contract
click captured:      2026-08-12T15:04:10Z
lead submitted:      2026-08-12T15:11:42Z
lead qualified:      2026-08-19T20:32:18Z  <- eventTimestamp
API upload started:  2026-08-19T20:40:00Z  <- operational metadata only

Do not wait until the attribution window is almost closed. Google’s published support guidance says GCLIDs are retained for 90 days and user-provided-data conversions can be uploaded within 63 days. Upload new milestones at least daily and alert on the age of the oldest pending event.

Google also notes in its offline-import troubleshooting guide that processing normally takes less than 12 hours but can take as long as 72 hours for conversions keyed by GBRAID or WBRAID.

Use transaction IDs for deduplication

Within a conversion action, Data Manager uses transactionId to deduplicate conversion events received from multiple sources. Build it from an immutable CRM entity and milestone. Do not use the upload batch ID, current timestamp, salesperson ID, or a random UUID generated on every retry.

Stable milestone IDs
lead_01K4M9 + qualified -> qualified-lead:lead_01K4M9
lead_01K4M9 + closed    -> closed-sale:lead_01K4M9

retry 1 -> qualified-lead:lead_01K4M9
retry 2 -> qualified-lead:lead_01K4M9
replay  -> qualified-lead:lead_01K4M9

A qualified lead and a closed sale should have different conversion actions and transaction IDs because they are different business events. Two retries of the same qualified-lead event must keep the same values.

Treat ingestion and diagnostics as two phases

A successful events:ingest response returns a request ID. It does not prove that every destination finished processing the records. Persist that request ID, then poll request status until each destination reaches SUCCESS, PARTIAL_SUCCESS, or FAILURE.

Delivery state machine
pending
  -> validating       validateOnly request
  -> submitted        live ingest returned requestId
  -> processing       diagnostics not terminal
  -> delivered        SUCCESS
  -> partial          PARTIAL_SUCCESS; inspect error counts
  -> failed           FAILURE; classify and remediate

Never mark delivered at HTTP 200 alone.

The Data Manager diagnostics guide says diagnostics can take from roughly 30 minutes up to 24 hours and are unavailable for validateOnly requests. Production ingestion request IDs therefore need durable follow-up.

The API fast-fails a request when structural or required-field validation fails, while more complex processing findings appear asynchronously. Keep both paths visible. A healthy HTTP error rate can coexist with an unhealthy destination success rate.

Batch for throughput, isolate for recovery

Data Manager accepts up to 2,000 events and 10 user identifiers per event in an ingestion request. Batch enough records to avoid wasting quota, but keep each batch recoverable. Group by destination, encoding, consent state, and schema version so one request has one unambiguous contract.

Current Data Manager limits include 100,000 ingestion requests per project per day, 300 per minute, and 2,000 events per ingestion request.

Batch key
const batchKey = [
  event.destinationId,
  event.consent.adUserData,
  event.consent.adPersonalization,
  event.encoding,
  event.schemaVersion,
].join(':')

Protect credentials and attribution data

The Data Manager API uses OAuth and the datamanager scope, not a browser API key. Keep credentials server-side and prefer Application Default Credentials with service-account impersonation over downloaded long-lived service-account keys. Grant only the access needed for the intended Google Ads destination.

Never log click IDs, session attributes, email or phone values, their hashes, OAuth tokens, full request payloads, or unredacted API responses. Operational logs need the internal event ID, destination, batch ID, request ID, status, latency, and normalized error code.

Safe operational record
{
  "eventId": "qualified-lead:lead_01K4M9",
  "destination": "google-qualified-lead",
  "batchId": "batch_01K5A2",
  "requestId": "request_redacted_example",
  "identifierTypes": ["gclid", "email"],
  "status": "processing",
  "attempt": 1,
  "durationMs": 386
}

Measure the pipeline against the CRM

Do not evaluate the integration by looking only at Google Ads totals. Start with the CRM milestones that should have produced events, then follow each through eligibility, batching, ingestion, diagnostics, and eventual reporting. Compare by conversion time, not upload date.

Metrics
eligible_crm_milestones_total{stage}
click_id_capture_rate{type}
session_attributes_capture_rate
user_data_eligible_rate{type}
oldest_pending_event_age_seconds
ingest_fast_failure_rate{code}
diagnostic_terminal_rate{status}
diagnostic_error_total{reason}
braid_processing_age_hours
crm_to_google_reported_rate{stage,conversion_date}
crm_value_vs_google_value{stage,currency}

Unknown-click results are not automatically pipeline failures when you upload all eligible leads, including organic and non-Google leads. Alert on abrupt rate changes, expired identifiers, denied-consent errors, destination misconfiguration, and batches that never reach a terminal diagnostic state.

Test the long path, not only the API client

The valuable tests begin at a landing URL and finish at a diagnostic result. Verify that values survive redirects, consent changes, form submissions, CRM synchronization, stage updates, retries, and batch construction without case changes or duplication.

Required test matrix
[ ] GCLID-only landing
[ ] GBRAID-only landing
[ ] WBRAID-only landing
[ ] GCLID and GBRAID on the same landing
[ ] no click ID, but eligible session_attributes
[ ] approved user-provided data without a click ID
[ ] denied and unknown consent states
[ ] repeated landing with first-touch preservation
[ ] lead submission after navigation and a new session
[ ] repeated CRM milestone webhook
[ ] two workers claiming the same outbox row
[ ] validateOnly success followed by live ingestion
[ ] synchronous request rejection
[ ] asynchronous PARTIAL_SUCCESS and FAILURE
[ ] conversion approaching the upload-age threshold
[ ] braid-keyed conversion still processing after 12 hours

For the shared outbox, retry, and platform-adapter architecture, read Server-Side Conversion Tracking in Next.js. This guide narrows that system to the Google Ads CRM attribution path.

Production rollout checklist

Done when
[ ] GCLID, GBRAID, and WBRAID are allowlisted and case-preserved
[ ] Consent policy decides whether attribution is persisted
[ ] Session attributes are captured when supported
[ ] Attribution overwrite behavior is documented and versioned
[ ] Attribution is frozen into the lead during lead creation
[ ] Stored identifiers are encrypted and excluded from logs
[ ] Funnel stages map to separate conversion actions
[ ] One primary bidding stage is chosen deliberately
[ ] CRM transitions and outbox rows commit atomically
[ ] transactionId is stable across every retry
[ ] The destination owns the correct UPLOAD_CLICKS conversion action
[ ] EventSource describes where the milestone occurred
[ ] User data follows Data Manager formatting and encoding rules
[ ] Different consent states are not mixed under one batch policy
[ ] New implementations use Data Manager rather than legacy uploads
[ ] validateOnly is used before production writes
[ ] Every live requestId is followed through terminal diagnostics
[ ] Upload-age and processing-delay alerts are active
[ ] CRM truth is reconciled by conversion date

The operating model to keep

Capture the opaque attribution signals at the eligible landing, preserve their case, and bind them to a server-issued session. Freeze that snapshot when the lead is created. When the CRM records a qualified or closed milestone, create one immutable conversion event and deliver it through Data Manager with the correct destination, timestamp, transaction ID, event source, identifiers, user data, and consent.

Then follow the request beyond HTTP success. Diagnostics, age limits, case sensitivity, CRM idempotency, and conversion-action design determine whether a technically valid upload becomes useful measurement. When those contracts are explicit, Google Ads can optimize toward qualified demand instead of whichever forms happened to submit.

Google: Upgrade offline conversion importsGoogle: Set up offline conversions using GBRAIDGoogle Data Manager API: Send eventsGoogle Data Manager API: Format user dataGoogle Data Manager API: DiagnosticsGoogle Data Manager API: Limits and quotasGoogle Ads: Fix offline conversion discrepancies and errorsGoogle Ads API: Feature deprecations