Hogsend is brand new.Try it
Hogsend
Integrations

Discord

Turn a Discord server into a Hogsend event source — messages, reactions, and joins flow in as discord.* events over an inline Gateway socket inside the Hatchet worker (presence is opt-in), contacts link their email with a /link command that emails a one-click confirm link, and an outbound destination posts lifecycle events to a channel.

@hogsend/plugin-discord is a consumer-mounted connector — the engine ships no Discord code; you pnpm add the package and wire it into your app. It ships two parts under meta.id = "discord": an inbound Gateway connector that turns Discord activity into discord.* events, and an outbound destination that posts lifecycle events to a channel. The live socket runs inline inside the existing Hatchet worker — gated by a Redis leader lease so exactly one replica holds it per bot token — and feeds transform → ingestEvent in process. There is no separate Discord worker and no HTTP ingress hop on the default path.

This is consumer-mounted content. You pnpm add @hogsend/plugin-discord, build a connector with createDiscordConnector(...), and pass it to createHogsendClient. Discord is not a signed-webhook preset — it does not appear in ENABLED_WEBHOOK_PRESETS and does not auto-mount from a secret env var.

What it does

In — three Discord Gateway dispatches become discord.* events out of the box, and a fourth (presence) is opt-in. Each runs through the same ingestion pipeline as PostHog and the REST API: stored in user_events, routed to matching journeys, exit-checked, and upserted onto a contact.

  • discord.message_sent, discord.reaction_added, discord.member_joined — see Events for the full mapping. discord.presence_active needs the GUILD_PRESENCES intent, which is not requested by default.
  • A contact gains a discord_id identity and a contacts.properties.discord metadata object.

OutdiscordDestination posts one Discord-markdown line per lifecycle event to a channel (incoming webhook preferred, bot-REST as the alt) on the durable outbound spine. See Outbound and the Destinations guide. Journeys can also send to Discord directly with sendConnectorAction(...) — see Outbound actions.

Architecture

The Gateway socket runs inline inside the Hatchet worker — the same process that already executes your journey tasks. There is no separate Discord service, and no shared ingress secret on the default path.

real Discord activity (message / reaction / join)
  → discord.js Gateway socket   (INLINE in the Hatchet worker, leader replica only)
  → in-process: connector.transform(dispatch)  →  ingestEvent(...)
  → user_events row + contact upsert (discord_id key + properties.discord) + journey routing

Discord (slash / modal / button)  ──HTTP──▶  POST /v1/connectors/discord/interactions  (ed25519 + ±300s)

The pieces:

  • One socket, one replica. A Discord bot token permits exactly one live Gateway session. The worker elects a single leader via a Redis leader lease (hogsend:connector-runtime:discord:leader, SET … NX PX, 30s TTL, renewed every 10s). Only the leader opens the socket; losers idle and re-race every 5s, so scaling the worker to N replicas never opens a second session. Failover is automatic within the TTL.
  • In-process dispatch. The leader hands each raw Gateway dispatch straight to the connector's transform, then ingestEvent — the exact pair the legacy HTTP ingress route ran, minus the network hop. No HTTP, no shared secret. Because the in-process path holds the container, it passes client.analytics into ingestEvent, so a Discord-keyed contact merge also stitches the analytics person.
  • Readiness is the heartbeat. The leader writes a TTL'd Redis key hogsend:connector-runtime:discord:heartbeat (30s TTL, refreshed every 10s). The admin connect-info.workerOnline flag and Studio's "Worker Online" both reflect this key. Because only the lease-holder writes it, a fresh key means this deployment's elected leader owns the socket.
  • Outbound is socket-free. Bot-REST actions (send a channel message, DM a member, mention a role) go through sendConnectorAction() + discordActions. They need only the bot token and are independent of the inbound runtime — a deploy with the gateway off can still send.

Source: packages/engine/src/connectors/runtime.ts, packages/engine/src/lib/leader-lease.ts, packages/engine/src/lib/connector-heartbeat.ts.

The HTTP routes under /v1/connectors/discord and their auth:

RoutePurposeAuth
POST /v1/connectors/discord/interactionsDiscord HTTP Interactions (slash / modal / button)ed25519 signature + ±300s timestamp replay window
GET|POST /v1/connectors/discord/oauth/callbackOAuth install + member-link (see Caveats)signed CSRF state, engine-verified before dispatch

