Revenue & LTV
A purchase in AgentHog is an ordinary custom event with the money in a prop. You map that prop once — which event carries money, and where the amount lives — and from then on revenue is a measure everywhere the product already measures things: totals, breakdowns by any dimension, a time series, the digest, cohort LTV, and the /revenue page.
The mapping is retroactive. Revenue is computed from the props already sitting in your events, so mapping today backfills every purchase you have ever sent. Nothing is re-ingested, no data is rewritten, and ah revenue unmap reverses it. You are not losing history by instrumenting first and mapping later.
1. Send the purchase
Nothing here is revenue-specific — it is a normal event with the amount as a prop. Send it server-side with a token from your Stripe / RevenueCat / app-store webhook rather than from the client: real timestamps, no bot scoring, and no client-tamperable revenue numbers.
// from your webhook handler — see /docs/server for the full relay shape
{
"project": "ah_xxxxxxxx",
"anonId": "server:user_8812",
"sessionId": "<a fresh uuid per event>",
"identify": { "email": "[email protected]", "traits": { "user_id": "user_8812" } },
"events": [{
"ts": 1766000000000,
"type": "custom",
"name": "checkout_completed",
"path": "/",
"props": { "cents": 4900, "currency": "USD", "plan": "pro" }
}]
}Client-side purchases work too and need no extra flag — but a webhook is the recommendation, and the rest of this page assumes it.
Three habits that make this painless later
- Pick one money prop and never vary it. An integer
centsis the safest choice. Mixingcentson one event andprice_usdon another is the thing that makes this annoying. - Send
currencyalongside it if you sell in more than one. - Keep
plan/tier/periodas separate scalar props, so--by props.planworks without parsing anything.
2. Map it
Start by letting AgentHog find the mapping. ah revenue suggest scans the props already in your data, proposes the event, the amount prop, the unit and the currency, and prints the exact commands to run.
ah revenue suggest
REVENUE SUGGEST — acme · scanned 30d · 3 candidate mappings event prop events numeric median sample reading status -------------------- --------- ------ ------- ------ ------ ------------------------------- ------ checkout_completed cents 4912 100% 4900 4900 minor units — 100% divisible… new subscription_renewed price_usd 881 100% 9.99 9.99 major units — two decimals new refund_issued cents 37 100% 4900 4900 minor units — 100% divisible… new Run: ah revenue map checkout_completed --amount cents --unit minor --currency USD --kind purchase ah revenue map subscription_renewed --amount price_usd --unit major --currency USD --kind subscription ah revenue map refund_issued --amount cents --unit minor --currency USD --sign -1 --kind refund ah revenue suggest --apply # write all three
The numeric column is the one to read carefully: it is the share of that event's occurrences whose amount prop actually parses as a number. A prop that parses on 62% of them is a bad mapping, and noticing that here beats discovering it inside a wrong total three weeks later.
--apply writes every new proposal and prints exactly what it wrote. Or map by hand, then confirm the mapping landed on real events:
ah revenue map checkout_completed --amount cents --currency USD ah revenue config
ah revenue config is not just a listing — it runs a live count per mapped event, so a typo'd event name shows up as 0 events and a partly-parsing prop shows up as a warning, at map time.
Mapping fields
| field | what it means |
|---|---|
--amount <prop> | The prop key holding the amount. Required. |
--unit minor|major | minor (the default) means the prop holds 4900; major means it holds 49.00. Internally everything is integer minor units, so amounts never accumulate float error. |
--currency USD | A fixed ISO-4217 code. If you declare neither this nor --currency-prop, USD is recorded in the config — so what the numbers were summed as is always visible, never inferred at read time. |
--currency-prop <prop> | Read the code per event instead. Wins over --currency, which stays the fallback for rows where the prop is missing or not a 3-letter code. |
--sign -1 | Make the event subtract. --kind refund implies it, so you rarely pass both. |
--kind purchase|subscription|refund | Labelling for reports — plus the sign default above. |
--match prop=value | Only count events carrying that prop value, e.g. --match environment=PRODUCTION to keep sandbox purchases out. An absent prop counts as a match, so history predating the prop is not silently dropped. |
--sessions server | Restrict a source to token-authed (webhook) events only. Omit it and both client- and server-sent purchases count. |
3. Read it
ah revenue # the headline block ah revenue --series week # revenue over time ah revenue --by utm_source # which channel earns ah revenue --by props.plan # which plan earns ah revenue --by flag:checkout_cta # revenue per A/B variant ah revenue --by geo_country --currency USD
REVENUE — acme · 30d · USD net $41,203.00 gross $42,910.00 refunds -$1,707.00 orders 874 payers 612 AOV $49.10 active 14,208 ARPU $2.90 ARPPU $67.32
| number | definition |
|---|---|
| net | gross + refunds — the headline |
| gross | the sum of positive amounts |
| refunds | the sum of negative amounts (so it prints negative) |
| orders | paying events |
| payers | distinct people with a positive amount |
| active | distinct people active in the window. Computed by the same function ah active uses, but not the same number by default — revenue folds server sessions into the filter, so a webhook payer who never browsed counts here and not there |
| ARPU | net ÷ active |
| ARPPU | net ÷ payers |
| AOV | gross ÷ orders |
The same numbers render on the /revenue dashboard page and appear as a block in ah digest, with deltas against the previous window. They all read one payload, so they cannot disagree.
How a webhook purchase gets a channel
A purchase relayed from Stripe or RevenueCat arrives on a server session that has no utm_source, no country and no A/B assignment of its own — so a naive breakdown would put all your revenue in one enormous unattributed bucket.
So --by splits on two different rules, depending on what the dimension is. A dimension the payment itself carries — any props.<key> — is read from the payment and nowhere else: --by props.plan splits by the plan each purchase was actually for, and a purchase with no plan prop shows as (none) rather than inheriting a plan its payer used months earlier. A dimension only a person can carry — session columns like utm_source or geo_country, and flag:<key> — falls back to the payer's own value, because a webhook structurally cannot carry one. The ARPU denominator resolves the dimension the same way, so numerator and denominator always agree about what a bucket means.
This works because a device session and a webhook resolve to the same person: coalesce(email, traits.user_id, anon_id). Sending identify with your webhook — as in the snippet above — is what makes that link exist.
Cohort LTV
ah ltv shows cumulative revenue per person by acquisition cohort. A cohort is everyone whose first session fell in that week (or day, or month) — over all time, not truncated to the window, so a returning visitor is not mis-cohorted as new.
ah ltv # weekly cohorts × 12 periods, $/person ah ltv --cohort month --periods 12 ah ltv --by utm_source # which channel buys the better customers ah ltv --absolute # total cohort revenue instead of per-person
LTV (cumulative revenue per person) — acme · weekly cohorts · USD cohort persons w0 w1 w2 w3 w4 2026-06-01 412 $12.40 $19.80 $24.10 $26.90 $28.30 2026-06-08 388 $11.90 $18.20 $23.40 $25.10 ·
A · means that period has not elapsed for that cohort yet — which is not the same as $0.00, and an incomplete period must never read as a cohort that stopped spending. Note also that cumulative net can go down: a refund in week 3 lowers the running total.
This is historical LTV — what the cohort has actually paid so far. It is not a predicted or modelled LTV, and nothing in AgentHog pretends otherwise.
Four rules that keep the numbers honest
| rule | why |
|---|---|
| Amounts are summed per currency, never across | Selling in three currencies gets you three blocks, not one silently wrong total. There is no FX conversion; --currency USD narrows to one. |
| Refunds are negative amounts, not a hidden category | Net is the default reported number, with gross and refunds beside it — so nobody has to guess which one they are looking at. |
| Unparseable amounts are counted, never dropped | A mapped event whose amount prop is missing or non-numeric is reported as unparseable. A quiet zero is the classic way revenue analytics lies. ah revenue config shows which event is doing it. |
| Money is integer minor units end to end | Floats never accumulate across rows, and the ISO-4217 exponent is respected — ¥4,900 is 4,900 yen, not ¥49.00. |
Revenue in experiments
Experiment results include a revenue-per-variant table once revenue is mapped — counting only payments at or after each user's first exposure, and attributing webhook payments to the person who was exposed.
It is reported descriptively, and deliberately does not feed the ★ winner verdict. Revenue per user is heavy-tailed and mostly zero: a single large order moves the mean more than the experiment does, so a winner declared on it would not be defensible. The verdict stays conversion-based, and the table prints the payer count next to each arm so you can see how few observations the mean rests on.
What this does not do
- No currency conversion. Amounts stay in the currency they arrived in.
- No predicted LTV. See above.
- No MRR, expansion or contraction. Those need modelled subscription state, not just events.
- No tax or fee netting. Amounts are whatever you send. If you want net-of-Stripe-fees, send a
net_centsprop and map that instead.
Anything the verbs do not cover is one query away: ah sql is read-only and locked to your own projects, and revenue is a GROUP BY over events joined to identities.
Next
Not sending purchases yet? Server to server has the webhook relay. New to the CLI? The ah CLI covers install and login. Every verb on this page takes --json, and ah revenue --help / ah ltv --help carry the full flag list.