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.
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.
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.
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.
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})
}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.
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 tableDo 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.
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.
'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_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.
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.
{
"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.
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.
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 encodingUse Google’s current Data Manager user-data formatting reference or its UserDataFormatter utility instead of reusing a UID2, Meta, or homegrown hash function.
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.
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 onlyDo 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.
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_01K4M9A 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.
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.
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.
{
"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.
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.
[ ] 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 hoursFor 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
[ ] 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 dateThe 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.