/v1/connectors/* is per-IP rate-limited (60/min) except /interactions — it arrives from Discord's interaction egress (a small set of source IPs), so per-IP keying would collapse a whole community onto one bucket. It is gated by ed25519 + replay instead.

The inbound socket is optional. The /link identity loop is pure HTTP interactions plus an emailed confirm link — it needs no socket and no bot token (the role grant aside). You only run the worker runtime when you also want inbound ingestion. See Run the inline Gateway runtime.

Setup

The eight steps below are the canonical order. Every value, route, env name, and command is copyable and correct against the code. Steps 1–5 stand up the Discord app and your env; the Wire the connector subsection (after step 5) is the prerequisite for steps 6–8.

1. Create a Discord app

In the Discord Developer Portal, click New Application. Each self-hosted deploy runs its own single-tenant Discord app.

2. Add a bot and toggle the two privileged intents

On the Bot tab, click Reset Token (this value is DISCORD_BOT_TOKEN). On the same tab, enable the two Privileged Gateway Intents the worker requests: Message Content and Server Members. Toggle both before the worker gets the token: if either is off, discord.js login() rejects with a disallowed-intents error and the inline runtime fails to start (it releases the lease so another replica or a fixed redeploy re-races cleanly).

Leave the third toggle, Presence, off. createDiscordGatewayWorker does not put GUILD_PRESENCES in its default bitfield, so enabling it in the portal alone changes nothing: see Presence is opt-in.

3. Collect four values from the portal

ValuePortal locationEnv varSecret
Application IDGeneral Information → Application ID (= OAuth2 Client ID)DISCORD_APPLICATION_IDno
Public KeyGeneral Information → Public Key (ed25519, hex)DISCORD_PUBLIC_KEYno
Bot TokenBot → Reset TokenDISCORD_BOT_TOKENyes
Client SecretOAuth2 → Reset SecretDISCORD_CLIENT_SECRETyes

The Public Key verifies every interaction signature. The Client Secret is used only for the server-side OAuth code exchange (the OAuth member-link path).

4. Invite the bot to your server

The bot must be a member of the guild to receive that guild's channel events. Use the OAuth2 URL Generator (scopes bot + applications.commands) with the permissions you need — View Channel, Read Message History, Send Messages, Use Application Commands — so the slash commands appear and the bot-REST outbound post can send.

A minimal inbound-only test can invite with permissions=0; the bot only needs to be present in a channel to receive messages and reactions:

https://discord.com/oauth2/authorize?client_id=<APPLICATION_ID>&scope=bot+applications.commands&permissions=0

5. Set environment variables

Every DISCORD_* var is optional — a deploy with no Discord configured still boots (the connector registers nothing). The connector is built only when DISCORD_APPLICATION_ID, DISCORD_CLIENT_SECRET, and DISCORD_PUBLIC_KEY are all set. API_PUBLIC_URL must be a public host (not loopback).

The split matters: the connector registration + the HTTP interactions/OAuth legs live on the API service; the bot token and a shared REDIS_URL live on the worker service (it holds the socket and the lease).

.env — API service
DISCORD_APPLICATION_ID=...
DISCORD_PUBLIC_KEY=...
DISCORD_CLIENT_SECRET=...      # secret — OAuth member-link only
DISCORD_BOT_TOKEN=...          # optional here — role grant on /link + outbound actions if the API sends
DISCORD_GUILD_ID=...           # optional — enables instant guild-scoped command registration

API_PUBLIC_URL=https://api.example.com   # public host, not loopback
.env — worker service
DISCORD_BOT_TOKEN=...          # secret — the inline Gateway socket logs in with this
REDIS_URL=...                  # the SAME Redis the API uses — load-bearing for the lease + heartbeat

The worker's REDIS_URL must point at the same Redis the API reads. The leader lease and the "Worker Online" heartbeat both live in Redis; if the worker can't reach the API's Redis, "Worker Online" never goes green even with a perfectly healthy socket.

Wire the connector

The plugin never reads process.env — you inject the values. Build the cold-connect flow with the engine's createColdConnect(...) primitive (the same one Telegram uses), build the connector with createDiscordConnector(...), register the connector and the destination on createHogsendClient, then hand the container db and client.identity into the connector's deferred callbacks. Both entry points (the HTTP API and the worker) need this — the ingest pipeline runs in the Hatchet worker too, so the connector/destination registry must be identical in both processes.

The connector is passed into createHogsendClient, which is what builds the container db — so the callbacks can't close over db at construction time. Construct the connector with deferred requireDb() / requireIdentity() getters and wire them once the client exists with setDiscordDb(client.db, client.identity). The callbacks only run at request time (the /link loop and the connect-page exchange), long after client.db is set, so the getters are always resolved by then.

src/discord.ts
import type { Database } from "@hogsend/db";
import {
  createColdConnect,
  type DerivedCredentialPayload,
  getDerivedCredential,
  getEmailService,
  type IdentityService,
  saveDerivedCredential,
} from "@hogsend/engine";
import {
  createDiscordConnector,
  DISCORD_PROVIDER_ID,
  discordDestination,
} from "@hogsend/plugin-discord";
import { discordEnv } from "./env.js";

// The container db + engine identity service, wired in once after
// createHogsendClient(...) returns.
let dbHandle: Database | undefined;
let identityHandle: IdentityService | undefined;

/** Call once, post-build: `setDiscordDb(client.db, client.identity)`. */
export function setDiscordDb(db: Database, identity: IdentityService): void {
  dbHandle = db;
  identityHandle = identity;
}

