Events
The conversion events BuildBase fires for you, the product events it forwards, and how to send your own to every vendor that can receive them.
BuildBase is the backend that observed the signup and the payment, so it fires those events itself. Everything else you send with track(), and it reaches the same vendors.
import { useTracking } from '@buildbase/sdk/tracking';
function ExportButton() {
const { track } = useTracking();
return (
<button
onClick={() => {
exportReport();
track('report_exported', {
format: 'csv',
rows: 4820,
workspace_plan: 'grow',
});
}}
>
Export
</button>
);
}What that one call produces
An event name of your own has no mapping in our table and no conversion to bill against, so it goes to every vendor that accepts an arbitrary name - and, deliberately, to no ad network. The campaign parameters the visitor arrived with ride along, so a product event can still be attributed to the ad that caused it.
| Vendor | Receives |
|---|---|
| Data layer | { event: 'report_exported', format: 'csv', rows: 4820, …, event_id } |
| GA4 | gtag('event', 'report_exported', { …, send_to, event_id }) |
| PostHog | posthog.capture('report_exported', { …, event_id }) |
| Plausible | plausible('report_exported', { props: { … } }) |
| Bing UET | uetq.push('event', 'report_exported', { …, event_id }) |
| Clarity | clarity('event', 'report_exported') |
| Hotjar | hj('event', 'report_exported') |
| Meta, TikTok, Reddit, X, LinkedIn | nothing - see below |
Note
GA4 treats event_id as a reserved key. On the wire it travels as evnid
rather than as an ep. event parameter, so look for it there in the network
tab, not among the event's parameters. The value is the same id every other
vendor receives.
Naming and limits
Lowercase letters, digits and underscores, starting with a letter, up to 40 characters - which is where GA4 truncates, and the shape every vendor accepts. demo_booked, not Demo Booked: a vendor that receives both keeps two rows in every report and neither is the total.
Parameters are flat scalars. GA4 keeps 25 per event and truncates a value at 100 characters; Plausible takes strings and numbers only. Send ids and amounts, not objects. Never send an email address, a name or anything else that identifies a person: that is against every vendor's terms, and none of these fields is the place for it.
Making your own event a conversion
Google Ads, X and LinkedIn do not take an event name at all - they take a conversion you created in the ad platform. That is what lets your own events be conversions there:
- Create the conversion action in the ad platform and copy its label.
- In Settings › Tracking & Tags, edit that tag and scroll to Conversion labels.
- Under An event of your own, enter the name you pass to
track()and the label, and click Add.
From then on track('demo_booked') is a reported conversion in that network, with your parameters attached. Nothing else changes: the same call keeps reaching GA4, PostHog and the rest under its own name.
Note
Meta, TikTok and Reddit work the other way round - they recognise a fixed list
of standard event names, so there is no label to map to. We will not guess
that your upgraded_seat is Meta's Purchase; that is how a conversion
report quietly becomes fiction. If your event genuinely is one of their
standard events, fire it under that canonical
name, or call window.fbq
yourself - the vendor globals stay yours.
Your own data on our events
The events BuildBase fires carry what BuildBase knows: that a signup happened, what it cost, which ad it came from. What it cannot know is the thing your funnel is actually built on - the plan someone intended before they signed up, the industry they picked, the experiment arm they landed in, the seller they bought from.
Three ways in, from least to most specific. All of them apply to the events we fire as well as the ones you send.
Every event: properties
Set what is always true of this app, this visitor, this session.
<SaaSOSProvider
tracking={{
properties: { app_version: '4.2', surface: 'web' },
}}
>For anything not known at mount - an experiment assignment, a tier fetched after login - set it when you learn it:
const { setProperties } = useTracking();
useEffect(() => {
setProperties({ variant: experiment.arm, tenant_tier: org.tier });
}, [experiment.arm, org.tier, setProperties]);It merges, so calling it twice keeps both, and everything from that point on carries them. Setting a key to undefined takes it back:
// On sign-out, so the next person's events do not carry the last person's.
setProperties({ tenant_tier: undefined, plan: undefined });Two layers, and they behave differently on purpose. The properties prop is whatever your app is rendering right now, so removing a key from it removes it from events. What setProperties added is kept separately and survives the next render, because the render after an experiment assignment must not wipe the assignment.
Neither survives a page reload - they live in memory, not in a cookie. If a value must be on every event of a visit, set it from your own store when the provider mounts. We do not persist it for you, because how long an experiment arm or a tier should outlive a session is your decision and your privacy policy, not ours.
One event at a time: enrich
Called just before anything is sent, with the event and what it already carries. Return the fields to add.
<SaaSOSProvider
tracking={{
enrich: ({ name, params }) => {
if (name === 'sign_up') return { plan_intent: store.chosenPlan, industry: store.industry };
if (name === 'checkout_started') return { seats: store.seats, coupon_shown: store.coupon };
return {};
},
}}
>This is the one that makes our events yours. It runs for sign_up, purchase, workspace_created and the rest, exactly as it does for your own track() calls.
The person, not the event: setUser
Traits belong to the customer and segment every report, rather than sitting on one event.
const { setUser } = useTracking();
setUser(user.id, { plan: 'growth', company_size: '50-200', is_agency: true });They reach GA4 as user properties, PostHog as person properties and Clarity as session tags - the three places a funnel or a recording gets filtered.
All of it is yours, and all of it can vary per user
Nothing here is a fixed list agreed in advance. properties is whatever your app renders, setProperties is whatever it learns, enrich runs per event and can read anything in scope - the signed-in user, a feature flag, a store. Two visitors in the same minute can send entirely different fields.
The split is worth being clear about: an org admin decides in the console which tags load and which conversions they map to, and the app owner decides in code what data rides along. Neither needs the other to deploy.
enrich is synchronous: it runs in the moment an event fires, so there is no room to await a lookup. When a value needs fetching, fetch it and hand it over with setProperties when it lands.
Which one to reach for
| You want | Use |
|---|---|
| The same field on everything | properties |
| A field that changes during the session | setProperties |
| A field that depends on which event this is | enrich |
| A field you segment or build cohorts on | setUser traits |
| To stop us sending an event and send it yourself | manual, below |
Warning
None of these may carry an email address, a name, a phone number or anything else identifying a person. Every ad network forbids it in event parameters and GA4 forbids it in user properties - it is grounds for losing the property, not a warning. Send an id and keep the mapping your side.
enrich can overwrite anything, ours included - it is your report. It cannot rename an event, because the name is what every vendor mapping is keyed on, and renaming one here would silently unmap it. To suppress an event entirely, return true from onEvent and send nothing.
The commercial events, and who fires them
Six of these fire on their own once a tag is attached. Three are yours to send, because only your app knows the moment: BuildBase never sees a form submitted, and subscription_created exists for apps that provision a subscription outside our checkout.
| Event | Fired by | When | GA4 | Meta | TikTok | |
|---|---|---|---|---|---|---|
sign_up | BuildBase | a first authentication | sign_up | CompleteRegistration | CompleteRegistration | SignUp |
login | BuildBase | any later authentication | login | - | - | - |
checkout_started | BuildBase | a checkout or plan change is created | begin_checkout | InitiateCheckout | InitiateCheckout | AddToCart |
trial_started | BuildBase | a trial begins, with a card or without | begin_checkout | StartTrial | StartTrial | Lead |
purchase | BuildBase | the buyer returns from Stripe having paid | purchase | Purchase | CompletePayment | Purchase |
subscription_upgraded | BuildBase | a plan change applied in place | subscription_upgraded | - | - | - |
workspace_created | BuildBase | a workspace is created | workspace_created | - | - | - |
subscription_created | your app | you provisioned a subscription outside our checkout | purchase | Subscribe | Subscribe | Purchase |
lead | your app | a form, a demo request, a waitlist | generate_lead | Lead | SubmitForm | Lead |
Every dash is deliberate. No ad network treats a returning login as a conversion, and reporting one inflates every campaign that ever touched an existing customer.
An upgrade is the interesting one. A plan change that sends the buyer out to Stripe is a checkout like any other, so it reports checkout_started and then purchase. A change applied in place reports subscription_upgraded, which goes to analytics and not to the ad networks - at that moment the plan has changed but nothing has been charged. The money follows when the proration is invoiced, and that is reported from the server, where it is known to have been paid.
Google Ads, X and LinkedIn take no event name at all - they take a conversion id you created in the ad platform. Fill those in per event in the console, and the SDK sends them; leave one blank and that event simply is not reported to that network, which is a choice rather than an error.
Sign-up or login
The SDK sees one thing: an authentication resolved and there is now a user. To tell a new customer from a returning one it reads firstAuthAt, which the server stamps once, where the session is minted. Account age alone is not enough - someone who signs up on a laptop and opens the app on their phone a minute later would otherwise be billed as two conversions.
Anything ambiguous - a missing stamp, an older one, an account that predates the field - resolves to login, which costs a row in a report rather than inventing a sale.
Product events, forwarded
Every moment the SDK already emits reaches analytics too, as a product event: no ad-network mapping, ids and roles only, and never a name, an email or an image.
user_created · user_updated · workspace_created · workspace_updated · workspace_switched · workspace_deleted · workspace_member_added · workspace_member_removed · workspace_member_role_changed
Your own handleEvent callback keeps receiving all of them exactly as before - the SDK subscribes alongside you, never instead of you.
The funnel between them
sign_up and purchase tell you that someone arrived and that someone paid. They cannot tell you where everyone else went: who opened the plan picker and closed it, who chose a plan and backed out of Stripe, who kept running into a feature their plan does not include. Those moments happen inside components the SDK renders - the settings modal, the plan picker, the gates - so the SDK reports them, in every app, without a line of your code.
They are product events, like the ones above. None maps to a Meta, TikTok or Reddit event, so no ad network sees them unless you attach a conversion label to one in the console. They reach the data layer, GA4, PostHog, Plausible, Clarity and Hotjar.
| Event | Fires when | Carries |
|---|---|---|
sign_in_started | signIn() is called - the sign-in or sign-up button | from_path |
pricing_viewed | a <PricingPage> has loaded its plans | pricing_slug, plan_count, authenticated |
pricing_plan_selected | a plan is chosen through <PricingPage>'s selectPlan | pricing_slug, plan_version_id, billing_interval, currency, authenticated |
workspace_switcher_opened | the workspace switcher opens | workspace_count |
workspace_settings_opened | the settings modal opens, from anywhere | section it opened on |
workspace_settings_section_viewed | a different section is opened inside the modal | section |
plan_picker_opened | the plan picker is shown | source, plan_count, has_subscription, current_plan_version_id, billing_interval |
billing_interval_changed | monthly, quarterly or yearly is switched in the picker | from, to |
plan_selected | a plan is chosen in the picker | source, plan_version_id, plan_slug, billing_interval, currency, value, change |
plan_picker_dismissed | the picker is closed without a plan chosen | source, billing_interval |
checkout_abandoned | the buyer comes back from Stripe without paying | plan_version_id, value, currency, seconds_at_checkout |
billing_portal_opened | Manage payment opens Stripe's portal | plan_slug, subscription_status |
subscription_cancel_started | the cancel confirmation opens | plan_slug, source |
subscription_cancelled | a cancellation is confirmed | plan_slug |
subscription_resumed | a scheduled cancellation is taken back | plan_slug |
feature_gate_shown | a WhenWorkspaceFeatureDisabled / WhenUserFeatureDisabled renders | feature, scope |
plan_gate_shown | a WhenSubscriptionToPlans renders its fallbackComponent | required_plans, current_plan |
quota_limit_reached | a quota gate renders because the quota is used up | quota, included, consumed |
quota_threshold_reached | a WhenQuotaThreshold renders | quota, threshold, percent_used |
subscription_notice_shown | a billing notice is shown | kind: trial, trial_ending, past_due, dunning, paused, cancel_scheduled |
sdk_error | an SDK operation fails | component, action, code |
A few rules keep the numbers honest:
sourceon the picker says what opened it -auto_no_subscription,deep_link,choose_plan,change_plan,resubscribe,empty_state,trial_bannerornew_version_notice- which is what tells you which of them turns into a sale.changeonplan_selectedisnew,upgrade,downgradeorinterval_change, judged on price in the chosen interval.- Gates and notices report once per page, however many times they render and however many rows a list draws them in. A seat limit on every row of a table is one fact, not fifty.
- Events that happen as the page loads wait for your tags. A gate on the first screen, or a failed request during load, is held until the tags are installed rather than reported to nobody.
checkout_abandonedneeds the SDK's own cancel URL - the one the billing screen andcreateCheckoutRedirectUrls()build. With a cancel URL of your own the SDK cannot tell an abandoned checkout from a success page that lost its query string, so it reports nothing rather than guess.sdk_errornever carries the error message. A message can include an email address or whatever the server echoed back, so only where it happened is sent.- Adding a member is already
workspace_member_added, above, so there is no separate invite event.
Which account it was
A product sold to teams is measured by account, not by person: which workspaces hit the seat limit and then upgraded, whether owners and members behave differently, what customers on each plan actually open. So once someone is signed in and a workspace is loaded, every event carries the account it belongs to - ours, the conversions, and your own track() calls alike.
| Field | What it is |
|---|---|
workspace_id | the current workspace |
workspace_role | the viewer's role in it |
workspace_owner | whether the viewer created it |
workspace_member_count | how many members it has |
workspace_plan | the plan's slug, or none - left out entirely while the subscription is loading |
subscription_status | active, trialing, past_due and so on, or none |
workspace_plan, workspace_role and subscription_status also go to GA4 as user properties, to PostHog as person properties and to Clarity as tags. That matters for GA4 in particular: its own page views and engagement are collected by the vendor, never sent by us, so they cannot carry event fields - a user property is what makes "page views by plan" possible.
Ids, slugs and counts only. A workspace's name is chosen by your customer and can be a person's name or their client's, so it never leaves the browser. The fields change as the viewer switches workspace or upgrades, and are removed on sign-out. A purchase or an abandoned checkout carries the workspace that started it, even though the page Stripe returns to has not loaded one yet.
They are not written onto the Stripe session. Your server already knows which workspace a subscription belongs to, and Stripe's metadata has room for only ten of your own fields, which are better spent on what the server cannot look up.
To leave one out, return true from onEvent for that name. To add a field to all of them, use properties or enrich exactly as for any other event.
When you want to fire them yourself
Set an event to manual and the SDK stops sending it, handing it to your callback instead. The event you receive carries the same parameters the vendors would have got, attribution included, so your own send is not missing the click id.
<SaaSOSProvider
tracking={{
events: { overrides: { purchase: 'manual' } },
onEvent: (e) => myAnalytics.record(e.name, e.params),
}}
>In auto mode - the default - onEvent is still called for every event, for information. Returning true from it tells the SDK you handled that one occurrence and suppresses its own send.
Warning
Marking an event manual without an onEvent means it silently never fires
anywhere. The provider throws at init rather than let that ship.
Payment, and the id that survives the round trip
checkout_started fires where a checkout session is created, and the session's metadata carries bb_event_id plus the stored click ids. Stripe copies session metadata onto the subscription, so a server reading that subscription later sees the same id the browser used.
purchase cannot fire there - most checkouts that start are never paid for - so the id is parked, along with the Stripe session it opened and what that session charges today.
You do not need a success page for what happens next. Stripe returns the customer to your successUrl with session_id appended, and when that id is the one that was parked the SDK fires purchase from whatever page they land on - the billing page they started from is fine. A session_id that was not parked does nothing, so a pasted URL cannot count as a sale, and spending is one-shot, so a refresh cannot count it twice.
| What the vendor receives | Where it comes from |
|---|---|
value | what was actually charged, in major units - 29, not 2900, and 2900 in yen |
currency | the currency the session resolved to, upper-cased as GA4 requires |
transaction_id | the Stripe session id: GA4's key for refunds, and Google Ads' deduplication key |
event_id | the same id as bb_event_id on the Stripe session and subscription |
checkout_started carries the same value and currency. A checkout that starts a card-required trial comes back as trial_started rather than purchase, because nothing has been sold yet.
Promotion codes
value on purchase is what the buyer paid, discount included. That takes a lookup, because Stripe does not apply a promotion code to the session until the payment completes - a checkout for a $29 plan reads 2900 at creation, still reads 2900 while the code sits applied on Stripe's page, and only becomes 1450 once paid. So the SDK asks the server what the session finally charged before it reports the sale, and falls back to the amount it knew if that call does not answer within two seconds.
checkout_started is deliberately not corrected: nothing has been discounted at the moment a checkout begins, and begin_checkout is the value of the intent.
Note
value includes tax where you charge it. If your ad platform should see
revenue net of tax, send it yourself from payment.succeeded, which carries
subtotal, discount and tax alongside amount - see Server-side
conversions.
Two cases still want the hook. A success URL that redirects and drops its query string before the page renders, and an event you want to add your own parameters to:
import { useCheckoutCompleted } from '@buildbase/sdk/tracking';
export default function BillingSuccess() {
useCheckoutCompleted({ coupon: 'LAUNCH' });
return <p>Thanks - your plan is active.</p>;
}It spends the same parked checkout, so having both never reports the sale twice.
The return does not have to land on the subdomain the checkout started on. A second copy of the parked checkout sits in a cookie on your registrable domain, next to the click id, and is only ever spent against the matching session_id - so a checkout started on app. and returned to billing. is reported from billing..
Credit packs work the same way. usePurchaseCredits fires checkout_started, writes bb_event_id onto the Stripe session, and the return reports purchase with credit_package_id in place of plan_version_id.
Note
This is the client-side half and it is lossy by nature - a customer who pays
and closes the tab is never counted. The shared bb_event_id is what lets a
send from your backend fill that gap without double-counting the browser hit.
See Server-side conversions.
The globals stay yours
track() is a convenience for the events that fan out everywhere, not a wall around the vendor API. Once a tag is installed its global is the vendor's own:
window.posthog.capture('feature_flag_evaluated', {
flag: 'new_editor',
variant: 'b',
});
window.fbq('trackCustom', 'HighIntentExport', { rows: 4820 });
window.clarity('set', 'plan', 'grow');Use useTracking().installed to check what is actually on the page before reaching for a global.
What we put on window
One object, and it is there to be read, not written. Useful in a console, in an end-to-end test, or from a script that is not inside React.
window.__buildbase.tracking;
// {
// installed: { 'ga4:G-ABC123': 'G-ABC123', 'meta_pixel:123…': '123…' },
// dataLayerName: 'dataLayer',
// clientId: 'the auth client this app mounted for',
// }installed is keyed the same way the consent manifest is, so a banner and a test can agree on what is actually on the page. Everything else stays the vendor's: window.dataLayer, window.gtag, window.fbq, window.posthog and the rest are theirs, untouched, exactly as their own documentation describes them.
We write one cookie of our own, bb_attr, on your registrable domain: the click ids and UTM values the visitor arrived with, so a sale on app. can still be attributed to an ad that landed on the marketing site. bb_su is a two-minute marker that stops one signup being counted twice across subdomains. Neither is read by any vendor.
Client or server
| Where it runs | What it can send | |
|---|---|---|
track(), properties, enrich, setUser | The browser | Anything, to every installed tag |
| Our server-side conversions | Our servers, on Stripe's word | The money events only - purchase, trial, upgrade, renewal, refund |
| Your own backend | Your servers, on our webhooks | Anything, to anything |
There is deliberately no track() on the server. An event sent from a backend with no browser identifiers reaches an ad platform and matches nobody, which looks like it worked and is worse than not sending it. What our servers do send is the handful of events where Stripe tells us money moved and the browser already handed over the identifiers to match on. For everything else your backend has the webhooks, which carry the same event id and click ids, and can go anywhere.
Next
- Consent - which of these wait for a yes.
- Webhooks & Events - the same moments, delivered to your backend.