The order exists once in the database. Meta reports it twice. Google reports it on a different day. The browser says the purchase happened before the payment provider confirmed it, while the server retried the same payload three times after a timeout. Every dashboard is technically receiving events, and none of them agrees with the business.
Server-side conversion tracking is often presented as a fetch call from a Next.js Route Handler. That is the least important part. A production implementation needs one authoritative event, a stable identity, a consent snapshot, durable delivery, platform-specific transformation, idempotent retries, and reconciliation against orders or qualified leads.
This guide builds that system for Meta Conversions API and Google Ads. The two destinations share an internal event contract, but they do not share a payload contract. Meta commonly deduplicates a browser Pixel event and a server CAPI event. Google separates website conversions enhanced after a tag event from later lead outcomes sent through the Data Manager API.
Moving a request off the browser does not create permission to collect, enrich, or send it. Resolve consent before placing customer identifiers or advertising cookies in an outbox, and retain the exact consent state used for every destination decision.
Choose the event that represents business truth
A button click is not a purchase. A form submission is not a qualified lead. Start by naming the database transition that creates the conversion. For commerce, that might be a payment-confirmed order. For lead generation, it might be a CRM status change to sales-qualified. Everything else is an earlier funnel event with a different name and value.
type ConsentState = 'granted' | 'denied' | 'unknown'
type ConversionEvent = {
id: string
schemaVersion: 1
kind: 'purchase' | 'qualified_lead'
occurredAt: string
source: 'checkout' | 'crm'
orderId?: string
leadId?: string
value?: number
currency?: string
pageUrl?: string
clickIds: {
gclid?: string
gbraid?: string
wbraid?: string
fbclid?: string
}
metaCookies: {
fbp?: string
fbc?: string
}
identity: {
email?: string
phoneE164?: string
}
consent: {
adStorage: ConsentState
adUserData: ConsentState
adPersonalization: ConsentState
capturedAt: string
source: string
}
}Generate the event ID when the business event is committed, not when a worker attempts delivery. A good ID is stable across retries and environments while remaining unique to the real event. An order-derived UUID or a namespaced order ID works better than Date.now().
Do not leave raw identity in an unrestricted JSON queue. Encrypt the payload with a managed application key or store only a reference to a protected customer record and resolve permitted fields at dispatch time. Apply strict access control, a short retention window, and deletion propagation.
purchase event ID: purchase:order_01K4M7Y9A4
Meta browser event_id: purchase:order_01K4M7Y9A4
Meta server event_id: purchase:order_01K4M7Y9A4
Google transaction ID: order_01K4M7Y9A4
retry attempt 1, 2, 3: identifiers never changeWrite the conversion and outbox in one transaction
Do not call an advertising API inside the checkout transaction or immediately after returning success. A platform timeout should not roll back an order, and a process crash after the order commits should not erase the conversion. Store the event in a transactional outbox beside the business record.
create table conversion_outbox (
id uuid primary key,
source_event_id text not null,
destination text not null,
payload jsonb not null,
status text not null default 'pending',
attempt_count integer not null default 0,
next_attempt_at timestamptz not null default now(),
claimed_at timestamptz,
delivered_at timestamptz,
platform_receipt jsonb,
last_error_code text,
created_at timestamptz not null default now(),
unique (source_event_id, destination)
);
create index conversion_outbox_ready
on conversion_outbox (next_attempt_at)
where status in ('pending', 'retry');'use server'
import {randomUUID} from 'node:crypto'
import {db} from '@/lib/db'
export async function completeOrder(input: CheckoutResult) {
return db.transaction(async (tx) => {
const order = await tx.order.confirmFromPayment(input.paymentId)
const eventId = 'purchase:' + order.id
const event = buildPurchaseEvent(order, eventId)
await tx.conversionOutbox.insertMany([
{
id: randomUUID(),
sourceEventId: eventId,
destination: 'meta',
payload: event,
},
{
id: randomUUID(),
sourceEventId: eventId,
destination: 'google-web-enhancement',
payload: event,
},
])
return {
orderId: order.id,
browserTracking: {
metaEventName: 'Purchase',
metaEventId: eventId,
},
}
})
}Why Next.js after is not the durable queue
Next.js after is useful for work that can happen after the response, including logging and analytics. It does not replace an outbox record for revenue events. A durable queue must survive process termination, retry later, enforce uniqueness, and expose delivery state independently of one request execution.
The Next.js after reference explains when callbacks run in Server Functions and Route Handlers. Use it to notify a worker that an outbox row exists if convenient; keep the row as the recovery mechanism.
Deduplicate Meta browser and server events
For a purchase that is intentionally reported through both the Meta Pixel and Conversions API, generate the event ID on the server and return it with the checkout result. The browser event and server event must use the same Meta event name and the same event ID. Do not generate an unrelated UUID in each layer.
declare global {
interface Window {
fbq?: (...args: unknown[]) => void
}
}
export function reportPurchaseToMeta(input: {
eventId: string
value: number
currency: string
}) {
window.fbq?.(
'track',
'Purchase',
{value: input.value, currency: input.currency},
{eventID: input.eventId},
)
}const metaEvent = {
event_name: 'Purchase',
event_time: Math.floor(Date.parse(event.occurredAt) / 1000),
event_id: event.id,
action_source: 'website',
event_source_url: event.pageUrl,
user_data: permittedMetaUserData(event),
custom_data: {
order_id: event.orderId,
value: event.value,
currency: event.currency,
},
}Meta’s official Node.js Business SDK provides Conversions API event classes and, in current releases, an optional parameter builder that can extract permitted request context and normalize and hash customer information.
A matching Meta event ID protects the browser/server pair. Your own unique outbox key protects worker races and repeated jobs. Keep both layers: platform deduplication is not a substitute for an idempotent producer.
Route Google events by conversion type
Google Enhanced Conversions is not one universal server endpoint. For an online purchase, the Google tag records the website conversion and first-party data can enhance that conversion through a supported tag configuration or a Google Ads API enhancement tied to the original conversion. For a lead that becomes qualified later in a CRM, use the current offline or enhanced-conversions-for-leads route.
Business event Base measurement Server-side route
website purchase Google tag + transaction ID enhanced conversion for web
form submitted on website Google tag / lead event capture first-party match inputs
lead qualified later in CRM CRM status transition Data Manager API event
sale closed after qualification CRM sale transition Data Manager API event
refund or corrected value original conversion ID supported conversion adjustmentThe current Google Ads documentation separates online enhanced conversions for web from offline conversions and enhanced conversions for leads. An online API enhancement references a website conversion already recorded by the tag, commonly through its transaction or order ID.
A queued web enhancement must respect that dependency. Delay or retry the enhancement until the tag-recorded conversion can be matched, and keep the original order ID unchanged. Do not turn a temporary not-yet-visible conversion into a second base conversion.
For new lead pipelines, design around the Data Manager API. Since June 15, 2026, developer tokens without prior offline-conversion activity are restricted from using the legacy Google Ads API UploadClickConversions route, and Google directs those workflows to Data Manager instead.
Google records that change in its feature deprecations and documents the current event request model in the Data Manager API.
const googleLeadEvent = {
eventName: 'qualified_lead',
eventTimestamp: event.occurredAt,
transactionId: event.leadId,
eventSource: 'EVENT_SOURCE_CRM',
adIdentifiers: compact({
gclid: event.clickIds.gclid,
gbraid: event.clickIds.gbraid,
wbraid: event.clickIds.wbraid,
}),
userData: buildGoogleUserData(event),
consent: {
adUserData: toGoogleConsent(event.consent.adUserData),
adPersonalization: toGoogleConsent(
event.consent.adPersonalization,
),
},
}The Data Manager send-events guide documents destinations, event sources, identifiers, consent, encoding, validate-only requests, and user-data formatting.
Do not share one hashing function across platforms
Email and phone hashing looks similar across advertising systems, but the canonical inputs and output encodings can differ. Google expects normalized identifiers hashed with SHA-256 and represented as lowercase hexadecimal. Meta’s current SDK can normalize and hash supported customer information. UID2 hashed inputs use Base64-encoded digest bytes. One generic hashIdentifier function is an attractive way to lower every match rate at once.
import {createHash} from 'node:crypto'
function sha256Hex(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('hex')
}
export function normalizeGoogleEmail(input: string): string {
const value = input.trim().toLowerCase()
const at = value.lastIndexOf('@')
if (at <= 0 || at === value.length - 1) {
throw new Error('Invalid email address')
}
let local = value.slice(0, at)
const domain = value.slice(at + 1)
if (domain === 'gmail.com' || domain === 'googlemail.com') {
local = local.split('+', 1)[0].replaceAll('.', '')
}
return local + '@' + domain
}
export function hashGoogleEmail(input: string): string {
return sha256Hex(normalizeGoogleEmail(input))
}
export function hashGooglePhone(phoneE164: string): string {
const compact = phoneE164.replaceAll(/\s/g, '')
if (!/^\+[1-9]\d{1,14}$/.test(compact)) {
throw new Error('Phone must be valid E.164')
}
return sha256Hex(compact)
}Google Ads email output: SHA-256 as lowercase hex
Google Ads phone input: valid E.164, including leading +
Meta phone normalization: use current SDK / parameter builder
Meta browser-server dedupe: same event_name + event_id
UID2 hashed input: SHA-256 digest bytes encoded as Base64
Never feed one platform's finished hash into another platform's normalizer.For the separate UID2 contract, see UID2 for DSP Retargeting: Email and Phone Normalization. The same source identity may legitimately produce different transport values for different protocols.
Capture click IDs without trusting the browser
At the landing boundary, allowlist the attribution parameters you support: gclid, gbraid, wbraid, and fbclid. Associate them with a server-side session or lead record under your retention and consent policy. Do not persist the entire query string, and do not let a later browser request replace a known value without an explicit attribution rule.
Treat click IDs and first-party advertising cookies as untrusted inputs. Validate length and character shape, record their source and capture time, and never use them for authorization. For Meta fbc and fbp context, prefer the official SDK parameter builder or documented construction rules rather than inventing a cookie format.
const CLICK_ID = /^[A-Za-z0-9._~-]{1,512}$/
export function optionalClickId(value: string | null) {
if (!value) return undefined
return CLICK_ID.test(value) ? value : undefined
}
export function captureAttribution(url: URL) {
return {
gclid: optionalClickId(url.searchParams.get('gclid')),
gbraid: optionalClickId(url.searchParams.get('gbraid')),
wbraid: optionalClickId(url.searchParams.get('wbraid')),
fbclid: optionalClickId(url.searchParams.get('fbclid')),
capturedAt: new Date().toISOString(),
}
}Make consent a routing input
Consent should be a typed field evaluated by each adapter. Unknown is not granted. If ad user data is denied, do not attach hashed email or phone to a Google enhanced conversion. Google’s consent mode explicitly separates ad_storage, ad_user_data, ad_personalization, and analytics_storage.
Google’s consent mode overview states that ad_user_data denied disables personal-data collection for online advertising, including hashed first-party data used by Enhanced Conversions.
function permittedGoogleUserData(event: ConversionEvent) {
if (event.consent.adUserData !== 'granted') return undefined
return compact({
hashedEmail: event.identity.email
? hashGoogleEmail(event.identity.email)
: undefined,
hashedPhone: event.identity.phoneE164
? hashGooglePhone(event.identity.phoneE164)
: undefined,
})
}
function maySendMetaCustomerInfo(event: ConversionEvent) {
return event.consent.adUserData === 'granted'
}
If you use server-side Google Tag Manager, the web container still collects the consent choice and sends the consent parameters to the server container. Server-side processing does not eliminate the client-side consent transition or the need to configure each destination tag correctly.
Google’s server-side Tag Manager consent guide describes the web-container, server-container, and consent-aware tag responsibilities.
Retry deliveries without multiplying conversions
A worker should claim ready rows with a database lock, send a bounded batch, classify the result, and store a sanitized receipt. Retry timeouts, rate limits, and transient server failures with exponential backoff and jitter. Quarantine authentication failures, schema errors, invalid identifiers, and expired events for review instead of retrying them forever.
pending
-> claimed
-> delivered
-> retry (timeout, 429, transient 5xx)
-> dead (invalid payload, auth, policy rejection)
retry delay = min(cap, base * 2^attempt) + jitter
invariants:
one row per source_event_id + destination
same platform dedupe key on every attempt
no access tokens or customer identifiers in receipts
stale claims are recoverable after a bounded leaseexport async function deliver(row: OutboxRow) {
const adapter = adapters[row.destination]
try {
const receipt = await adapter.send(row.payload, {
idempotencyKey: row.sourceEventId,
})
await markDelivered(row.id, sanitizeReceipt(receipt))
} catch (error) {
const failure = adapter.classify(error)
if (failure.retryable) {
await scheduleRetry(row.id, failure.code)
} else {
await moveToDeadLetter(row.id, failure.code)
}
}
}Keep secrets and identifiers out of observability
A delivery log needs the internal event ID, destination, attempt, duration, HTTP class, platform error code, and final state. It does not need an email, phone number, hash, click ID, cookie value, access token, complete payload, or complete response body.
{
"message": "conversion delivery completed",
"eventId": "purchase:order_01K4M7Y9A4",
"destination": "meta",
"attempt": 2,
"durationMs": 241,
"httpStatusClass": "2xx",
"acceptedCount": 1,
"state": "delivered"
}access token
raw email or phone
hashed email or phone
fbc, fbp, gclid, gbraid, wbraid, or fbclid
client IP address or full user agent by default
full outbound request
full platform response
unredacted exception request configurationReconcile delivery, attribution, and revenue separately
An HTTP 200 proves that a platform accepted a request, not that it attributed the conversion to an ad or counted it in the reporting view you expect. Track three ledgers: business events created, destination deliveries accepted, and platform conversions later reported. Compare them by event date and platform reporting date without pretending they are the same clock.
business_conversions_total{kind}
outbox_age_seconds{destination}
delivery_acceptance_rate{destination}
delivery_retry_rate{destination,error_code}
dead_letter_total{destination,error_code}
meta_browser_server_dedupe_rate
google_enhancement_diagnostics_rate
consent_eligible_rate{destination}
identifier_presence_rate{type,destination}
platform_attributed_rate{destination,event_date}
business_value_vs_platform_value{destination,currency}Use platform diagnostic tools before comparing attribution. Meta Events Manager can reveal missing or poorly deduplicated server events. Google provides enhanced-conversion and offline-data diagnostics. Once transport quality is healthy, differences from business totals can be analyzed as consent coverage, match coverage, attribution windows, reporting delay, and campaign eligibility.
Test the system at boundaries
Unit tests should cover event IDs, currency and value validation, Google normalization vectors, platform routing, and every consent state. Integration tests should terminate a worker after the external request but before the local receipt, run two workers against the same row, simulate a 429, and replay the same order several times.
it('keeps one Meta event ID across browser and server', () => {
const event = purchaseEvent({orderId: 'order_123'})
expect(toMetaBrowser(event).eventID).toBe(event.id)
expect(toMetaServer(event).event_id).toBe(event.id)
})
it('does not send Google user data when consent is denied', () => {
const event = purchaseEvent({adUserData: 'denied'})
expect(permittedGoogleUserData(event)).toBeUndefined()
})
it('creates one destination row when the job is replayed', async () => {
await enqueuePurchase('order_123')
await enqueuePurchase('order_123')
expect(await outboxCount('purchase:order_123', 'meta')).toBe(1)
})Use platform validation modes before production writes
Send test traffic to Meta’s event-testing workflow and use validateOnly for supported Google Data Manager requests. Verify timestamps, event names, currency, values, consent, identifiers, and error handling. Then enable a small production slice and compare it to the order or CRM ledger before increasing traffic.
Production rollout checklist
[ ] The database transition that defines each conversion is documented
[ ] Event IDs are generated once and remain stable across retries
[ ] Business record and outbox rows commit in one transaction
[ ] Unique source-event and destination keys prevent duplicate jobs
[ ] Meta browser and server events share event_name and event_id
[ ] Google website and qualified-lead flows use the correct route
[ ] New Google lead integrations use the Data Manager API
[ ] Google and Meta normalization remain separate adapters
[ ] Consent is captured, stored, and evaluated per destination
[ ] Unknown consent never becomes granted implicitly
[ ] Click IDs are allowlisted, time-stamped, and treated as untrusted
[ ] Secrets, customer data, hashes, and click IDs are absent from logs
[ ] Retries preserve platform deduplication keys
[ ] Permanent failures enter a reviewable dead-letter state
[ ] Test and validation modes pass before production traffic
[ ] Accepted deliveries are reconciled to platform reporting and business truthThe design to keep
Your Next.js application should create a conversion once, in the same transaction that creates the business fact. A durable outbox then gives each destination its own delivery state. Meta receives a server event that can be deduplicated against the browser event. Google receives the correct online enhancement or later lead event for that conversion type. Consent decides which fields and routes are allowed.
That architecture survives ad blockers, timeouts, worker crashes, API changes, and dashboard delays without turning one order into two. More importantly, it gives the company a measurement system it can explain: business truth first, controlled delivery second, platform attribution third.