function requireDb(): Database {
  if (!dbHandle) {
    throw new Error("Discord connector used before setDiscordDb(...)");
  }
  return dbHandle;
}

function requireIdentity(): IdentityService {
  if (!identityHandle) {
    throw new Error("Discord connector used before setDiscordDb(...)");
  }
  return identityHandle;
}

// The cold-connect flow, built on the engine `createColdConnect()` primitive.
// `identityKind: "discordId"` rides the dedicated `contacts.discord_id` column
// (with a collision guard), so `platformKey` returns the RAW snowflake. The
// anti-email-bomb throttle (per-user + per-email Redis-INCR windows,
// fail-closed) lives INSIDE `mintConfirm`. Returns { mintConfirm, confirmUrl,
// routes }.
export const discordColdConnect = createColdConnect({
  connectorId: DISCORD_PROVIDER_ID,
  identityKind: "discordId",
  platformKey: (id) => id, // raw snowflake → dedicated discord_id column
  linkedEvent: "discord.linked",
  identifyPropKey: "discord_id",
  buildIngest: (binding) => ({
    eventProperties: {
      source: "discord",
      discordId: binding.platformUserId,
      via: "email_confirm",
    },
    contactProperties: { discord: { id: binding.platformUserId } },
  }),
  branding: {
    iconSvg: DISCORD_ICON_SVG, // the real Discord mark — not an emoji
    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.",
  },
});

export function buildDiscordConnector() {
  const applicationId = discordEnv.DISCORD_APPLICATION_ID;
  const clientSecret = discordEnv.DISCORD_CLIENT_SECRET;
  const publicKeyHex = discordEnv.DISCORD_PUBLIC_KEY;
  // No connector when the app isn't configured — the destination is still
  // registered separately (it's config-driven per webhook endpoint).
  if (!applicationId || !clientSecret || !publicKeyHex) return undefined;

  const base = discordEnv.API_PUBLIC_URL.replace(/\/$/, "");

  return createDiscordConnector({
    applicationId,
    clientSecret,
    publicKeyHex,
    redirectUri: `${base}/v1/connectors/discord/oauth/callback`,
    studioIntegrationsUrl: `${base}/studio/integrations`,
    // Persist server-derived config (kind="derived"). Read-merge-write so a
    // guild id captured on install never clobbers a stored bot token.
    saveDerived: async (patch) => {
      const db = requireDb();
      const current =
        (await getDerivedCredential(db, "discord")) ??
        ({} as DerivedCredentialPayload);
      await saveDerivedCredential(db, "discord", {
        ...current,
        ...(patch as DerivedCredentialPayload),
      });
    },
    // Route the snowflake through the engine identity service so `discord_id`
    // is the SOLE merge key and a successful link propagates the PostHog merge
    // through the same emission ingest uses (NOT bare resolveOrCreateContact).
    resolveContact: async (patch) => {
      await requireIdentity().linkContact({
        discordId: patch.discordId,
        email: patch.email,
        contactProperties: patch.contactProperties,
      });
    },
    // The `/link` front door: mint a server-sealed cold-connect confirm token
    // (the throttle runs FIRST inside `mintConfirm` — fail-closed) and, only on
    // ok:true, email the one-click confirm LINK. The handler never sees the
    // token — it lives only in the emailed URL. The bind happens later when the
    // user clicks the link (the discordColdConnect.routes exchange folds
    // discord_id + email onto one contact). A mailer throw propagates so the
    // loop fails CLOSED; ok:false maps to rate_limited/unavailable.
    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 — bypasses unsubscribe/frequency suppression so a
      // confirm link is NEVER silently dropped. No contact exists yet, so
      // userId is the email (a valid external key).
      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 };
    },
  });
}

