BuildBaseBuildBase

Server-side conversions

Report the sales a browser never saw - a closed tab, an ad blocker, a renewal, a refund - from our servers or from yours, under the same event id the browser used.

The browser reports a sale only if it is still open when Stripe sends the customer back. Someone who pays and closes the tab is never counted, an ad blocker drops the hit, and a renewal or a refund happens with no browser anywhere. The fix for all of them is a send from a server.

There are two ways to get one. Turn it on in the console and BuildBase sends it for you, or take the same data off your webhooks and send it yourself. They use the same event id, so either one deduplicates against the browser.

What the browser hands over

When a checkout starts, the SDK stamps the Stripe session with everything a later server event needs and cannot get: the event id the browser events carry, the click ids and UTM values from the landing page, Meta's _fbp and _fbc, GA4's client and session ids, the user agent, the page, which app it was, and what the visitor answered about consent. Stripe copies it onto the subscription and its invoices, which is how a renewal a year later still knows which ad it came from.

The buyer's IP address is added by the server at that moment, and only for a visitor who granted marketing consent.

Let BuildBase send it

Under Settings › Tracking & Tags, edit a Meta Pixel or Google Analytics 4 entry and turn on Server-side conversions.

ProviderCredentialWhere to find it
MetaConversions API access tokenEvents Manager › your pixel › Settings › Conversions API
GA4Measurement Protocol API secretAdmin › Data streams › your stream › Measurement Protocol API secrets

The credential is encrypted at rest and never returned by any read - the console shows a preview of the stored one, and an empty field on save keeps it. The entry has to be attached to the app under Auth › Clients like any other tag: a sale made in one app goes to that app's destinations and nobody else's.

What happenedMetaGA4
First payment, or a credit packPurchasepurchase
A trial started, card or notStartTrialnot sent - the browser reported it
The first charge when a trial endsPurchase, system_generatedpurchase
A plan change charged a prorationPurchase, system_generatedpurchase
Renewal, if Send renewals is onPurchase, system_generatedpurchase
Refundnot sent - Meta has no such callrefund, against the same transaction_id

The first charge when a trial ends is the sale the trial was for, and it is sent without anyone turning renewals on. Stripe files that invoice exactly like a renewal, so on a product that starts everyone on a trial, treating it as one would keep every sale from every ad platform. An upgrade is sent without renewals on too. A renewal is revenue with no decision behind it; a plan change is a decision the customer just made, and hiding expansion revenue behind the switch that exists to keep renewals out would be the wrong default.

A first payment carries the browser's event id, and a renewal or a refund takes an id of its own because there is no browser event to match.

The two vendors are treated differently here, because they document different things.

Meta asks for both. Its pixel and its Conversions API deduplicate on a shared event_id with matching event names, inside 48 hours, and Events Manager shows the result as one event from two sources. So a purchase goes from the browser and from here, and Meta keeps one.

GA4 does not say. Google documents that it deduplicates purchases with the same transaction ID "from the same user" on a web stream - and says nothing at all about the Measurement Protocol, gives no window, and has no event_id to deduplicate on. Its own guidance is that the protocol augments the tag rather than repeating it. So we do not send both and hope: your browser tells us when it has reported a purchase, and we send only the ones it did not. Where the browser never got there - a closed tab, a blocked tag - the conversion still arrives, about ten minutes later.

Note

This is why a purchase can take a few minutes to appear in GA4 from the server, while Meta has it at once. The wait is what makes it safe to send at all. A refund, a renewal and an upgrade have no browser counterpart and go immediately.

Email and user id are SHA-256 hashed before they leave. _fbp, _fbc, the IP and the user agent are sent as they are, which is what Meta specifies. Amounts are converted per currency: 2900 is twenty-nine dollars and two thousand nine hundred yen.

Every amount is read from the completed Stripe object, so a promotion code the buyer typed on Stripe's page is already in it.

