Skip to content

The context envelope

Some conversions don’t fire when the user is on the page. A Stripe webhook arrives ten minutes after checkout. A subscription activates when payroll runs. A refund processes overnight. By the time you fire the server-side conversion, the browser is gone — and with it the GA4 client ID, the captured click identifiers, the consent state, and any userData you wanted to pass through.

The context envelope is the SDK’s answer: capture all of that on the browser as a plain JSON object, persist it (database row, hidden form field, x-trackbridge-context request header), and hydrate it on the server later via serverTracker.fromContext(envelope).

type TrackbridgeContext = {
v: 1;
createdAt: number; // Unix ms
clientId?: string; // GA4 _ga cookie
sessionId?: string; // GA4 _ga_<containerId> cookie
userId?: string; // set if you called identifyUser()
clickIds: { gclid?: string; gbraid?: string; wbraid?: string };
consent: ConsentState; // snapshot at export time
userData?: UserData; // pass-through, pre-normalize / pre-hash
};

Every field except clickIds, consent, v, and createdAt is optional. The shape is deliberately small — it round-trips through JSON.stringify losslessly with no pluggable serializers, no class instances, no functions.

The v: 1 field is a wire-format version. serverTracker.fromContext throws synchronously if it sees an unknown v — envelopes are opaque payloads, not user-editable, so a version mismatch is a programming error worth surfacing immediately.

import { tracker } from '@/lib/tracker.client';
const envelope = tracker.exportContext({
userData: {
email: order.customer.email,
phone: order.customer.phone,
},
});
// Send to the server however you like:
await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({ items: cart, trackbridgeContext: envelope }),
});

exportContext reads the tracker’s current state (getClickIdentifiers, getClientId, getSessionId, getConsent, plus the most recent identifyUser value) and packages it. The optional userData argument is included verbatim — pre-normalize, pre-hash. The SDK normalizes and hashes when the envelope eventually reaches trackConversion on the server, identically to the direct path.

The envelope is a snapshot at one moment. If consent changes after export, the envelope you’ve already persisted does not update.

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 envelope = order.trackbridgeContext; // persisted at checkout time
const bound = serverTracker.fromContext(envelope);
const result = await bound.trackPurchase({
transactionId: order.id,
value: order.total,
currency: order.currency,
items: order.items,
// clientId, gclid, gbraid, wbraid, userData all come from the envelope
});
if (!result.ads.ok && !('skipped' in result.ads)) reportError(result.ads.error);
return new Response();
}

fromContext(envelope) returns a ContextBoundServerTracker with the same five helpers (trackPurchase, trackBeginCheckout, trackAddToCart, trackSignUp, trackRefund) plus trackConversion and trackEvent. On the bound tracker, clientId and the click identifiers default to the envelope’s values — you only need to pass them per-call when you want to override.

Override rule. When both the envelope and the per-call input supply the same field, the per-call value wins. There is no deep merge. This matters most for userData: passing userData per-call replaces the envelope’s userData entirely; partial overrides require the caller to spread.

Reach for the envelope when:

  • The conversion fires meaningfully later than the user’s session (webhook, queue worker, cron).
  • The conversion fires from a different process than the request handler that received the order (queue consumer in another deployment).
  • You want to centralize identity capture in one place on the browser instead of threading gclid / clientId / userData through every order-related API call.

You don’t need it when:

  • The server-side conversion fires inline with the HTTP request that created the order — the request can carry clientId and gclid as discrete fields, no envelope needed.
  • You only fire from the browser (Consent Mode v2 may make this unwise, but it’s a valid choice).

The v field is a contract: any change to the envelope shape that breaks the previous reader bumps v to 2, and fromContext rejects v: 1 envelopes with a clear error. Persisted envelopes that survive across an SDK upgrade either still parse (no v change) or fail loudly (deliberate v bump).

If you persist envelopes for long periods (24 hours+), include the SDK version that produced them in your row alongside the envelope itself. It makes the rare “we upgraded the SDK and now old envelopes throw” debugging much faster.

The envelope contains identifiers (clientId, clickIds) and — if you supply it — PII (userData). It is not encrypted by the SDK. Treat it the same way you treat any other PII-bearing field on the order row:

  • Encrypt at rest if your database is encrypted at rest.
  • Don’t log the envelope verbatim in application logs (debug: true in development is fine; production logs should redact).
  • Honor user deletion requests by removing the envelope alongside the order row.

The hashed PII Google receives is derived from this envelope at fire time, not at export time. If a user requests deletion before the conversion fires, deleting the envelope prevents the hashed PII from ever reaching Google.