Link a Discord account to an email
How the in-Discord /link modal loop attaches an email to a Discord account — a transactional one-click confirm link, single-use with a 15-minute TTL and a fail-closed throttle, where the bind happens in the browser, plus reading the resulting discord_id and contacts.properties.discord on a contact.
A Discord member and an email subscriber are the same person on two surfaces, but nothing ties them together until the member links their email. The @hogsend/plugin-discord connector does this inside Discord, built on the engine's createColdConnect() primitive: /link opens an email modal, Hogsend mails a one-click confirm link through a transactional send, and clicking that link binds the email onto the discord_id contact in the browser. After that, discord_id and contacts.properties.discord are on the contact row, and the member is both emailable and credited for Discord activity.
This is not a journey — it is the consumer wiring that makes the loop work, plus how a journey reads the result.
The /link loop
The flow is fully ephemeral and never echoes the email in a message body. There is no typed code, no Enter-code button, and no /verify command — the bind is a click on the emailed link:
| Step | What happens |
|---|---|
/link | Opens an email modal (the primary UX; a modal is the initial response and does zero work, so it is instant) |
| Email modal submit | Validates the address synchronously first — a bad address gets an instant inline ephemeral error (no defer); a valid one defers, then out-of-band mints a cold-connect token, emails the one-click confirm link, and PATCHes a button-less "check your inbox, click the link" message |
| Click the emailed link | Lands on GET /connect/discord — the engine-served connect page, styled to the Studio "Crimzon" language and showing the real Discord mark. ?tok= is read client-side only |
| Confirm connection button | A human button POST runs the exchange: it folds discord_id + email onto one contact, then the page runs client-side posthog.identify(contactKey, { discord_id }) |
Every interaction is ed25519-verified with a ±300s replay window before any work runs. The bind only ever happens on the human button click (POST) — a link-preview prefetch (GET) renders the page but cannot complete the bind.
The wiring
createDiscordConnector injects the engine helpers the plugin must not read itself; the connector is then passed to createHogsendClient in both entry points. The /link front door is the consumer's requestConfirm callback, which mints a token through a discordColdConnect = createColdConnect({...}) flow and emails the one-click confirm link:
import {
createColdConnect,
getEmailService,
} from "@hogsend/engine";
import {
createDiscordConnector,
DISCORD_PROVIDER_ID,
} from "@hogsend/plugin-discord";
// The cold-connect flow — the same engine primitive Telegram uses. It owns the
// sealed single-use token, the connect page, and the bind exchange.
export const discordColdConnect = createColdConnect<Record<string, never>>({
connectorId: DISCORD_PROVIDER_ID,
identityKind: "discordId",
// The dedicated `discord_id` column keys on the RAW snowflake — no namespace
// prefix (the engine has a collision guard on that column).
platformKey: (id) => id,
linkedEvent: "discord.linked",
identifyPropKey: "discord_id",
buildIngest: (binding) => ({
// Scalar trigger properties a `discord.linked` welcome journey reads off
// `user.properties.*` — `contactProperties` never reach the Hatchet payload.
eventProperties: {
source: "discord",
discordId: binding.platformUserId,
via: "email_confirm",
},
// `discord` is a deep-merge key, so this never clobbers the richer metadata
// (username/avatar/etc.) inbound gateway events set.
contactProperties: { discord: { id: binding.platformUserId } },
}),
branding: {
iconSvg: DISCORD_ICON_SVG, // the real Discord mark, accent-tinted chip
accentColor: "#5865f2",
title: "Connect your Discord",
blurb: "Tap below to finish linking your Discord account to your contact.",
reassurance:
"Didn't start this in Discord? You can safely close this tab — nothing " +
"links to your account until you tap Confirm above.",
// successCopy / errorCopy …
},
});
export const discordConnector = createDiscordConnector({
applicationId: env.DISCORD_APPLICATION_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
publicKeyHex: env.DISCORD_PUBLIC_KEY,
redirectUri: `${base}/v1/connectors/discord/oauth/callback`,
studioIntegrationsUrl: `${base}/studio/integrations`,
saveDerived: async (patch) => {
/* read-merge-write into the derived credential */
},
// discord_id is the SOLE merge key. Goes through `client.identity.linkContact`
// (NOT bare resolveOrCreateContact) so the contact-merge propagates the PostHog
// merge through the SAME engine path ingest uses.
resolveContact: async (patch) => {
await client.identity.linkContact({
discordId: patch.discordId,
email: patch.email, // the AUTHORITATIVE address the link was issued for
contactProperties: patch.contactProperties,
});
},
// The /link front door: mint a server-sealed cold-connect token (the throttle
// runs FIRST inside mintConfirm) and, only on ok:true, email the one-click
// confirm LINK. The handler never sees the token — it lives only in the URL.
requestConfirm: async ({ discordUserId, email }) => {
const minted = await discordColdConnect.mintConfirm({
platformUserId: discordUserId,
email,
});
if (!minted.ok) {
return {
ok: false,
reason:
minted.reason === "redis_unavailable" ? "unavailable" : "rate_limited",
};
}
const url = discordColdConnect.confirmUrl({ apiPublicUrl: base, token: minted.token });
// TRANSACTIONAL send — skipPreferenceCheck so a confirm link is NEVER dropped
// by unsubscribe/frequency suppression. No contact exists yet, so userId is
// the email (the exchange later folds discord_id + email onto one contact).
await getEmailService().send({
template: "transactional/magic-link",
props: { magicLinkUrl: url, expiresIn: "15 minutes" },
to: email,
userId: email,
userEmail: email,
subject: "Confirm your Discord connection",
category: "transactional",
skipPreferenceCheck: true,
});
return { ok: true };
},
});Mount the connect page so a clicked link resolves (the engine derives the basePath from connectorId, so discordColdConnect.routes serves GET /connect/discord + POST /connect/discord/exchange):
const app = createApp(client, {
webhookSources,
routes: [
telegramColdConnect.routes,
...(discordConnector ? [discordColdConnect.routes] : []),
],
});Three guarantees fall out of this wiring:
- The confirm link rides a transactional send.
category: "transactional"withskipPreferenceCheck: truemeans the link is never dropped by an unsubscribe or a frequency cap — routing it through the journey-categorysendEmailwould silently lose it for unsubscribed users. - The throttle runs inside
mintConfirm. Before any token is sealed,mintConfirmenforces the cold-connect throttle (per-user + per-email Redis-INCR rolling windows, fail-closed), so an over-cap/linkreturns{ ok: false }with no email sent. The consumer no longer hand-rolls a/verifyattempt counter. - The bind happens in the browser on the Confirm button POST. The sealed token is single-use (default 900s TTL) and lives only in the emailed URL. The exchange runs on a human button POST, never on the page GET — so a link-preview prefetch can't complete the bind, and the token survives an interrupted attempt for a retry (it is peeked, then consumed only after the bind commits).
Reading the linked identity in a journey
JourneyUser carries id, email, and properties, but not the nested Discord metadata. Read the authoritative contacts row: the discord_id column is the merge key, contact.email tells you linked vs Discord-only, and contacts.properties.discord holds the read-only metadata.
// src/journeys/react-to-linked-state.ts
import { contacts } from "@hogsend/db";
import { days, defineJourney, getDb, hours, sendEmail } from "@hogsend/engine";
import { eq } from "drizzle-orm";
import { Events, Templates } from "./constants/index.js";
export const reactToLinkedState = defineJourney({
meta: {
id: "react-to-linked-state",
name: "Discord — react to link state",
enabled: true,
trigger: { event: Events.DISCORD_MESSAGE_SENT }, // "discord.message_sent"
entryLimit: "once_per_period",
entryPeriod: days(7),
suppress: hours(12),
},
run: async (user, ctx) => {
const db = getDb();
const contact = await db.query.contacts.findFirst({
where: eq(contacts.id, user.id),
});
const meta = (contact?.properties?.discord ?? {}) as {
username?: string;
};
if (!contact?.email) {
// Discord-only — no address. Nudge to link via the channel.
await ctx.trigger({
event: Events.DISCORD_NUDGE_LINK,
userId: user.id,
properties: { username: meta.username ?? null },
});
return;
}
await sendEmail({
to: contact.email,
userId: user.id,
journeyStateId: user.stateId,
template: Templates.DISCORD_ACTIVE_THANKS,
subject: `Thanks for being active, ${meta.username ?? "friend"}`,
journeyName: user.journeyName,
});
},
});discord_id is the sole merge key — the connect-page exchange resolves through client.identity.linkContact with the discord identity Kind, so the raw snowflake lands in the indexed discord_id column. contacts.properties.discord is decorative metadata (username, global_name, avatar, joined_at, roles, and the derived last_seen), deep-merged and non-clobbering — never a resolution key.
- The linked email is the one typed, proven by the click on the emailed link and carried through the engine-sealed token. The OAuth member-link alternative uses the address the link was issued for, never the OAuth-reported Discord email — using the latter as a resolution key would let a member attach an address they do not own.
- The OAuth member-link is the web-initiated alternative. The one-click install / member-link path (
POST /v1/admin/connectors/discord/member-link-url) is unchanged and stays available, but the in-Discord/linkmodal is the primary, live path.
Related: Welcome new Discord members gates its welcome on this link, Re-engage quiet Discord members reads the same contact metadata, and the Discord integration documents the loop's security model.
Welcome new Discord members
A welcome journey triggered by discord.member_joined — wait for the member to link an email with ctx.waitForEvent(), send the welcome the instant they link, and nudge the still-unlinked in-channel via the Discord destination.
Welcome new Telegram members
A real-time onboarding pair triggered by telegram.started and telegram.message — reply to a bare /start with a welcome, and echo every inbound message with a TypeScript journey, so an inbound platform event becomes an outbound Telegram reply in your repo.