Revenue tracking
First-class value on events, the deals ledger with canonical stages, per-contact revenue, and the Studio surfaces — know what every contact and campaign is actually worth.
Lifecycle marketing without revenue data is flying blind: you can see opens, clicks, and journeys completing, but not which of it made money. Hogsend closes that loop with a revenue spine that runs through the whole engine:
value+currencyare first-class on every event — not a property convention, a real column the engine can aggregate, filter, and feed to conversion points.- A deals ledger projects CRM stage changes into canonical, monotonic pipeline stages — who is quoted, who bought, for how much.
- Per-contact revenue rolls up from valued events, so the contacts list can answer "show me the people worth over £5k".
- Studio puts it front and center: a Deals board and revenue stats, and long-tail value filters on Contacts.
Value on events
Any event can carry a monetary worth. It lands on user_events.value
(numeric(14,2)) with an ISO-4217 currency, distinct from the property
bags:
curl -X POST $API/v1/events \
-H "Authorization: Bearer $HOGSEND_DATA_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "order.completed", "email": "ada@example.com",
"value": 149.00, "currency": "GBP",
"eventProperties": { "sku": "starter" } }'await hogsend.events.send({
name: "order.completed",
email: "ada@example.com",
value: 149,
currency: "GBP",
});hogsend.capture("checkout.completed", { sku: "starter" }, {
value: 149,
currency: "GBP",
});Malformed money is dropped, not stored: non-finite numbers are rejected at
the schema, and currency is uppercased and ignored without a value.
Totals are never summed across currencies — every rollup is per-currency.
Browser events are pk_-trust-tier: anyone can mint them with any value.
They're stored — but conversion points reject
browser-sourced events by default (the forged-value guard) and revenue
rollups skip browser-minted values for the same reason, so a spoofed
value reaches neither an ad platform nor your reporting. Send
money-bearing events server-side.
Ad-click capture
Revenue attribution starts at the ad click. When a visitor lands with a click
ID (fbclid, gclid, ttclid, msclkid, li_fat_id, rdt_cid, …) or
utm_* params in the URL, @hogsend/js fires campaign.arrived
automatically — one event per distinct attribution set per session — and
persists the set as last-touch. That single event is what later powers:
- identity stitching — lead intake ties the browser session to the email-anchored contact, so the click and the buyer are one person;
- click-evidence recovery — when a conversion fires, the engine finds the
contact's most recent
campaign.arrivedand hands its click IDs (with the real click timestamp) to conversion destinations like Meta CAPI.
hogsend.getAttributionFields() exposes the current set for form hidden
fields — see Lead intake.
The deals ledger
Deals are a projection, not a second source of truth. Stage changes
arrive as events — your own server-side events, or a CRM's webhooks/poll —
each one is stored on the contact's timeline, and the deals table projects
the latest state per deal: provider, canonical stage, value, and the
timestamps that matter (quotedAt, soldAt, lostAt).
Funnels — event-native, code-first, plural
A funnel is a first-class primitive authored like a journey: your ordered stages, the money milestones among them, and the events that move a contact between them. No CRM required:
import { crmPipeline, defineFunnel } from "@hogsend/engine";
export const selfServe = defineFunnel({
id: "self-serve",
stages: [
{ id: "trial", on: "trial.started" },
"activated", // plain string = no event trigger
{ id: "quoted", milestone: "quoted", // mints deal.quoted
on: { event: "quote.sent", where: (b) => b.prop("total").gte(1000) } },
{ id: "subscribed", milestone: "won", // mints deal.sold
on: "subscription.created" },
],
lostOn: "subscription.cancelled",
// Trust allowlist for event triggers (like defineConversion.sources).
// Default: every source EXCEPT browser `inapp` — a pk_ event can't
// forge a stage jump.
sources: ["stripe", "api"],
// Optional CRM leg — composable bindings, not a config tree. The
// (provider, pipeline) pair is the traffic claim; `stages` maps native
// stage ids to YOUR stages (or pass `resolve: (e) => ...` for logic).
bindings: [
crmPipeline({ provider: "ghl", pipeline: "residential",
stages: { "quote-sent": "quoted", "job-won": "subscribed" } }),
],
});
createHogsendClient({ funnels: [selfServe, commercial] });When a contact triggers a stage's on event (source-gated, where
passing), their open deal in that funnel moves — whichever producer created
it. No deal yet? One is minted under the synthetic events provider (one
per contact per funnel; an explicit deal_id event property addresses
multi-deal cases; once that one deal closes, a later trigger holds rather
than opening a second cycle — re-entry is deliberately deferred). lostOn
only ever closes an existing deal. And the hybrid flow works both ways: an
event can open the deal and the CRM close it — the CRM leg adopts the
contact's open event-minted deal (the implicit single-deal row;
deal_id-addressed rows stay separate by design) the first time it sees
the native deal id, instead of shadowing it with a sibling.
Milestones sit on the stage they describe: milestone: "quoted" mints
deal.quoted (the mid-funnel money signal), milestone: "won" mints
deal.sold (revenue realized) — once per deal, ever, valued from the deal.
A funnel with no won milestone never mints deal.sold and contributes
£0 realized revenue: stages and counts still work (note: such a funnel's
terminal-stage deals read as "open pipeline" in stats — a cosmetic count
wrinkle, plus whatever value its trigger events carried). All-string
stages keep legacy defaults (sold = the last stage, quoted = a stage
literally named "quoted"); the moment ANY entry is an object, milestones
are explicit-only.
CRM traffic routes to the funnel whose binding claims its
(provider, pipeline) — exact beats provider-wide "*", overlapping claims
throw at boot, a funnel's own "*" binding doubles as a per-stage fallback
for its pipeline-specific maps. Deals carry funnel_id, the money events
carry it as a property (so conversion points and journeys scope per funnel
with a where), and Studio's dashboard gets a funnel switcher. A deal
belongs to one funnel for life: a stage event arriving from another
funnel's pipeline still lands on the event timeline but never rewrites the
deal's projection.
Rank comes from array order; lost stays the implicit terminal; a typo in
a binding's stage map throws at boot with the exact path. Single-pipeline
CRM deployments can skip defineFunnel entirely — the shorthand
crm: { stages, stageMaps } is sugar for one "default" funnel (its
stages takes the same entries). The default ladder:
lead → contacted → survey_booked → quoted → sold (and: lost)The projection is monotonic: a late-arriving lower-stage event can
never regress a deal that's already further along, and lost never
overwrites the sold stage. Out-of-order delivery and webhook/poll
double-detection heal themselves.
The money events stay stable across any ladder (journeys and conversion
points never chase your naming): deal.quoted, deal.sold. Both
land on the contact's timeline from every producer and can trigger
journeys like any other event — a post-sale onboarding journey triggers on
deal.sold with zero extra wiring. All three machinery events (plus
funnel.stage_changed) are on the
outbound webhook catalog; note that
funnel.stage_changed itself only reaches the timeline (and journeys) for
CRM-produced changes — for event-driven moves the triggering event IS the
timeline row, and funnel.stage_changed goes outbound only. One
accounting rule to know: any event name wired to a milestone stage is
excluded from revenue rollups (its value arrives via the minted money
event, so a sale counts exactly once); non-milestone triggers keep their
values in the rollups.
Connecting a CRM
The CrmProvider contract (defineCrmProvider from @hogsend/engine) is a
thin wire: verify + parse the CRM's webhook into normalized stage events, and
optionally poll (reconciliation sweep, cron every 10 minutes) and hydrate
(fetch full records for thin webhooks). Providers register on the client and
receive webhooks at POST /v1/webhooks/crm/:providerId:
const client = createHogsendClient({
crm: {
providers: [myCrmProvider],
// Native (pipeline → stage → YOUR stage); "*" = any pipeline.
stageMaps: {
"my-crm": {
"*": { "New Enquiry": "lead", "Survey Booked": "survey_booked",
"Quote Sent": "quoted", "Job Won": "sold" },
},
},
},
});Identity resolves without guesswork: a crm_links alias map ties the CRM's
contact/deal IDs to the Hogsend contact (established when the lead is pushed
or first seen), with email fallback — so an email-less stage webhook still
lands on the right person. The repo carries reference provider
implementations for GoHighLevel, Attio, and HubSpot
(packages/plugin-ghl, plugin-attio, plugin-hubspot).
Per-contact revenue
Every contact's valued events roll up to per-currency totals — surfaced on
GET /v1/admin/contacts/{id} and in the Studio contact drawer. Only
realized money counts: the rollup skips deal.quoted,
funnel.stage_changed, and any funnel milestone trigger rows (one
deal's value rides several timeline events — counting them all would
multiply a single sale) and browser-minted values (forgeable). A sold deal
contributes exactly once, via deal.sold. The contacts list API gains two
long-tail filters:
minRevenue— only contacts whose valued events sum to at least this much (per-currency).dealStage— only contacts with a deal currently at a canonical stage (e.g. everyone sitting atquoted).
Combine them with the existing property/bucket filters to find your value customers: "quoted over £10k, hasn't opened the last two emails" is a filter, not a spreadsheet afternoon.
Studio surfaces
- Deals (
/deals) — revenue stats up top (sold last 30 days, lifetime, open pipeline, average order value, average time-to-close — all per-currency), then the pipeline board: six canonical-stage columns with every deal's value, provider, and age. The "who is in which bucket" view. - Contacts — the
minRevenueanddealStagefilters, plus a revenue block in the contact detail drawer showing per-currency totals and the valued-event history.
Multi-model attribution
Every conversion writes an attribution credit ledger at the moment it
fires: the engine reads the contact's touchpoint path (ad clicks, email/SMS
clicks, lead forms) inside the conversion point's lookback window
(attributionWindowDays, default 90) and persists all eight models'
allocations — first touch, last touch, last non-form, linear, time decay,
position-U, position-W, and blended. Because every model is stored up
front, switching models in reporting is instant and consistent — history is
never re-derived under new rules.
The Studio dashboard's Attribution tab shows credited revenue per
channel under any model, and the full model-comparison matrix side by side.
A channel that only looks good under one model is telling you something.
The models themselves live in @hogsend/attribution as pure functions —
usable in your own code:
import { computeCredits } from "@hogsend/attribution";
computeCredits("timeDecay", touchpoints, {
conversionAt: Date.now(),
halfLifeDays: 7,
});Closing the loop
Revenue in Hogsend isn't just reporting — it feeds back out:
- Conversion points declare which events (e.g.
deal.sold) are conversions, resolve their value, and dispatch them to ad platforms with recovered click evidence, so Meta/Google optimize toward revenue, not clicks. - The analytics mirror forwards
value/currencyon captured events, so your PostHog dashboards see the same numbers. - Journeys trigger on money events — the engine that sent the nurture sequence knows the moment it paid off.
Lead intake
Any form vendor → the canonical lead.submitted event — identity-stitched to the visitor's ad-click session, value-bearing, idempotent across webhook retries.
Lifecycle emails through a swappable provider (Resend by default) — React Email templates, bounce tracking, unsubscribe management, and deliverability monitoring.