export { discordDestination };

Then mount the connector, the destination, and discordColdConnect.routes (the engine-served GET /connect/discord page + its exchange POST) on the app:

src/index.ts and src/worker.ts
import {
  buildDiscordConnector,
  discordActions,
  discordColdConnect,
  discordDestination,
  setDiscordDb,
} from "./discord.js";

const discordConnector = buildDiscordConnector();

const client = createHogsendClient({
  // ...journeys, email, etc.
  connectors: discordConnector ? [discordConnector] : [],
  destinations: [discordDestination],
  connectorActions: discordActions, // socket-free outbound — see below
});

// Wire the container db + identity into the deferred Discord callbacks
// (once, post-build).
setDiscordDb(client.db, client.identity);

// Mount the cold-connect page + exchange (GET/POST /connect/discord …).
const app = createApp(client, {
  routes: [discordColdConnect.routes],
  // ...webhookSources, etc.
});

6. Set the Interactions Endpoint URL

In the portal (General Information → Interactions Endpoint URL), point it at <API_PUBLIC_URL>/v1/connectors/discord/interactionsAPI_PUBLIC_URL already includes the scheme, so do not prepend another https://. The engine emits ${apiPublicUrl}/v1/connectors/discord/interactions with no extra scheme; match that. With API_PUBLIC_URL=https://api.example.com the URL is:

https://api.example.com/v1/connectors/discord/interactions

Discord sends a synchronous validation PING when you save; the route answers PONG, verified env-only with DISCORD_PUBLIC_KEY — no hogsend connect discord needed. The API must already be running behind the public URL when you click Save. On an env-only deploy you set this URL in the portal yourself (or run hogsend connect discord, which PATCHes it onto the app for you when your consumer mounts the /wire route).

Also add the OAuth2 redirect (OAuth2 → Redirects) so the install + member-link callbacks land:

https://api.example.com/v1/connectors/discord/oauth/callback

7. Register slash commands

Register /link:

pnpm --filter @hogsend/api discord:register-commands

This calls the Discord REST API directly — it needs DISCORD_APPLICATION_ID and DISCORD_BOT_TOKEN (and optional DISCORD_GUILD_ID) only, with no dependency on API_PUBLIC_URL or a tunnel. With DISCORD_GUILD_ID set it registers guild-scoped commands (instant); without it, global commands (~1h propagation). The call replaces the full command set, so it is idempotent.

8. Run the inline Gateway runtime

This is what turns on inbound ingestion (the /link loop needs none of it). The socket opens inside the existing Hatchet worker — there is no separate Discord process. You wire it in two places.

First, install discord.js in the worker's app — it's declared as an optional peer of @hogsend/plugin-discord so a destination-only deploy isn't forced to install a socket client:

pnpm add discord.js

The peer range is >=14.0.0; apps/api pins discord.js@^14.26.4. discord.js is imported dynamically inside the socket's start() — enabling the runtime without the peer installed fails loudly at start, not at module load.

Then pass the Discord runtime factory to createWorker in src/worker.ts:

src/worker.ts
import { createWorker } from "@hogsend/engine";
import { createDiscordRuntime } from "@hogsend/plugin-discord/gateway";

const worker = createWorker({
  container: client,
  journeys,
  // ...buckets, extraWorkflows, etc.
  connectorRuntimes: { discord: createDiscordRuntime },
});

