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 cents is the safest choice. Mixing cents on one event and price_usd on another is the thing that makes this annoying.
  • Send currency alongside it if you sell in more than one.
  • Keep plan / tier / period as separate scalar props, so --by props.plan works 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

fieldwhat it means
--amount <prop>The prop key holding the amount. Required.
--unit minor|majorminor (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 USDA 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 -1Make the event subtract. --kind refund implies it, so you rarely pass both.
--kind purchase|subscription|refundLabelling for reports — plus the sign default above.
--match prop=valueOnly 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 serverRestrict 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
numberdefinition
netgross + refunds — the headline
grossthe sum of positive amounts
refundsthe sum of negative amounts (so it prints negative)
orderspaying events
payersdistinct people with a positive amount
activedistinct 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
ARPUnet ÷ active
ARPPUnet ÷ payers
AOVgross ÷ 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

rulewhy
Amounts are summed per currency, never acrossSelling 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 categoryNet 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 droppedA 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 endFloats 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_cents prop 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.