# 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**.

| Provider | Credential                      | Where to find it                                                      |
| -------- | ------------------------------- | --------------------------------------------------------------------- |
| Meta     | Conversions API access token    | Events Manager › your pixel › Settings › Conversions API              |
| GA4      | Measurement Protocol API secret | Admin › 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 happened                       | Meta                             | GA4                                         |
| ----------------------------------- | -------------------------------- | ------------------------------------------- |
| First payment, or a credit pack     | `Purchase`                       | `purchase`                                  |
| A trial started, card or not        | `StartTrial`                     | not sent - the browser reported it          |
| The first charge when a trial ends  | `Purchase`, `system_generated`   | `purchase`                                  |
| A plan change charged a proration   | `Purchase`, `system_generated`   | `purchase`                                  |
| Renewal, if **Send renewals** is on | `Purchase`, `system_generated`   | `purchase`                                  |
| Refund                              | not sent - Meta has no such call | `refund`, 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 log                       | What it means                                                                                                      |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `marketing consent refused`             | The buyer said no. Meta is never sent a refusal.                                                                   |
| `analytics consent refused`             | The same, for GA4.                                                                                                 |
| `consent unknown`                       | The buyer was never asked - an API checkout, or an app with no banner. See below.                                  |
| `no GA4 client id for this buyer`       | GA4 never loaded in their browser. A made-up id would create a user who belongs to nobody.                         |
| `the browser reported this one to GA4`  | Your 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 destination` | The default. An ad platform told about every monthly charge optimises toward people who would have renewed anyway. |
| `older than the 72 hours GA4 accepts`   | GA4 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.

> **Note:**
  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.

| Webhook                | When                                    | Money fields                                            |
| ---------------------- | --------------------------------------- | ------------------------------------------------------- |
| `subscription.created` | a checkout completed                    | `amount`, `currency`, `checkoutSessionId`               |
| `payment.succeeded`    | any invoice was paid, renewals included | `amount`, `currency`, `invoiceId`, `billingReason`      |
| `payment.refunded`     | money went back                         | `amount`, `amountRefunded`, `currency`, `fullyRefunded` |
| `credit.purchased`     | a credit pack was bought                | `amountPaid`, `currency`, `sessionId`                   |
| `subscription.updated` | the 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:

```json
{
  "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.

```typescript
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](/tracking/events) - what the browser sends, and where `eventId` comes from.
- [Consent](/tracking/consent) - how the answer is given, and why it has to be restored on every load.
- [Webhooks](/webhooks/overview) - verifying signatures and delivery guarantees.