The runtime auto-starts when all of these hold (the first two are the defaults — you don't set them):

  • ENABLE_CONNECTOR_RUNTIMES=true (default),
  • CONNECTOR_RUNTIME_HOST=worker (default),
  • a discord factory is passed to connectorRuntimes, and
  • the Discord connector is registered as a gateway-transport connector.

On a lease win the worker logs Connector runtime acquired lease; opening socket then Connector runtimes started. With N worker replicas, exactly one opens the socket; the rest idle and stand by for failover. createDiscordRuntime returns null when DISCORD_BOT_TOKEN is unset — the engine then skips Discord cleanly (no lease held, dashboard stays truthfully Offline), so forgetting the token is a silent no-op, not a crash.

Verify readiness via Studio (Integrations → Discord → "Worker Online") or the admin projection:

curl -s "https://api.example.com/v1/admin/connectors/discord/connect-info" \
  -H "Authorization: Bearer ${ADMIN_API_KEY}" | jq '{workerOnline, workerLastSeenAt}'

workerOnline: true ⇒ a lease-holder owns the socket. Then post a message in a channel the bot can see and confirm a discord.message_sent row lands in user_events.

The runtime refuses to boot if a requested privileged intent is not toggled in the portal — login() rejects with a disallowed-intents error rather than silently connecting with no events. Toggle the two intents in step 2 first. On a failed start the runtime releases the lease, so another replica (or a fixed redeploy) re-races cleanly.

To explicitly disable the inline runtime (e.g. you run the standalone hatch instead), set ENABLE_CONNECTOR_RUNTIMES=false on the worker (the env is a literal enum — "false" actually disables it).

Direction recap

  • The bot dials Discord outbound for the Gateway socket, held inline in the worker by the lease-holder.
  • Slash commands are inbound HTTP interactions Discord POSTs to the public Interactions Endpoint.

A contact links their email by running /link inside Discord, which emails them a one-click confirm link. There is no typed code — the bind happens in the browser when the user clicks that link. This is the same engine createColdConnect() flow Telegram uses. Every Discord interaction is ed25519-verified with a ±300s replay window before any work runs, and the /link half of the loop is pure HTTP interactions — it needs no Gateway socket.

/link  ──▶  email modal  ──submit──▶  valid? ──no──▶  inline ephemeral error (no defer)
                                          │ yes

                  defer (ephemeral) → requestConfirm: mintConfirm (throttle) +
                                      email a one-click confirm LINK


                  PATCH @original → button-less "check your inbox, click the link"

  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ in the BROWSER ─ ─ ─

                          user clicks the emailed link

                  GET /connect/discord  (engine-served, Crimzon-styled page)

                          "Confirm connection" button  ──POST──▶  exchange

                  fold discord_id + email onto ONE contact  +  posthog.identify
  • /link (no options) opens an email modal.
  • Email modal submit validates the address synchronously first: a bad address gets an instant inline ephemeral error (no deferral). Only a valid address defers (ephemeral), then runs requestConfirm out of band — the consumer's mintConfirm (throttle-first) plus a transactional send of the one-click confirm link.
  • It then PATCHes the original message with a button-less "check your inbox, click the link" reply. The one-click action lives in the emailed link, not a Discord button — and the reply never echoes the email address.
  • The user clicks the emailed link and lands on the engine-served GET /connect/discord page (mounted by the consumer via discordColdConnect.routes). The page is styled to the Hogsend Studio "Crimzon" design language, shows the real Discord mark in an accent-tinted chip (not an emoji), and carries an "if this wasn't you, ignore this" reassurance footnote. The ?tok= token is read client-side only and is never reflected into the markup.
  • The bind happens on a human button click: "Confirm connection" issues a POST to the exchange, which peeks the sealed token and ingestEvents discord.linked, folding discord_id + email onto one contact. On success the page runs client-side posthog.identify(contactKey, { discord_id }).

Because the bind is a POST on a button click — not a GET — an email client's link-preview prefetch can fetch the page but cannot complete the link. The confirm token is single-use with a 15-minute TTL, sealed server-side, and scoped to the discord connector (a token minted elsewhere is rejected on exchange).

The anti-email-bomb throttle (independent per-user and per-email rolling Redis-INCR windows, fail-closed) runs inside mintConfirm before any token is sealed or any email is sent. An over-cap mint returns { ok:false, reason:"rate_limited" } and a Redis fault { ok:false, reason:"unavailable" }; the loop replies with a neutral "try again later" and sends no link. A mailer throw propagates so the loop fails closed (apologetic reply, no link).

Two paths attach a Discord account to a contact, chosen by where the linking starts. Lead with the in-Discord /link command; the OAuth member-link is the web-initiated alternative.

/link — in Discord (recommended)OAuth member-link — from your app
Entry pointInside Discord, the user runs /linkA "Connect Discord" button in your web app
FlowEmail modal → emailed one-click confirm linkGET /connect/discord page → "Confirm connection" button POST (the bind happens in the browser)Engine mints the URL, the user authorizes on Discord, the callback attaches discord_id
IdentityThe email the user types in the modal (sealed into the confirm token; discord_id folded on at the exchange)The email the link was issued for (never the OAuth-reported Discord email)
OAuth scopesnoneidentify email guilds.members.read
Setup costenv-only — no extra credential, no CLIenv-only — the route mounts automatically

Use /link for any user already in your Discord server: it runs on HTTP interactions plus a one-click emailed link, no OAuth redirect leaves Discord, and the confirm token is single-use, sealed server-side, with a 15-minute TTL.

Use the OAuth member-link when linking is initiated from your web app rather than inside Discord. The engine mints the URL at POST /v1/admin/connectors/discord/member-link-url (it signs a member_link state binding { contactId, email }); the user authorizes; the GET|POST /v1/connectors/discord/oauth/callback route attaches discord_id to the bound contact. The contact is identified by the email the link was issued for — the OAuth-reported Discord email is stored only as a non-key field, and only when verified.

Events

The four Discord Gateway dispatches and their Hogsend event names:

Discord dispatchHogsend event
MESSAGE_CREATEdiscord.message_sent
MESSAGE_REACTION_ADDdiscord.reaction_added
GUILD_MEMBER_ADDdiscord.member_joined
PRESENCE_UPDATEdiscord.presence_active

The first three arrive out of the box. PRESENCE_UPDATE does not: see Presence is opt-in.

Noise is dropped (the transform returns null): bot, webhook, and system messages; bot members; and offline / absent presence. Each event carries a deterministic idempotencyKey (discord:msg:…, discord:react:…, discord:join:…, discord:presence:…), so the at-least-once Gateway (RESUME replays) dedupes on user_events.idempotencyKey rather than re-firing your journeys.

Identity

discord_id is a fourth contact identity Kind (external | email | anonymous | discord), and the raw Discord snowflake is the indexed merge key (a partial unique index on contacts.discord_id). On the inbound path the connector also sets userId = discord:<snowflake> on the IngestEvent, but it's the separate discordId field — the raw snowflake — that becomes the load-bearing discord_id column.

Contact metadata

Discord metadata lands under contacts.properties.discord, deep-merged one level (non-clobbering — each event carries only the fields it knows; absent fields are preserved from prior events). null is never written: a global_name/avatar Discord reports as null is left off, not stored as null.

FieldWhen
idalways (the snowflake)
last_seenalways (derived first-party — see below)
usernamemessage / join
global_namemessage / join (when present)
avatarmessage / join (when present)
joined_atjoin
rolesjoin (when non-empty)

last_seen is derived first-party — Discord has no last-seen field. Hogsend stamps it from each event's timestamp (the max of observed events). Because presence is collapsed to "active" (offline / absent dropped), presence is not a last-seen feed. Only MESSAGE_CREATE derives its timestamp from the message snowflake; reaction / join / presence use receipt time.

Outbound (the destination)

discordDestination (also meta.id = "discord") posts one Discord-markdown line per lifecycle event to a channel. It reads { webhookUrl?, channelId?, username? } off the webhook endpoint's config and resolves the wire in this order:

  1. config.webhookUrl (or endpoint.url when it starts with https://discord.com/api/webhooks/) → POST an incoming webhook. No bot token. Accepts 204 as success.
  2. config.channelId + endpoint.secret (the bot token) → bot-REST POST /channels/:id/messages with Authorization: Bot <token>.
  3. Neither → throws (a non-retryable config error → DLQ).

It subscribes to the full lifecycle catalog: contact.created / updated / deleted / unsubscribed, email.sent / delivered / opened / clicked / action / bounced / complained, journey.completed, and bucket.entered / left.

Create the endpoint with the public API, supplying a Discord incoming-webhook URL:

await hs.webhooks.create({
  url: "https://discord.com/api/webhooks/123/abc",
  kind: "discord",
  eventTypes: ["email.action", "email.complained", "journey.completed"],
  config: {
    webhookUrl: "https://discord.com/api/webhooks/123/abc",
  },
});

See the Destinations guide for authoring defineDestination() transforms and Outbound destinations for the delivery spine.

Outbound actions (from a journey)

The destination above fans lifecycle events out per webhook-endpoint config. To send to Discord from inside a journey or workflow — a one-off channel message, a DM, a mention — use sendConnectorAction(...). It is a standalone import (NOT on JourneyContext — like sendEmail(), features are standalone imports), socket-free (bot-REST, needs only the bot token), and fully independent of the inbound gateway. A deploy with the gateway off, or a worker replica that is a lease loser, can still send.

Register the action set once on the client with connectorActions: discordActions (shown in the Wire the connector snippet above), then call from a journey:

import { sendConnectorAction } from "@hogsend/engine";

await sendConnectorAction({
  connectorId: "discord",
  action: "sendChannelMessage",
  args: { channelId: "…", content: "…" },
});

discordActions (from @hogsend/plugin-discord) is the array of every Discord outbound action:

ActionSends
sendChannelMessageone message to a channel
broadcastToChannela broadcast message to a channel
mentionMembersa message that @-mentions members
mentionRolea message that @-mentions a role
dmMembera direct message to a member

sendConnectorAction resolves the action from the registry and runs it with a contact-resolution helper that matches by email / external id / discord snowflake. It throws when the action isn't registered — so pass connectorActions: discordActions to createHogsendClient first.

Preference gating

dmMember is the only member-directed Discord action, and it is preference-gated automatically. When the engine can resolve the target member to a contact, the DM is skipped if that contact has globally unsubscribed (unsubscribedAll) or opted out of the auto-registered discord channel list — the action returns a typed ConnectorActionSkipped ({ skipped: true, reason: "unsubscribed_all" | "channel_unsubscribed", connectorId, action }) and no Discord API call is made. Narrow it with the isConnectorActionSkipped guard from @hogsend/engine:

import { isConnectorActionSkipped, sendConnectorAction } from "@hogsend/engine";

const res = await sendConnectorAction({
  connectorId: "discord",
  action: "dmMember",
  args: { member: user.properties.discordId, content: "…" },
});
if (isConnectorActionSkipped(res)) return; // member opted out — nothing sent

The ops actions — sendChannelMessage, broadcastToChannel, mentionMembers, mentionRole, and the grantRole / removeRole role actions — are channel/server-directed, carry no recipient preference surface, and are never gated. Likewise, if dmMember can't resolve a contact (a raw snowflake for a member the engine has never seen), the DM proceeds. Opt a contact out of Discord with POST /v1/lists/discord/unsubscribe. The skip verdict is recorded in the durable journal, so it replays verbatim. The discord channel list only exists when Discord is configured (its member-directed action is registered).

Security

Interaction verification

Every interaction is verified with an ed25519 signature over timestamp + body (native node:crypto, no tweetnacl), fail-closed, plus a ±300s timestamp replay window. The route reads the exact raw body bytes the signature covers before parsing anything.

Inbound socket isolation

The inbound socket has no inbound network surface to authenticate — it dials Discord outbound and feeds dispatches into transform → ingestEvent in process. There is no HTTP ingress endpoint and no shared secret on the default path. The one access control that matters is the leader lease: only the lease-holding replica opens the socket, and it is fail-safe — a Redis fault means "no lease ⇒ no socket", never two sockets. Liveness is owned (only the lease-holder writes the heartbeat), so a stray process can no longer light the dashboard green.

Privileged intents & ToS

The three default events require two privileged Gateway intents: MESSAGE_CONTENT and GUILD_MEMBERS. The fourth, discord.presence_active, requires a third (GUILD_PRESENCES) that the worker does not request by default. All three are a self-serve portal toggle under 10,000 users / 100 guilds — no Discord review or verification at that scale. This is Discord policy, not code behavior. Each self-hosted deploy runs its own Discord app (single-tenant).

Presence is opt-in

createDiscordGatewayWorker leaves GUILD_PRESENCES out of its default intents bitfield, so PRESENCE_UPDATE never reaches the socket and discord.presence_active never fires. Presence was the highest-volume, lowest-value feed on the gateway: every member flipping offline to online produced an ingest keyed on nothing but a snowflake.

The transform arm is untouched, so the intent is the only switch. To opt back in, toggle Presence in the portal and pass an explicit bitfield. The shape depends on which host runs the socket. On the standalone host (CONNECTOR_RUNTIME_HOST=standalone), the worker keeps the HTTP ingress hop, so it takes a real apiPublicUrl + ingressSecret and no poster:

standalone host (CONNECTOR_RUNTIME_HOST=standalone)
createDiscordGatewayWorker({
  botToken,
  apiPublicUrl,
  ingressSecret,
  intents:
    DISCORD_INTENTS.GUILDS |
    DISCORD_INTENTS.GUILD_MEMBERS |
    DISCORD_INTENTS.GUILD_MESSAGES |
    DISCORD_INTENTS.GUILD_MESSAGE_REACTIONS |
    DISCORD_INTENTS.MESSAGE_CONTENT |
    DISCORD_INTENTS.GUILD_PRESENCES,
});

On the default worker-hosted runtime there is no ingress hop, and createDiscordRuntime does not forward an intents option, so a deploy that wants presence supplies its own ConnectorRuntime around createDiscordGatewayWorker. Mirror what createDiscordRuntime does, adding the intent:

src/worker.ts (worker-hosted, presence on)
import { type ConnectorRuntimeDeps, createWorker } from "@hogsend/engine";
import { DISCORD_INTENTS } from "@hogsend/plugin-discord";
import { createDiscordGatewayWorker } from "@hogsend/plugin-discord/gateway";

function discordWithPresence(deps: ConnectorRuntimeDeps) {
  const botToken = process.env.DISCORD_BOT_TOKEN;
  if (!botToken) return null; // engine skips Discord, holds no lease

  const socket = createDiscordGatewayWorker({
    botToken,
    // Unused inline: the poster below replaces the HTTP ingress hop.
    apiPublicUrl: "",
    ingressSecret: "",
    poster: async ({ dispatchType, data }) => deps.ingest(dispatchType, data),
    onGuildObserved: (guildId) => deps.onMetadata({ guildId }),
    intents:
      DISCORD_INTENTS.GUILDS |
      DISCORD_INTENTS.GUILD_MEMBERS |
      DISCORD_INTENTS.GUILD_MESSAGES |
      DISCORD_INTENTS.GUILD_MESSAGE_REACTIONS |
      DISCORD_INTENTS.MESSAGE_CONTENT |
      DISCORD_INTENTS.GUILD_PRESENCES,
  });

  return {
    start: () => socket.start(),
    stop: () => socket.stop(),
    getMetadata: () => ({ intents: socket.getIntents() }),
  };
}

const worker = createWorker({
  container: client,
  journeys,
  connectorRuntimes: { discord: discordWithPresence },
});

The custom runtime must pass poster: without it forwardDispatch falls back to the legacy HTTP POST to /v1/connectors/discord/ingress, which 401s on a worker-hosted deploy that correctly leaves CONNECTOR_INGRESS_SECRET unset, so every dispatch is dropped silently.

Discord's terms still bind regardless of the toggle: no ML-training on message content, a public privacy policy, user opt-out and deletion, and data minimization. Hogsend stores derived signalslast_seen, counts, metadata — not raw message bodies.

Legacy: the standalone worker path

Before the inline runtime (engine < 0.25.0), the Gateway socket ran in a separate long-lived worker process (pnpm discord:worker / node dist/discord-worker.js) that dialed Discord and POSTed each raw dispatch to POST /v1/connectors/discord/ingress behind an x-hogsend-ingress-secret header equal to CONNECTOR_INGRESS_SECRET (≥32 chars, shared by the worker and the API). On the default CONNECTOR_RUNTIME_HOST=worker deploy that hop no longer existstransform → ingest runs in process.

ThingStatus
CONNECTOR_INGRESS_SECRET (env)Legacy. Used only by the standalone hatch (CONNECTOR_RUNTIME_HOST=standalone). The default worker-hosted runtime ignores it — do not set it for a worker-hosted deploy.
POST /v1/connectors/discord/ingress (route)Legacy. The HTTP ingress hop. Not used by the default inline runtime; kept for the standalone host mode.
discord-worker entry / pnpm discord:workerAdvanced escape hatch. Run only with CONNECTOR_RUNTIME_HOST=standalone. That mode DOES use CONNECTOR_INGRESS_SECRET and the ingress route, with the same secret set on both the API and the standalone worker.
connect-info.ingressSecretConfigured (admin field)Stale as a readiness signal. It keys on the unset CONNECTOR_INGRESS_SECRET, so it reports false on a perfectly healthy worker-hosted deploy. Do not gate readiness on it — use workerOnline (the heartbeat).

For worker-hosted readiness, the signal to watch is workerOnline (the connector heartbeat / Studio "Worker Online"), not ingressSecretConfigured.

Caveats

  • The bot must be a guild member to receive that guild's channel events.
  • Presence is not last-seen: offline / absent presence is dropped, and last_seen is derived from observed events.
  • One bot token = one live Gateway session. The leader lease prevents two replicas of the same deploy from doubling up, but it cannot stop a different process using the same token (e.g. a stray standalone worker). If events arrive duplicated, ensure only the worker-hosted runtime is active.
  • On an env-only deploy the OAuth install URL and the member-link URL (POST /v1/admin/connectors/discord/member-link-url) both work — they are engine-shipped routes, and apps/api seeds derived.discordAppId from DISCORD_APPLICATION_ID at boot. The capability that remains CLI-driven is the server-side PATCH /applications/@me that auto-sets the Interactions Endpoint URL (via hogsend connect discord, when your consumer mounts the /wire route); on an env-only deploy you set that URL in the portal (Setup step 6).

  • The first npm publish of @hogsend/plugin-discord is manual — CI cannot create a new @hogsend/* package.