Skip to content

serverTracker.trackConversion()

Uploads one click conversion to the Google Ads API. Resolves the label against your conversionActions map, attaches click identifiers and hashed userData if provided, and uses your transactionId as the dedup key for the matching browser-side call.

serverTracker.trackConversion(input: ServerConversionInput): Promise<ServerConversionResult>;
type ServerConversionInput = {
label: string;
value?: number;
currency?: string;
transactionId?: string;
gclid?: string;
gbraid?: string;
wbraid?: string;
userData?: UserData;
};

UserData is the same shape as on the browser side.

FieldRequiredNotes
labelyesA key from ads.conversionActions. Throws if missing from the map.
valuenoConversion value, in currency.
currencynoISO 4217 currency code. Required by Google when value is set.
transactionIdno — but always pass oneSame dedup key as the browser-side call. If omitted, auto-generated and logged.
gclidnoForwarded from the browser via getClickIdentifiers().
gbraidnoSame.
wbraidnoSame.
userDatanoIdentity fields. The SDK normalizes and hashes identically to the browser.
type ServerConversionResult = {
ads: SendResult;
};
type SendResult =
| { ok: true }
| { ok: false; error: Error };

Resolves after the Ads API call completes. Runtime failures — network errors, non-2xx responses, and OAuth refresh failures — are captured on result.ads.error rather than thrown. Configuration errors (missing ads, unknown label) still throw at call time. With debug: true, the same failures are also logged via console.warn.

  1. Looks up input.label in ads.conversionActions to get the conversionAction resource name.

  2. Fetches an Ads API access token via the OAuth refresh-token provider (cached until ~60 seconds before expiry).

  3. POSTs to https://googleads.googleapis.com/<apiVersion>/customers/<customerId>:uploadClickConversions with:

    {
    "conversions": [{
    "conversionAction": "customers/.../conversionActions/...",
    "conversionDateTime": "<RFC 3339 with timezone>",
    "orderId": "<transactionId>",
    "conversionValue": <value>,
    "currencyCode": "<currency>",
    "gclid": "<gclid>",
    "userIdentifiers": [
    { "hashedEmail": "..." },
    { "hashedPhoneNumber": "..." },
    { "addressInfo": { "hashedFirstName": "...", "hashedLastName": "...", "hashedStreetAddress": "...", "city": "...", "state": "...", "postalCode": "...", "countryCode": "..." } }
    ]
    }],
    "partialFailure": true
    }
  4. Headers include Authorization: Bearer <accessToken>, developer-token: <developerToken>, Content-Type: application/json, and optionally login-customer-id.

partialFailure: true means a 200 OK can still report per-conversion errors in the response body. With debug: true, the body is logged so you can see which conversions Google rejected.

Throws at call time if ads was not configured on createServerTracker:

Error: [trackbridge] trackConversion requires `ads` to be configured on createServerTracker

Throws if label is not in ads.conversionActions:

Error: [trackbridge] no conversionAction configured for label "foo" — add it to ads.conversionActions on createServerTracker

Always warns (regardless of debug) when transactionId is missing or empty:

[trackbridge] ⚠️ trackConversion called without transactionId
→ Auto-generated: tb_<uuid>
→ Dual-send disabled for this call. Pass a transactionId you control
to enable cross-side dedup.
→ See: trackbridge.dev/docs/dedup

Captures Ads API non-2xx into result.ads.error (also console.warn if debug: true):

[trackbridge] Ads API returned <status>: <response body>

Captures network errors and OAuth refresh failures into result.ads.error (also console.warn if debug: true):

[trackbridge] Ads API request failed: <error>

The OAuth refresh-token flow runs as part of the upload call; if Google rejects the refresh (e.g., a revoked refresh token), the failure surfaces as result.ads.error with a message like [trackbridge] OAuth refresh failed with 401 Unauthorized. It does not throw.

app/api/webhooks/stripe/route.ts
import 'server-only';
import { serverTracker } from '@/lib/tracker.server';
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
if (event.type !== 'checkout.session.completed') return new Response();
const order = await db.orders.findByStripeId(event.data.object.id);
const result = await serverTracker.trackConversion({
label: 'purchase',
value: order.total,
currency: order.currency,
transactionId: order.id,
gclid: order.gclid,
gbraid: order.gbraid,
wbraid: order.wbraid,
userData: {
email: order.customer.email,
phone: order.customer.phone,
firstName: order.customer.firstName,
lastName: order.customer.lastName,
address: {
street: order.billingAddress.street,
city: order.billingAddress.city,
region: order.billingAddress.region,
postalCode: order.billingAddress.postalCode,
country: order.billingAddress.country,
},
},
});
if (!result.ads.ok) reportError(result.ads.error);
return new Response();
}