properties and enrich shape what the browser sends. A server-side conversion is built from the Stripe object instead, so to carry your own data through to one, put it on the checkout: anything you pass in stripeOptions.metadata is on the session, on the subscription and on every invoice, and comes back to you on the webhooks.

When nothing is sent

Every decision is written to the Server-side conversions log on the same screen, skips included. A sale missing from an ad account is usually one of these:

Reason in the logWhat it means
marketing consent refusedThe buyer said no. Meta is never sent a refusal.
analytics consent refusedThe same, for GA4.
consent unknownThe buyer was never asked - an API checkout, or an app with no banner. See below.
no GA4 client id for this buyerGA4 never loaded in their browser. A made-up id would create a user who belongs to nobody.
the browser reported this one to GA4Your page already sent it. GA4 has no reliable deduplication for a server hit, so we do not send a second.
renewals are off for this destinationThe default. An ad platform told about every monthly charge optimises toward people who would have renewed anyway.
older than the 72 hours GA4 acceptsGA4 drops a backdated hit silently; Meta rejects the whole batch past 7 days. We do not send either.

Consent. A buyer who refused is never sent, to anyone. One who was never asked is skipped by default, because no answer is not a yes. If your app runs tracking={{ consent: 'granted' }} that is an answer and it is carried. If you have a lawful basis with no banner at all, turn on Send when the buyer was never asked for that destination. GA4 is also told what the buyer said about ads, through ad_user_data and ad_personalization.

Failures. A 429 or a 5xx is retried five times with backoff, inside both vendors' time limits. A 4xx is not - a wrong token is the same request the second time - and the vendor's answer is in the log, with the credential removed if it quoted it back.

Test before you trust it

Send a test conversion sends one synthetic purchase and shows what the vendor said.

  • Meta needs a test event code from Events Manager › Test events. The event appears there within seconds, and stays out of reporting and optimisation. While a code is set, every conversion goes to Test Events - the console says so in red - so clear it to go live.
  • GA4 goes to Google's validation endpoint, which checks the payload and records nothing.

Warning

GA4 cannot tell you an API secret is wrong. Its collection endpoint answers 204 to everything, and its validator checks the payload, not the secret. The only proof is the event arriving: after a real purchase, look for it in Reports › Realtime, or in DebugView.

What it does not do

Google Ads, TikTok, LinkedIn, Microsoft, Reddit and X have no server-side send here yet. Google Ads uploads need an approved developer token and an OAuth grant per account, and each of the others is a credential we would hold and an API we would have to keep working. For those, use your webhooks.

Send it yourself

Everything above is also on your webhooks, so the same can be done from your own backend - to any vendor, with your own rules.

WebhookWhenMoney fields
subscription.createda checkout completedamount, currency, checkoutSessionId
payment.succeededany invoice was paid, renewals includedamount, currency, invoiceId, billingReason
payment.refundedmoney went backamount, amountRefunded, currency, fullyRefunded
credit.purchaseda credit pack was boughtamountPaid, currency, sessionId
subscription.updatedthe subscription changed-

subscription.created, payment.succeeded and credit.purchased also carry subtotal, discount and tax. amount is what was charged - after any promotion code, including tax - so subtotal minus discount plus tax is amount. Send amount - tax if your ad platform should see revenue net of tax.

Each carries the same tracking block:

{
  "event": "payment.succeeded",
  "data": {
    "invoiceId": "in_1UGZ...",
    "amount": 2900,
    "currency": "usd",
    "billingReason": "subscription_create",
    "tracking": {
      "eventId": "49dbd52e-8e55-4881-bda7-5a5ea71bcb39",
      "clientId": "UQBwpOF9...",
      "attribution": { "gclid": "Cj0KCQ...", "utm_source": "google" },
      "browser": {
        "fbp": "fb.1.1700000000000.123456789",
        "gaClientId": "1075550343.1789630253",
        "gaSessionId": "1789630252",
        "userAgent": "Mozilla/5.0 ...",
        "url": "https://app.example.com/billing",
        "ip": "203.0.113.7"
      },
      "consent": { "analytics": true, "marketing": true }
    }
  }
}

