What to track first: the events that move money
The temptation is to track everything. The useful move is to track the handful of things that happen to your revenue: signups, payments, failures, cancellations. Here is how to pick them, name them, and wire them in without making a mess.
The first time you connect an app to an event feed, the temptation is to track everything. Every click, every page view, every button. A week later the feed is noise and nobody opens it.
Butters works best for business events: something happened that a person on your team would want to know about. A useful test is whether you'd mention it to a colleague. A stranger signing up for your product in its first month? You'd mention it. A page view? Never. That second kind of data belongs in an analytics tool, which is built for volume and funnels. The first kind belongs in a feed you actually read.
Start with the money path
For most products, the events that matter are the ones on the path between a stranger and a paying customer, and back off it again.
For a SaaS app that's roughly:
- User signed up
- Trial started
- Subscription started
- Payment failed
- Subscription cancelled
For a shop:
- Order placed
- Payment captured
- Refund issued
- Review posted
That's five to eight events. Each one is a single call from a place in your code where the thing already happens: the signup handler, the Stripe webhook, the refund endpoint. You don't need new infrastructure. You need to find those places and add a line.
One category per question
Categories are what you filter and chart by, so choose them to answer questions. "How many signups this week?" wants a signups category. "What's happening with billing?" wants billing.
Don't make a category per event. "Subscription started" and "Subscription cancelled" both belong in billing, and the title tells them apart. Five categories you understand beat thirty you don't.
Categories are created automatically the first time an event names one. That's convenient, and it means a typo creates a new category. Keep the names in constants rather than typing them at each call site.
A small helper
// track.js
export const CATEGORY = { signups: 'signups', billing: 'billing', orders: 'orders' }
export async function track(event) {
try {
await fetch('https://app.getbutters.com/api/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.BUTTERS_API_KEY}`,
},
body: JSON.stringify({ project: process.env.BUTTERS_PROJECT, ...event }),
signal: AbortSignal.timeout(2000),
})
} catch {
// Tracking must never break the thing being tracked.
}
} Call it from your server, never from the browser. An API key belongs to your whole organization and can read events as well as write them, so it has to stay somewhere your users can't see.
Titles for scanning, descriptions for detail
Here's a failed payment coming in from a Stripe webhook:
case 'invoice.payment_failed': {
const invoice = stripeEvent.data.object
const amount = `${(invoice.amount_due / 100).toFixed(2)} ${invoice.currency.toUpperCase()}`
await track({
category: CATEGORY.billing,
title: 'Payment failed',
description: `**${invoice.customer_email}** · ${amount} · attempt ${invoice.attempt_count}`,
icon: '💳',
user_id: invoice.customer,
url: `https://dashboard.stripe.com/invoices/${invoice.id}`,
tags: { attempt: String(invoice.attempt_count) },
notify: invoice.attempt_count >= 3,
})
break
} The title is short and the same every time, so a run of failures reads as a pattern rather than a paragraph. The description carries the specifics and supports **bold** and [links](url). The url makes the event title clickable, straight through to the invoice in Stripe. And notify only fires on the third attempt, when the payment has stopped being a blip and become a customer about to churn.
Tie events to people
Set user_id to your own identifier for the customer on every event you can. It's the thread that connects a signup in March to a failed payment in June.
Search covers titles, descriptions and tags. So if you want to find everything about one customer by typing their account name, put a short identifier into the description or a tag as well. Think before you put personal data there: everyone in your organization can read the feed.
Insights for the numbers you check every morning
Events record what happened. Insights hold a number that is true right now: MRR, active trials, orders today. They show as cards on the dashboard and are keyed by title, so posting the same title again replaces the value rather than adding a new card.
A nightly job is enough to keep them current:
curl -X POST https://app.getbutters.com/api/insight \
-H "Authorization: Bearer $BUTTERS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"project\": \"$BUTTERS_PROJECT\", \"title\": \"MRR\", \"value\": \"$MRR\", \"icon\": \"💰\"}" The value is stored as text, so format it the way you want it read: $12,480 rather than 12480. Zero is a valid value, and a card that reads 0 is sometimes the most important card on the page.
Backfill the history you already have
If your database already knows about last month's orders, you don't have to start the charts from zero. Every event accepts created_at in unix seconds, and events land on the right day in the charts and in the right position in the feed, whatever order you send them in.
for (const order of await db.orders.since(thirtyDaysAgo)) {
await track({
category: CATEGORY.orders,
title: 'Order placed',
description: `Order **#${order.number}**`,
user_id: order.customerId,
created_at: Math.floor(order.createdAt.getTime() / 1000),
})
} That's seconds, not milliseconds, and the difference won't be caught for you. A millisecond timestamp is a valid number, so it's accepted and dated some fifty thousand years in the future, where it sits at the top of your feed for good. A timestamp sent as a string is ignored and the event is dated now. Divide by 1000 and send a number.
What not to send
- Page views and clicks. Use an analytics tool. The feed is for events a person reads.
- Anything that fires on every request. At that volume you're paging through noise to find the signal.
- Secrets and card data. Metadata is visible to everyone in your organization. Treat it like a shared channel, not a vault.
Start with the money path, give it a week, and look at the feed. You'll know within days which events you check and which ones you scroll past. Delete the second kind from your code, and add whatever you found yourself wishing you had.