Hogsend is brand new.Try it
Hogsend
Recipes

Verification chase

Send verify-email transactionally with hs.emails.send, then chase it with a defineJourney() — ctx.waitForEvent on user.email_verified with a 24-hour timeout, up to two re-sends, and exitOn the verification.

Email verification is two different sends with two different rules. The first verify-email is transactional — hs.emails.send from your signup handler, fired because the user just asked to create an account. The chase is lifecycle: a journey that waits 24 hours for user.email_verified, re-sends at most twice, and exits the instant the token is redeemed. Keeping the chase in a journey means there is no cron scanning for unverified users and no way to mail someone who already verified.

StageHow you express it
First send, at signuphs.emails.send({ template: "transactional/verify-email" })
Detect verificationctx.waitForEvent({ event: "user.email_verified", timeout: hours(24) })
Re-send, at most twicetwo sendEmail(…) calls with escalating subjects
Stop the moment they verifymeta.exitOn: [{ event: "user.email_verified" }]
Respect preferences on the chasectx.guard.isSubscribed() — no skipPreferenceCheck

The first send is transactional

The signup handler sends the verify-email and fires the event that starts the chase, with the @hogsend/client SDK:

// your signup handler
import { hs } from "./lib/hogsend.js";

await hs.emails.send({
  to: user.email,
  template: "transactional/verify-email",
  props: {
    firstName: user.firstName,
    verifyUrl: `https://app.example.com/verify?token=${token}`,
  },
});

await hs.events.send({
  name: "user.signed_up",
  email: user.email,
  userId: user.id,
  eventProperties: {
    first_name: user.firstName,
    // a re-link endpoint, not the raw token — it mints a fresh token on
    // click, so the second re-send still works after the original expires
    verify_url: `https://app.example.com/verify/resend?u=${user.id}`,
  },
  idempotencyKey: `signed-up-${user.id}`,
});

When the token is redeemed, the verify endpoint fires the event that resolves the wait and exits the journey:

// your verify endpoint, after the token checks out
await hs.events.send({
  name: "user.email_verified",
  userId: user.id,
  idempotencyKey: `email-verified-${user.id}`,
});

The event is named user.email_verified, not email.verified — the email. namespace is reserved for engine-emitted events (email.opened, email.link_clicked, email.action), and app events must stay out of it.

The chase journey

// src/journeys/verification-chase.ts
import { days, hours, minutes } from "@hogsend/engine";
import { defineJourney, sendEmail } from "@hogsend/engine";
import { Events, Templates } from "./constants/index.js";

export const verificationChase = defineJourney({
  meta: {
    id: "verification-chase",
    name: "Onboarding — verification chase",
    enabled: true,
    trigger: { event: Events.USER_SIGNED_UP },
    entryLimit: "once",
    suppress: hours(12),
    exitOn: [
      { event: Events.EMAIL_VERIFIED },
      { event: Events.USER_DELETED },
    ],
  },

  run: async (user, ctx) => {
    const verifyUrl = String(user.properties.verify_url ?? "");
    const firstName = String(user.properties.first_name ?? "");

    // The signup handler already sent the first verify-email. The lookback
    // catches a user who verified while this run was enrolling.
    const first = await ctx.waitForEvent({
      event: Events.EMAIL_VERIFIED,
      timeout: hours(24),
      label: "await-verification",
      lookback: minutes(30),
    });
    if (!first.timedOut) return; // verified — done

    // Re-send 1, after a day.
    if (!(await ctx.guard.isSubscribed())) return;
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.TRANSACTIONAL_VERIFY_EMAIL, // "transactional/verify-email"
      subject: "Reminder: verify your email address",
      journeyName: user.journeyName,
      props: { firstName, verifyUrl },
    });

    const second = await ctx.waitForEvent({
      event: Events.EMAIL_VERIFIED,
      timeout: days(2),
      label: "await-verification-2",
    });
    if (!second.timedOut) return;

    // Re-send 2 — the last one. An unverified account past this point is
    // your signup flow's problem, not email's.
    if (!(await ctx.guard.isSubscribed())) return;
    await sendEmail({
      to: user.email,
      userId: user.id,
      journeyStateId: user.stateId,
      template: Templates.TRANSACTIONAL_VERIFY_EMAIL,
      subject: "Last reminder — your account isn't active yet",
      journeyName: user.journeyName,
      props: { firstName, verifyUrl },
    });
  },
});

Both re-sends reuse the canonical transactional/verify-email template with subject overrides — one template, three sends. The schedule is the code: 24 hours, then two days, then stop.

waitForEvent is the branch and exitOn is the guarantee. The success branch is a bare return, so listing user.email_verified in both places is safe here: a verification mid-wait either resolves the wait (and the code returns) or exits the run (and no further step fires) — the outcome is identical. The lookback: minutes(30) on the first wait covers the user who clicks the link seconds after signup, before the durable wait is established.

Why the chase never uses skipPreferenceCheck

skipPreferenceCheck: true exists for mail the user just asked for — a password reset, a security notification — and requires a full-admin key (Transactional emails). The distinction is who initiated the send: a password reset answers a request the user made seconds ago; a verification re-send is mail you want them to act on. So the chase runs under normal preference rules — the enrollment guard skips users who have unsubscribed, and ctx.guard.isSubscribed() re-checks before each re-send. The first verify-email needs no bypass either: a seconds-old signup has no preference state to bypass.

Add the events and template keys

// src/journeys/constants/index.ts
export const Events = {
  USER_SIGNED_UP: "user.signed_up",
  EMAIL_VERIFIED: "user.email_verified",
  USER_DELETED: "user.deleted",
} as const;

export const Templates = {
  TRANSACTIONAL_VERIFY_EMAIL: "transactional/verify-email",
} as const;

transactional/verify-email is the canonical key used across the docs; authoring it is a React Email component plus a registry.ts entry and a templates.d.ts augmentation (Email guide). Register the journey by adding verificationChase to your journeys array, exactly as in Lifecycle journeys.

  • Name the event user.email_verified. The email. namespace belongs to engine-emitted events; an app event inside it would collide with the tracking vocabulary.
  • Re-send links must outlive the original token. Point verify_url at an endpoint that mints a fresh token on click — by the second re-send, three days in, the signup-time token has usually expired.
  • At most two re-sends is structural, not configured. The journey is linear code with two sendEmail calls; there is no retry loop to misconfigure, and entryLimit: "once" means a replayed signup event can't restart the chase.

Related: Transactional emails covers the first send and the skipPreferenceCheck rules, Welcome series takes over once they're verified, and the Journeys guide documents waitForEvent, lookback, and exitOn semantics in full.