Semantic links
In-email surveys and one-tap actions — links whose clicks fire real events, with EmailAction.
A plain tracked link tells you it was clicked. A semantic link tells you
what the click meant: "yes", "I'm stuck", "NPS 9". The click is the form
submission — recorded server-side at the redirect, routed to journeys, stored
in user_events, and fanned out to your destinations. No landing-page wiring,
no JavaScript in the email, no polling.
Real <form> elements in email are a dead end — JavaScript is stripped
universally and the major clients break forms — so the working pattern across
the industry is every answer is a link. A yes/no question is two links; an
NPS survey is eleven. EmailAction makes those links carry their meaning.
Quick example
In a template, each answer is an EmailAction — an anchor that carries an
event name and a scalar payload:
import { EmailAction } from "@hogsend/email";
import { Events } from "../journeys/constants/index.js";
<Section className="my-6 text-center">
<EmailAction
event={Events.CHECKIN_ANSWERED}
properties={{ answer: "yes" }}
href="https://app.example.com/thanks"
className="mx-1 inline-block rounded-lg border px-5 py-2"
>
Going great
</EmailAction>
<EmailAction
event={Events.CHECKIN_ANSWERED}
properties={{ answer: "no" }}
href="https://app.example.com/thanks"
className="mx-1 inline-block rounded-lg border px-5 py-2"
>
I'm stuck
</EmailAction>
</Section>In the journey that sent it, wait for the answer and branch on the payload:
const answer = await ctx.waitForEvent({
event: Events.CHECKIN_ANSWERED,
timeout: days(5),
label: "await-answer",
});
if (answer.timedOut) return; // no answer — leave them be
if (answer.properties?.answer === "no") {
await sendEmail({
to: user.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.ACTIVATION_NUDGE,
subject: "Let's get you unstuck",
journeyName: user.journeyName,
});
}That's the whole loop: a question in an email, a durable wait, a branch on the answer.
How it works
- At send time the engine's link rewriter lifts the
event+propertiesoff each anchor into itstracked_linksrow and strips the attributes — the metadata never reaches the inbox. The in-HTML encoding is internal wire format; the database row is the contract. - At click time the recipient is redirected as normal (the
hrefis where they land), and the click is recorded as a provisional answer. - ~30 seconds later a confirmation task judges the click with the whole
scanner-burst window visible (see below), then emits the event through the
full ingest pipeline:
user_events, journey routing, exit checks, and theemail.actionoutbound envelope to your destinations.
Answer semantics
- First answer wins, per (send, event name). An NPS row of eleven buttons shares one answer slot — only the first confirmed score counts. Repeat clicks and later different answers are recorded as raw clicks but not re-emitted.
- Scanner bursts are suppressed. Corporate mail gateways (Outlook SafeLinks, Proofpoint) follow every link in an email within seconds of delivery. Because confirmation is deferred past the burst window, the gate sees the whole burst — including the scanner's first click — and suppresses it. The cost is ~30 seconds of answer latency, which is invisible to a journey waiting on a days-scale timeout.
- The generic events still fire. A semantic click also records
email.link_clickedper hit, exactly like any tracked link. The semantic event is additional, under its own (your) name.
Rules the engine enforces
A violation fails the send, loudly, so you find out in development:
eventmust not use an engine-reserved namespace —email.,journey.,bucket.,contact.(dot or colon form).propertiesmust be a flat object of scalars (string | number | boolean | null), under 2 KB as JSON. Non-scalars don't survive the event wire.- The
hrefmust be an absolutehttp(s)URL (it doubles as a normal tracked link), and not an unsubscribe/preferences URL.
Two EmailActions may share the same href with different events or
properties — they get separate tracked links, so "yes" and "no" can both land
on the same thanks page.
Reading the answer in a journey
ctx.waitForEvent() returns the matched event's payload:
const answer = await ctx.waitForEvent({
event: Events.NPS_SUBMITTED,
timeout: days(3),
});
if (!answer.timedOut && typeof answer.properties?.score === "number") {
const score = answer.properties.score;
// identify, branch, trigger follow-ups…
}Two rules worth copying into every journey that does this:
- Don't put the awaited event in
exitOn. An exit match mid-wait aborts the run before your post-wait branch executes. React viawaitForEventor exit viaexitOn— one event name, one role. - Waiting twice for the same event (say, after a reminder send)? Pass
lookbackon the second wait. The wait is forward-looking, so an answer landing in the gap between the two waits would otherwise be missed;lookbackchecks recentuser_eventsfirst and resolves immediately, payload included:
answer = await ctx.waitForEvent({
event: Events.NPS_SUBMITTED,
timeout: days(7),
lookback: hours(1), // covers the gap since the first wait timed out
});Cross-journey fan-out
Because the answer is a real ingested event with properties, a separate journey can trigger on it with a property condition — no coupling to the journey that asked the question:
export const detractorRescue = defineJourney({
meta: {
id: "detractor-rescue",
trigger: {
event: Events.NPS_SUBMITTED,
where: (b) => b.prop("score").lte(6),
},
entryLimit: "once_per_period",
entryPeriod: days(30),
},
run: async (user, ctx) => {
await ctx.sleep({ duration: hours(2), label: "cool-off" });
await sendEmail({ /* a personal follow-up */ });
},
});Destinations and PostHog
Confirmed answers emit an email.action envelope on the outbound spine —
durable, signed, retried like every other destination delivery. The PostHog
preset captures it under your event name (nps.submitted, not
email.action) with the properties flattened, so the answer is immediately
usable in insights, cohorts, and flags.
The hosted answer page
No landing page? Point the action at the engine's own:
import { EmailAction, HOSTED_ANSWER_HREF } from "@hogsend/email";
<EmailAction event="checkin.answered" properties={ANSWER_YES} href={HOSTED_ANSWER_HREF}>
Going great
</EmailAction>The sentinel resolves at send time to GET /v1/t/a/:linkId — a minimal
engine-served page (same trust model as unsubscribe: possession of the
unguessable link is the auth) that confirms the recorded answer and offers an
optional free-text box. A submitted comment ingests as <event>.comment
(checkin.answered.comment) with the original answer's properties attached —
a real event journeys can wait on and destinations receive. One comment per
(send, event); repeats are no-ops.
Cross-device identity (hs_t)
With TRACKING_IDENTITY_TOKEN=true, every tracked-link redirect appends a
short-lived hs_t token to the destination URL. Your landing site exchanges
it for the distinct id and identifies the session:
// on the landing site, after arrival
const res = await fetch("https://api.example.com/v1/t/identify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: params.get("hs_t") }),
});
const { distinctId } = await res.json();
posthog.identify(distinctId); // the email click and the web session mergeThe token is encrypted (AES-256-GCM keyed off BETTER_AUTH_SECRET), not
merely signed — the distinct id can be an email address, and nothing readable
may sit in a URL, browser history, or a referrer header. Tokens expire after
an hour; tampering fails decryption. Strip hs_t from the address bar after
the exchange, and gate the identify call behind whatever analytics consent
your site operates under. Opt-in by design: appending a parameter changes
outbound URLs, which can break pre-signed destinations.
What it is not
- Not for destructive actions. "Cancel my account" should never be one click from an email — a scanner spreading clicks beyond the burst window could in principle slip one through. Semantic links are for answers, not irreversible operations.
- Not free text in the email itself. The answer space is whatever you can
enumerate as links; the click records the structured part. For the comment,
the hosted answer page already collects one (
<event>.comment) — or land on your own page and do the same.
See also: Tracking API for the click mechanics,
Journeys for waitForEvent, and
Destinations for the outbound spine.
Lifecycle emails through a swappable provider (Resend by default) — React Email templates, bounce tracking, unsubscribe management, and deliverability monitoring.
Lists
Code-defined email subscription categories — declare a list with defineList(), pick its opt-in vs opt-out polarity, and the engine wires suppression, the preference center, and the runtime API automatically.