Skip to content

Delayed conversions with the envelope

Some conversions don’t fire when the user is on the page:

  • A Stripe webhook arrives 10 minutes after checkout.
  • A subscription activates after the first successful payroll run.
  • A free-trial conversion completes when the trial expires unused.
  • A queue worker processes orders in batches.

By the time the conversion fires, the browser is gone. The GA4 _ga cookie, the captured gclid, the consent state, the userData you wanted to pass — all of it lived on the client, none of it is on the server.

The context envelope solves this in three steps: capture state on the browser into a serializable JSON envelope, persist it on the order row at checkout time, hydrate it on the server later.

When the user submits the checkout form, call tracker.exportContext() and send the result with the rest of the order payload. Include any userData the consumer typed into the form.

app/checkout/_actions.ts
'use client';
import { tracker } from '@/lib/tracker.client';
export async function placeOrder(formData: CheckoutFormData) {
const trackbridgeContext = tracker.exportContext({
userData: {
email: formData.email,
phone: formData.phone,
},
});
const order = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cart: formData.cart,
customer: formData.customer,
trackbridgeContext, // ← the envelope
}),
}).then((r) => r.json());
return order;
}

The envelope is plain JSON — no class instances, no functions. It survives JSON.stringify/JSON.parse losslessly.

When the order is created, store the envelope alongside the order. A JSONB column in Postgres works fine. The shape is TrackbridgeContext (see TrackbridgeContext).

app/api/orders/route.ts
export async function POST(req: Request) {
const { cart, customer, trackbridgeContext } = await req.json();
const order = await db.orders.create({
data: {
customerId: customer.id,
items: cart.items,
total: cart.total,
currency: cart.currency,
stripeSessionId: null,
trackbridgeContext, // JSONB column
},
});
// Hand off to Stripe / your payment provider.
const session = await stripe.checkout.sessions.create({ /* ... */ });
await db.orders.update({
where: { id: order.id },
data: { stripeSessionId: session.id },
});
return Response.json({ checkoutUrl: session.url });
}

Treat the envelope as PII. It contains identifiers (clientId, clickIds) and — in this recipe — the customer’s email and phone. Encrypt at rest if your DB encrypts at rest; honor user-deletion requests by removing the envelope alongside the order row.

When the Stripe webhook fires checkout.session.completed, look up the order, hydrate its envelope, and fire the conversion:

app/api/webhooks/stripe/route.ts
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.findUnique({
where: { stripeSessionId: event.data.object.id },
});
if (order === null) return new Response('order not found', { status: 404 });
const bound = serverTracker.fromContext(order.trackbridgeContext);
const result = await bound.trackPurchase({
transactionId: order.id,
value: order.total,
currency: order.currency,
items: order.items,
// clientId, gclid, userData all default from the envelope
});
if (!result.ads.ok && !('skipped' in result.ads)) reportError(result.ads.error);
if (!result.ga4.ok && !('skipped' in result.ga4)) reportError(result.ga4.error);
return new Response();
}

fromContext(envelope) returns a ContextBoundServerTracker with clientId, click identifiers, userId, and userData already filled in from the envelope. You only need to pass per-call values when overriding.

The bound tracker merges per-call input over envelope-derived defaults:

  • clientId, userId: per-call value wins, otherwise envelope’s.
  • gclid / gbraid / wbraid: per-call value wins per field; envelope fills in the rest.
  • userData: per-call replaces envelope’s entirely. There is no deep merge. To override one field, spread:
    userData: { ...order.trackbridgeContext.userData, email: order.customer.updatedEmail }
  • Other fields (transactionId, value, currency, items): per-call only.

This recipe assumes the conversion only fires server-side. If you want true dual-send (also fire from the success page when the user lands on it), call tracker.trackPurchase on the success page with the same transactionId as the server-side call. Google dedupes; the server call is the floor (catches webhooks the user never saw), the browser call is the ceiling (catches sessions where the webhook is delayed or fails).

app/checkout/success/page.tsx
'use client';
import { useEffect } from 'react';
import { tracker } from '@/lib/tracker.client';
export function FirePurchaseClient({ order }: { order: Order }) {
useEffect(() => {
tracker.trackPurchase({
transactionId: order.id, // ← MUST match the server-side call
value: order.total,
currency: order.currency,
items: order.items,
userData: { email: order.customer.email },
});
}, [order]);
return null;
}

Both fires use order.id. Google sees one Ads conversion, dedupes correctly.

TrackbridgeContext carries a v: 1 field. Persisted envelopes survive across SDK upgrades when v is unchanged; a deliberate v bump in a future release causes fromContext to throw on old envelopes with a clear error.

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

  1. Place a test order locally with debug: true on both trackers. Confirm tracker.exportContext() returns an envelope with clickIds, consent, and userData.
  2. Inspect the persisted JSONB column — it should match the envelope you exported.
  3. Trigger the webhook (Stripe CLI, or hand-curl your endpoint). Confirm serverTracker.trackPurchase returns { ads: { ok: true }, ga4: { ok: true } } (or ads: { skipped: 'no_label_configured' } if you’re testing without an Ads label).
  4. Wait up to 24 hours, then check the conversion in the Ads UI — it should appear once, not twice (assuming you also fire from the browser with the same transactionId).