# Conversions without a browser

Trial conversions, renewals and refunds happen in a Stripe webhook with nobody on your site. BuildBase records each one against the original ad click and hands it to Google Ads as an offline import.

A seven-day trial converts to paid on day seven, from a Stripe webhook, with nobody on the site. So does every renewal and every refund. None of these can ever be a browser event, so no tag can report them - and the conversion your ad platform most needs to see, the paying customer, is the one it never hears about.

BuildBase closes that gap on the server. The SDK already holds the click id the visitor arrived with; it now also stores it on the user at sign-up and on the workspace when it is created. When Stripe reports a payment, the webhook handler writes a row to the org's `ad_conversions` ledger, joined to that click. You then deliver the ledger to Google Ads, as a scheduled import today or through the Google Ads API where it is enabled.

## What gets recorded

| Event                     | When                                          | Order ID                                                                                                 |
| ------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `purchase`                | A first paid checkout with no trial           | The checkout's `bb_event_id`, the same `transaction_id` the browser sent, so Google keeps one of the two |
| `trial_converted_to_paid` | The first non-zero paid invoice after a trial | The Stripe subscription id: a subscription converts once                                                 |
| `subscription_renewed`    | Every later paid invoice                      | The Stripe invoice id                                                                                    |
| `refund`                  | A charge refunded, fully or in part           | Its own id; it adjusts the order it reverses                                                             |

Each row carries the org and workspace, the event id, the click id and its type (`gclid`, `gbraid`, `wbraid`, `fbclid` and the rest), when the click was captured, the conversion time, the value in major units (negative for a refund), the currency, a SHA-256 hash of the buyer's normalised email (never the address), the consent answer from checkout, and a status.

A Stripe event delivered twice writes one row. A row that cannot be sent is still written, with the reason, so the numbers add up:

| Reason                   | Meaning                                            |
| ------------------------ | -------------------------------------------------- |
| `no_click_id`            | No live ad click on record for this customer       |
| `consent_denied`         | The buyer refused ad consent at checkout           |
| `internal_account`       | The buyer matched your internal-account list       |
| `fake_click_id`          | A test value such as `SETUP-CHECK-...` was removed |
| `no_original_conversion` | A refund with no recorded sale to adjust           |

Click ids expire 90 days after the click, which is the window Google accepts. The time is measured from when the SDK captured the landing, not from when the row was written.

> **Note:**
  Needs `@buildbase/sdk` 0.0.72 or later for the capture time and for storing
  attribution at sign-up and workspace creation. With an older SDK, rows still
  use the click ids stamped on the checkout itself.


## Deliver to Google Ads: scheduled import

1. In Google Ads, create a conversion action of type **Import > Other data sources or CRMs > Track conversions from clicks**. Name it, for example, `Paid subscription`.
2. In the console, open **Settings › Tracking & Tags › Server-side conversions**. Under **Conversion actions**, type that name exactly for each event you want exported. `purchase` and `trial_converted_to_paid` default to `Paid subscription`; renewals stay out until you name an action for them.
3. Click **Create token**. It is shown once; we keep only a hash.
4. In Google Ads, go to **Goals › Conversions › Uploads › Schedules**, add a source of type **HTTPS**, paste the **Conversions** URL, use any username and the token as the password, and pick a schedule.

The file follows Google's offline click conversion format:

```text
Google Click ID,Conversion Name,Conversion Time,Conversion Value,Conversion Currency,Order ID
Cj0KCQjw...,Paid subscription,2026-09-20 09:05:07+0000,49.00,USD,sub_1Q2w3E
```

Times are UTC. Each fetch returns every live row from the last 90 days; Google ignores order ids it has already imported, so a missed run loses nothing.

**Refunds** are a second file, the **Refund adjustments** URL, because Google's click import accepts no negative value. A full refund retracts the original conversion and a partial one restates it at what is left. Schedule it the same way as a conversion adjustments upload.

The export also answers `?token=` for anything that cannot send Basic auth. A wrong or missing token is a `401`.

## Deliver to Google Ads: the API

Direct upload through `uploadClickConversions` is built, and behind a platform flag until it is switched on for your organization. Where it is on, the same tab shows a **Direct upload** card: your customer ID, a refresh token from the Google Ads OAuth consent, and the numeric id of each conversion action. Uploads use partial failure, so one bad row does not block the rest; a refused row keeps Google's message, and is retried up to five times.

## Internal accounts

Your own sign-ups are not customers. Under **Internal accounts**, list email domains, addresses and workspace ids. Their conversions still appear in the ledger, marked `internal_account`, and never reach any ad vendor, including the Meta and GA4 server-side sends.

**Remove test click ids** clears values such as `SETUP-CHECK-...` from stored attribution and marks any queued row that carried one.

## Consent and your privacy notice

A row is only exported when the buyer's consent answer allowed marketing, or when no answer was recorded. Whether uploading conversions for customers who were never asked is lawful for you, and on what basis, is your decision; state it in your privacy notice. The ledger holds a hashed email and a click id, and rows are deleted after a year.

## API

| Method  | Path                                                              | What                                                         |
| ------- | ----------------------------------------------------------------- | ------------------------------------------------------------ |
| `GET`   | `/api/organizations/tracking/ad-conversions`                      | The ledger, newest first. `status`, `event`, `page`, `limit` |
| `GET`   | `/api/organizations/tracking/ad-conversions/summary`              | Counts, settings, export URLs                                |
| `PATCH` | `/api/organizations/tracking/ad-conversions/settings`             | Conversion names, exclusions, Google Ads                     |
| `POST`  | `/api/organizations/tracking/ad-conversions/token`                | Rotate the export token                                      |
| `GET`   | `/api/v1/public/:orgId/ad-conversions/google-ads.csv`             | The import, token-protected                                  |
| `GET`   | `/api/v1/public/:orgId/ad-conversions/google-ads-adjustments.csv` | Refund adjustments, token-protected                          |

Meta and LinkedIn offline formats are planned on the same ledger.