Amounts are in the currency's minor unit. On credit.purchased, amount is credits and amountPaid is money. The block is absent, not empty, for a checkout that did not start in a browser running the tracker, and consent is absent when the buyer was never asked.

billingReason is Stripe's own. subscription_create is the first payment, which the browser has usually reported: send it with tracking.eventId and the vendor keeps one. subscription_cycle is a renewal - except the first charge when a trial ends, which is the sale and carries trialConverted: true. Give either an id of its own - the invoiceId is a good one - or it is discarded as a duplicate of the original sale. A trial's first invoice is subscription_create for 0: there is nothing to report yet. payment.refunded carries the block of the sale it reverses, which is what a Google Ads retraction is keyed on, and Google accepts one for 55 days.

import { createHash } from 'node:crypto';
import { parseWebhookEvent } from '@buildbase/sdk';

const sha256 = (v: string) =>
  createHash('sha256').update(v.trim().toLowerCase()).digest('hex');

export async function POST(req: Request) {
  const body = await req.text();
  const event = parseWebhookEvent({
    body,
    signature: req.headers.get('x-buildbase-signature'),
    timestamp: req.headers.get('x-buildbase-timestamp'),
    secret: process.env.WEBHOOK_SECRET!, // shown once, when you create the endpoint under Settings › Webhooks
  });
  if (!event)
    return Response.json({ error: 'Invalid signature' }, { status: 401 });

  const { tracking } = event.data;
  // No answer is not a yes, and a refusal is a refusal.
  if (event.event !== 'payment.succeeded' || !tracking?.consent?.marketing) {
    return Response.json({ received: true });
  }

  const { amount, currency, invoiceId, billingReason, trialConverted } =
    event.data;
  // A trial's first invoice is for 0 - the sale comes when the trial ends.
  if (amount === 0) return Response.json({ received: true });
  // Only the first payment of a checkout also happened in a browser.
  const fromBrowser = billingReason === 'subscription_create';
  // Stripe files the charge that ends a trial like a renewal; it is the sale.
  const isRenewal = billingReason === 'subscription_cycle' && !trialConverted;
  if (isRenewal && !process.env.SEND_RENEWALS) {
    return Response.json({ received: true });
  }
  const email = await emailForWorkspace(event.data.workspaceId); // yours

  await fetch(
    `https://graph.facebook.com/v21.0/${process.env.META_PIXEL_ID}/events?access_token=${process.env.META_CAPI_TOKEN}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        data: [
          {
            event_name: 'Purchase',
            event_time: Math.floor(Date.now() / 1000),
            event_id: fromBrowser ? tracking.eventId : invoiceId,
            action_source: fromBrowser ? 'website' : 'system_generated',
            event_source_url: fromBrowser ? tracking.browser?.url : undefined,
            user_data: {
              em: [sha256(email)],
              fbp: tracking.browser?.fbp,
              fbc: tracking.browser?.fbc,
              client_ip_address: tracking.browser?.ip,
              client_user_agent: tracking.browser?.userAgent,
            },
            custom_data: {
              value: amount / 100,
              currency: currency.toUpperCase(),
            },
          },
        ],
      }),
    }
  );

  return Response.json({ received: true });
}

amount / 100 is right for a two-decimal currency and wrong for yen. For Google Ads, upload a click conversion keyed on attribution.gclid (or gbraid / wbraid from an iOS click), with consent set from the block and order_id set to the checkoutSessionId, so a later payment.refunded can retract it.

Next

  • Events - what the browser sends, and where eventId comes from.
  • Consent - how the answer is given, and why it has to be restored on every load.
  • Webhooks - verifying signatures and delivery guarantees.