← All docs

Revenue tracking

Attach revenue to any event with $revenue and $currency, and where those numbers show up.

Revenue isn’t a separate event type. You attach it to any event you already send, using two reserved properties.

lp.track('ticket_purchased', { $revenue: 49.99, $currency: 'USD', ticket_type: 'vip' });

That’s the whole API.

The two reserved properties

PropertyTypeWhat happens
$revenuenumberPromoted to a revenue_micros column as round(value × 1_000_000)
$currencystring, max 8 charactersUppercased into a currency column

Both are stripped from properties before the event is stored. In the example above, the stored event keeps ticket_type: 'vip' and nothing else — $revenue and $currency become their own columns.

Infinity and NaN are ignored.

Why micros

Revenue is stored as integer micros — 49.99 becomes 49990000. Sums over integers are exact and deterministic. Sums over floats drift, and two people running the same query on the same data can get different totals. Storing micros makes that class of bug impossible.

You don’t have to do anything about it. Send a normal number; the conversion happens on ingest.

Every other $ property is rejected

$revenue and $currency are the only property keys allowed to start with $. Any other $-prefixed property key rejects the entire event.

// Rejected — the whole event is dropped
lp.track('purchase', { $revenue: 20, $user_id: 'user_8f21c' });

This is on purpose: identity can never be set through properties. Use identify() in the browser, or the user_id field in server-side events.

The usual property limits still apply to everything else on the event — 64 properties, 64-character keys, 1024-character strings, 8192 bytes serialized. The full table is in the SDK reference.

Send it from your backend

The single most common revenue-tracking mistake is firing the event from the browser on a “thank you” page.

That page can be reloaded, which double-counts. It can be bookmarked and revisited, which double-counts again. It can sometimes be reached without paying at all. And a payment that fails after redirect, or a card that gets declined asynchronously, still counted.

Send revenue from your backend, after the payment actually settles — from your Stripe webhook handler, or wherever your order becomes final:

await fetch('https://collector.launchpulse.dev/v1/events', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.LAUNCHPULSE_SECRET}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    event: 'subscription_paid',
    user_id: order.userId,
    properties: {
      $revenue: order.amount / 100,
      $currency: order.currency,
      plan: order.plan,
    },
  }),
});

Full setup, including the endpoint and its limits, is in Server-side events.

Use the same user_id your browser code passes to identify(). That’s what ties the payment back to the session, the source, and the campaign that produced it.

Currency

$currency is a string of at most 8 characters, uppercased on ingest. Send an ISO code — USD, EUR, MXN.

LaunchPulse does not convert between currencies. If you charge in several, revenue totals sum the raw numbers regardless of currency, so you’ll want to either normalize to one currency before sending, or read revenue per currency.

Where revenue shows up

  • The Revenue tile on the Pulse dashboard — shown only when no first-value event is configured. Once you’ve set a first-value event, that tile shows activation instead. See Conversions and activation.
  • The Revenue metric in insights — for breaking revenue down by source, campaign, or any property.
  • The portfolio cards — revenue per project, across everything in your account.

If revenue reads as 0

This is worth knowing before it confuses you.

The analytics store drops columns that are entirely empty for a project. If you have never sent $revenue for a project, the column doesn’t exist, and revenue reads as 0 everywhere. That is not an error and not a broken query — there’s simply nothing there.

Once your first event with $revenue lands, the column appears and the numbers start populating. So if revenue looks stuck at zero:

  1. Check the events log and open a purchase event. Does it have a revenue value?
  2. If the property is still sitting inside properties as literal $revenue, the event was stored but something isn’t matching — confirm you sent a number, not a string. "49.99" is not 49.99.
  3. Remember that a 2xx from the ingest API doesn’t prove your key is valid. Unknown, revoked and paused keys all get a uniform 2xx. The events log is the only real confirmation.

A worked example

A paid newsletter, tracking both the free signup and the paid conversion:

// Browser — someone subscribes to the free list
lp.track('newsletter_signup', { source_page: '/pricing' });
// Backend — Stripe webhook, after invoice.paid
await sendToLaunchPulse({
  event: 'subscription_paid',
  user_id: customer.internalId,
  properties: {
    $revenue: invoice.amount_paid / 100,
    $currency: invoice.currency,
    plan: 'annual',
    is_renewal: invoice.billing_reason === 'subscription_cycle',
  },
});

Now the Revenue metric in insights can break down by plan, or by is_renewal to separate new revenue from renewals, or by first_source to see which acquisition channel actually produces paying customers rather than just traffic.

NextServer-side events

Talk to us

Questions about LaunchPulse, or want a walkthrough? Send a note and a real person replies.

Or email us at hello@launchpulse.dev