Hogsend is brand new.Try it
Hogsend
Building

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:

  1. value + currency are first-class on every event — not a property convention, a real column the engine can aggregate, filter, and feed to conversion points.
  2. A deals ledger projects CRM stage changes into canonical, monotonic pipeline stages — who is quoted, who bought, for how much.
  3. Per-contact revenue rolls up from valued events, so the contacts list can answer "show me the people worth over £5k".
  4. 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:

HTTP
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" } }'
@hogsend/client (server)
await hogsend.events.send({
  name: "order.completed",
  email: "ada@example.com",
  value: 149,
  currency: "GBP",
});
@hogsend/js (browser)
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 stitchinglead 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.arrived and 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:

src/funnels/index.ts
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:

src/index.ts
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 at quoted).

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 minRevenue and dealStage filters, 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/currency on 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.