Impact: attribution in your first week
What you know on day 1, day 3, and day 7 — arrivals, influenced, credited revenue per journey, and your first lift read. The checklist, and exactly what each number does and does not claim.
Adopt Hogsend and you know what your lifecycle work is doing to revenue and your funnel from the first week — per journey, per campaign, per channel, under any attribution lens. This page is the wiring checklist and the honest reading guide for the three numbers.
The three numbers
Impact reporting keeps three numbers separate, always labeled, never blended:
- Attributed — fractional credit that sums to the real total. Eight models (first, last, linear, time-decay, position, blended…) are all computed and persisted at conversion time, so switching models is instant and historical. Claim strength: this touch path earned a share.
- Influenced — coverage: the full value of every conversion a journey touched at all. Deliberately multi-counts across journeys (it's reach, not credit) and never sums to the total. Claim strength: we were in the room.
- Incremental — holdout-backed lift: what would not have happened without the journey. The only number allowed to say "caused". Claim strength: causal.
Everything correlational is labeled correlational in the API and Studio. When an incumbent tool shows you one big "attributed revenue" number, it is usually per-object last-touch with no cross-object dedup — summed, it exceeds what you actually earned. Hogsend shows the overlap instead of hiding it.
The first-week checklist
Day 0 — wire two things (≈15 minutes)
- Arrival capture (2 minutes): add
@hogsend/jsto your landing pages. Ad clicks, UTM landings, and vanity-link arrivals becomecampaign.arrived/link.arrivedtouches. - One revenue webhook (10 minutes): point a Stripe (or order/subscription) webhook at a webhook source so purchases land on the spine with
value+currency.
That's the whole setup. The built-in revenue conversion point fires on any trusted valued event with zero configuration (quote-shaped CRM events are excluded; browser-minted values never count). Author your own defineConversion with id: "revenue" to replace it, or set HOGSEND_DEFAULT_REVENUE_CONVERSION=false.
Optionally, define what progress means for you:
// A funnel is an ordered ladder; stages move on plain events — no CRM needed.
export const selfServe = defineFunnel({
id: "self-serve",
stages: [
{ id: "signed_up", on: "user.signup" },
{ id: "activated", on: "activation.completed" },
{ id: "subscribed", on: "subscription.started" },
],
});
// A milestone is just a conversion point without money.
export const activation = defineConversion({
id: "activation",
trigger: { event: "activation.completed" },
});Day 1 — touches and influenced
Emails click, links get followed, landings arrive. Studio's Impact tab shows touch volume per channel, and the Influenced column starts filling: which conversions each journey/campaign touched at all.
Day 3 — first credits
Contacts who converted with a touch path in the window earn ledger credits. The attribution matrix shows the same conversions under all eight models side by side — with the Unattributed bar keeping the delta explicit (direct/imported conversions are never silently dropped), and the overlap line showing what single-credit reporting would have double-counted.
Day 7 — per-journey revenue, and the first lift read
- "This journey attributed £X under time-decay" is a
groupBy=journeyquery, drill-down per template. - If you set a holdout on a journey (
holdout: { percent: 10 }on its meta), the lift endpoint compares entered vs held-out cohorts. Withgoalset on the journey's meta, the endpoint defaults to that conversion definition. The win probability is suppressed under 10 combined conversions and flagged under 100 contacts per cohort — at low volume you get honest "not enough data yet", never noise wearing a percentage sign.
Already running Hogsend? Credit your whole history today
hogsend attribution backfillReplays your entire event history through conversion evaluation and the credit ledger — both idempotent, so re-running never double-counts, and ad-platform dispatches are never re-fired for historical conversions. A window/definition change gets a clean recompute: hogsend attribution backfill --definition revenue --recompute.
Bind a goal
meta.goal names the conversion this journey exists to move — a defineConversion id, including the built-in zero-config revenue conversion when it is seeded:
export const activationWelcome = defineJourney({
meta: {
id: "activation-welcome",
name: "Activation — Welcome Series",
enabled: true,
trigger: { event: "user.created" },
entryLimit: "once",
suppress: hours(12),
exitOn: [{ event: "user.deleted" }],
holdout: { percent: 10 },
goal: "revenue",
version: "2026-07-welcome-subject-ab",
},
run: async (user, ctx) => {
/* ... */
},
});The binding is boot-validated, fail-closed: a goal that matches no registered conversion definition throws at createHogsendClient with the known-id list. A typo'd id would otherwise match zero conversion rows and every default readout would quietly report 0% for both cohorts — a wrong-but-plausible number, worse than a crash. The seeded revenue conversion counts, and counts only when actually seeded: HOGSEND_DEFAULT_REVENUE_CONVERSION=false without an authored replacement makes goal: "revenue" a boot error.
The lift and impact endpoints use the goal as their default conversion definition; an explicit ?definitionId= query param always wins. The goal never gates enrollment, sends, or conversion firing — it is purely a readout default.
Holdout placement, from our own deployment: 10% on steady lifecycle journeys, 15% on winbacks — effects are small and the cost of withholding from dormant users is low, so a bigger control buys resolution cheaply — and none on dunning, where withholding a payment-failure notice is forfeited revenue, not learning. When several journeys share one trigger event and hold out independently, each measured lift is marginal on top of the others' sends; the numbers never add up to a combined program effect.
Ending a holdout
Removing holdout from the meta is the graduation mechanism: subsequent triggers enroll previously-held-out users normally. Their aged held_out row keeps anchoring the old versions' lift readouts — by design, it is that era's control. The digest reads the resulting cohort shift as the new version's numbers.
Versions: which edit moved the number
Every enrollment — and every held-out row — is stamped with a content fingerprint of the journey definition that created it, plus the optional meta.version display label. The impact readout groups cohorts by hash, so "conversion rate before vs after the rework" is a table, not archaeology.
Edit the run body — a subject line, a sleep, a branch — or the meta: the hash changes. Toggling enabled does not. Template component edits (src/emails/) are NOT in the hash; bump the version label when you rework a template — the digest reports label changes as shipped changes.
The label is display, the hash is truth: readouts group by hash, and changing only the label never forks a cohort. Rows created before this feature form the "unversioned" cohort. Treat a new hash as "possible new version", not proof of change — a toolchain bump can fork a hash with zero content edits.
Variants: split inside the treatment
ctx.variant is the deterministic A/B primitive: a pure sha256 bucket over (journeyId, key, userId) — no RNG, no clock.
const arm = await ctx.variant("welcome-subject", ["setup", "outcome"]);
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.ACTIVATION_WELCOME,
subject:
arm === "outcome"
? "Welcome to Hogsend — your first journey live in 15 minutes"
: "Welcome to Hogsend — let's get you set up",
});Two separate guarantees:
- Within one enrollment, the assignment is recorded once and the recorded value wins verbatim on every later call — including a replay after a deploy that changed the arms array.
- A re-entry mints a new enrollment and re-derives the arm from the same deterministic hash, so it gets the same arm as long as the arms array is unchanged. Editing arms between entries may reassign re-entrants.
Holdout diverts before run() executes, so variants split the treatment cohort only; per-variant lift compares each arm against the whole held-out cohort. Sending the same template in every arm needs no idempotencyLabel — the arm is deterministic and recorded, so a replay re-derives the identical send key. When arms select different templates, the template key is the send key's discriminant and the recorded arm keeps it pinned across replays; the pre-existing rule still stands — if a later unconditional send can hit the same template as one of the arms under the same nearest wait label, give one of them a distinct idempotencyLabel.
The impact readout, and the digest
Two admin endpoints serve the readout:
GET /v1/admin/journeys/{id}/impact— one journey: overall treatment-vs-control lift, a per-version timeline where each version's control is the held-out rows carrying the same hash, and per-variant arms with an observational open/click funnel.GET /v1/admin/impact/overview— every journey ranked by converters, attributed value under a chosen model, campaigns (correlational only, always), and the global-control readout.
Every block carries an explicit causal flag. causal: true appears only where a held-out cohort backs the number; everything else is labeled observational — including cross-version rate comparisons, where older versions had longer post-entry conversion exposure.
Suppression is absolute: under 10 combined conversions the verdict is suppressed — counts and a "collecting" state, never a percentage. Under 100 contacts per cohort the number is flagged smallSample — shown, with the warning, never hidden.
The weekly digest is a webhook, not an email. Subscribe an outbound endpoint to the impact.digest event type and it delivers two kinds of entries: shipped (a new version hash, new journey, or new label observed — observational, structurally without lift fields) and lift (a journey's holdout win probability crossed the threshold, in either direction). No subscriber, no work; empty digests are never sent. Knobs: IMPACT_DIGEST_CRON (default 0 9 * * 1, Mondays 09:00 UTC) and IMPACT_DIGEST_WIN_PROB (crossing threshold, default 0.95, clamped 0.5–0.999).
Reading guide — what each surface claims
| Surface | Number | May claim |
|---|---|---|
Attribution matrix (/v1/admin/attribution) | attributed | "earned a share of" |
| Influenced column | influenced | "touched" |
Funnel progression (/v1/admin/funnels/:id/progression) | correlational | "associated with" — exposure splits self-select engaged contacts |
Journey lift (/v1/admin/journeys/:id/lift) | incremental | "caused" — the only one |
Journey impact (/v1/admin/journeys/:id/impact) | mixed, per-number causal flag | "caused" only where causal: true |
Impact overview (/v1/admin/impact/overview) | mixed, per-number causal flag | "caused" only where causal: true |
Attribution windows are configurable per channel (windows: { email: days(5), sms: days(1) } on defineConversion) and apply forward only — the ledger is written at conversion time; the backfill command is the deliberate recompute path.
One asymmetry worth knowing: a journey excluded by the ENABLED_JOURNEYS filter is never registered, so its lift/impact readout falls back to "any conversion" (source: "none") even though its code declares a goal — while a meta.enabled: false journey stays registered and keeps its goal default. Blueprint journeys are DB-authored and cannot declare a persisted meta.goal; their readout is source: "none" unless the request passes ?definitionId=.
Is it wired? Ask the engine
The Studio Setup checklist (and GET /v1/admin/readiness) includes four attribution rows — arrival capture seen, valued events flowing, conversions firing, credits accruing — each naming the exact missing wire when the answer is "not yet".
Conversions & Ad-Platform Feedback
Declare conversion points in code, resolve their value, and feed them back to ad platforms server-side — first-party click capture, durable dispatch, deterministic dedup.
Conversion timing
How long after a journey enrollment or an event your conversions actually fire — median and 90th-percentile time-to-convert, and the share that convert within 1, 7, 14, and 30 days.