Skip to content

Stripe webhook recipe

The server side of dual-send is most useful when it fires from an event that’s authoritative and reliable. Stripe webhooks are exactly that: when checkout.session.completed fires, the payment is real and the order is final. Wiring serverTracker.trackConversion into that handler is the canonical server-side fire.

This guide is the full recipe — endpoint, idempotency, click-ID forwarding, and the order-record schema you’ll want.

  • An order record stored in your database with the click identifiers captured at checkout time. Without click IDs, the server-side fire still counts the conversion but Google can’t attribute it to the original ad click.
  • Stripe webhook signing configured (you should already have this — webhook handlers without signature verification are exploitable).
  • The server tracker created with the ads config block, per Quick start and Setting up Google Ads OAuth.

Whatever your order table looks like, it needs columns for the click identifiers and the customer identity fields the conversion will use:

ALTER TABLE orders ADD COLUMN gclid TEXT;
ALTER TABLE orders ADD COLUMN gbraid TEXT;
ALTER TABLE orders ADD COLUMN wbraid TEXT;
-- already-present columns: id, total, currency, customer_email, customer_phone,
-- billing_street, billing_city, billing_region, billing_postal_code, billing_country, ...

Populate gclid / gbraid / wbraid when the user submits checkout — read them from the browser tracker:

app/checkout/page.tsx
'use client';
import { tracker } from '@/lib/tracker.client';
async function submitCheckout(orderInput: OrderInput) {
const clickIds = tracker.getClickIdentifiers();
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ ...orderInput, clickIds }),
});
return res.json();
}
app/api/checkout/route.ts
import 'server-only';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
export async function POST(req: Request) {
const { clickIds, ...input } = await req.json();
const order = await db.orders.create({
...input,
gclid: clickIds.gclid ?? null,
gbraid: clickIds.gbraid ?? null,
wbraid: clickIds.wbraid ?? null,
});
const session = await stripe.checkout.sessions.create({ /* ... */ });
await db.orders.update(order.id, { stripeSessionId: session.id });
return Response.json({ checkoutUrl: session.url });
}
app/api/webhooks/stripe/route.ts
import 'server-only';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { serverTracker } from '@/lib/tracker.server';
export async function POST(req: Request) {
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('missing signature', { status: 400 });
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('invalid signature', { status: 400 });
}
if (event.type !== 'checkout.session.completed') {
return new Response();
}
const session = event.data.object as Stripe.Checkout.Session;
const order = await db.orders.findByStripeSessionId(session.id);
if (!order) return new Response('unknown order', { status: 404 });
// Idempotency — Stripe may retry. Don't fire the conversion twice.
if (order.conversionFiredAt) return new Response();
await serverTracker.trackConversion({
label: 'purchase',
value: order.total / 100, // Stripe stores cents; Ads expects dollars
currency: order.currency.toUpperCase(),
transactionId: order.id, // ← shared with the browser-side fire
gclid: order.gclid ?? undefined,
gbraid: order.gbraid ?? undefined,
wbraid: order.wbraid ?? undefined,
userData: {
email: order.customerEmail,
phone: order.customerPhone,
firstName: order.customerFirstName,
lastName: order.customerLastName,
address: {
street: order.billingStreet,
city: order.billingCity,
region: order.billingRegion,
postalCode: order.billingPostalCode,
country: order.billingCountry,
},
},
});
await db.orders.update(order.id, { conversionFiredAt: new Date() });
return new Response();
}

Stripe retries webhooks. Your handler will run more than once for the same checkout.session.completed event in normal operation — that’s how Stripe’s at-least-once delivery works.

If you fire serverTracker.trackConversion on every retry, Google receives multiple uploads with the same transactionId. Per the Ads API dedup behavior, Google deduplicates them — so volume is correct — but every retry consumes API quota and shows up as a rejected partial-failure in your debug logs.

The simplest defense: a conversionFiredAt timestamp on the order. Set it after the call succeeds; check it before the call. Now retries are no-ops.

If you can’t add a column (legacy schema, etc.), use the Stripe event.id as a request-deduplication key in a separate table.

This recipe is the server side of dual-send. The browser side still needs to fire on the success page after Stripe redirects the user back:

app/checkout/success/page.tsx
'use client';
import { useEffect } from 'react';
import { tracker } from '@/lib/tracker.client';
export default function SuccessPage({ searchParams }: { searchParams: { order: string } }) {
useEffect(() => {
fetch(`/api/orders/${searchParams.order}`)
.then((res) => res.json())
.then((order) => {
tracker.trackConversion({
label: 'purchase',
value: order.total / 100,
currency: order.currency.toUpperCase(),
transactionId: order.id, // ← same as the webhook will use
userData: { email: order.customerEmail /* ... */ },
});
});
}, [searchParams.order]);
return <h1>Thanks for your order</h1>;
}

Both sides fire, both use the same transactionId, Google dedupes. If the user has an ad blocker, the browser fire is dropped — the webhook still went through. If the webhook is delayed by a Stripe outage, the browser fire still went through. Either case: one conversion, correctly attributed.

  • Subscriptions instead of one-shot checkout. Use customer.subscription.created (first signup) and/or invoice.payment_succeeded (recurring) instead. Same shape, different transactionId semantics — subscription.id for the first signup, invoice.id for recurring.
  • Stripe Payment Element (no Checkout redirect). The browser-side fire happens in your custom success handler; the server-side fire still happens in the payment_intent.succeeded webhook. The transactionId should be the order ID you assigned at intent creation, not the Stripe payment intent ID.
  • Refunds. serverTracker.trackConversion doesn’t model refunds. If you need refund-driven conversion adjustments in Ads, that’s a separate conversionAdjustment API call that Trackbridge does not currently wrap.