Hogsend is brand new.Try it
Hogsend
Building

Account Linking

Bind a player's Steam, Twitch, Battle.net, Epic Games, Xbox, or Riot Games account to a Hogsend contact.

You know a player by their SteamID. Your CRM knows them by email. Nothing joins the two, so the churn email never reaches the player who stopped logging in.

defineAccountLink binds a third-party platform account to a Hogsend contact. The player signs in through the platform, and from then on the platform id and the contact are the same person to every part of the system.

Steam needs no credentials

Steam uses OpenID 2.0 — no app to register, no key to obtain. It links on a bare deploy. The other five built-in providers (Twitch, Battle.net, Epic Games, Xbox, Riot Games) are OAuth2 and each needs a client id and secret from its developer portal.

Turn it on

Providers register on operator intent. Intent is any ACCOUNT_LINK_* env var, STEAM_WEB_API_KEY, or passing any accountLinks option to createHogsendClient, including {}. With no intent, no providers register and nothing throws at boot.

ACCOUNT_LINK_STATE_TTL_SECONDS is deliberately not intent: it carries a default, so counting it would make every deploy look like it opted in.

# Steam — this one variable is enough (OpenID 2.0, no credentials).
ACCOUNT_LINK_ALLOWED_ORIGINS=https://yourgame.com

# Optional. Widens Steam with persona name and avatar.
STEAM_WEB_API_KEY=...

# Twitch. BOTH are required, or it does not register.
ACCOUNT_LINK_TWITCH_CLIENT_ID=...
ACCOUNT_LINK_TWITCH_CLIENT_SECRET=...

# Battle.net. BOTH required.
ACCOUNT_LINK_BATTLENET_CLIENT_ID=...
ACCOUNT_LINK_BATTLENET_CLIENT_SECRET=...

# Epic Games. BOTH required.
ACCOUNT_LINK_EPIC_CLIENT_ID=...
ACCOUNT_LINK_EPIC_CLIENT_SECRET=...

# Xbox (Microsoft identity). BOTH required.
ACCOUNT_LINK_XBOX_CLIENT_ID=...
ACCOUNT_LINK_XBOX_CLIENT_SECRET=...

# Riot Games (RSO). BOTH required.
ACCOUNT_LINK_RIOT_CLIENT_ID=...
ACCOUNT_LINK_RIOT_CLIENT_SECRET=...

Redis is required: the mint throttle fails closed without it.

Your server mints a URL for a contact, then sends the player to it.

curl -X POST $API/v1/accounts/mint-link \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider":"steam","email":"player@example.com"}'
{ "url": "https://api.yourgame.com/v1/accounts/steam/start?t=...", "expiresAt": "..." }

Open that URL. The engine redirects to the platform, the player signs in, and the callback writes the link and shows a result page. The URL is engine-origin, never the provider's.

Mint on click, not in advance

The state token lives 15 minutes by default (ACCOUNT_LINK_STATE_TTL_SECONDS). An expired link is refused and nothing is written, so mint the URL when the player clicks, not when the page loads.

If the player is already signed in to your site, skip your backend entirely. POST /v1/accounts/link-url takes a userToken your server minted and returns the same shape. It mints only for that token's own user: a contactId, email or differing userId in the body is a 403 with no mint.

The three planes

The same fact reaches you three ways. Pick per use case.

PlaneSurfaceUse it for
PULL/v1/accounts/*The authoritative read. Reconciliation, support tooling, "who owns this SteamID right now".
PUSHaccount.linked, account.unlinked, account.link_failedKeeping your own database in sync as links change.
IN-PROCESSafterLink / afterUnlink hooksWriting straight into your production database inside your own app.

The version contract

This is the part to get right. Every mutation carries a version that increases monotonically per (provider, providerUserId).

Upsert on that pair and apply only when the incoming version is greater than what you have stored. That one rule makes duplicate, out-of-order and late deliveries all no-ops, so you never need to reason about delivery order.

// version is a decimal STRING, because the column is a Postgres bigint.
if (BigInt(incoming.version) > BigInt(stored.version)) {
  await upsert(incoming);
}

Never parseInt a version. A value above Number.MAX_SAFE_INTEGER rounded through a JavaScript number breaks the comparison silently, which is the exact case the guard exists for. Compare with BigInt() or a numeric column.

Examples

Register providers

Providers are not listed in your code. The env presets build them, so the same wiring serves a Steam-only deploy and a Steam plus Twitch one. What you write is the hook.

import { contacts, type Database } from "@hogsend/db";
import { createHogsendClient, type AccountLinkHooks } from "@hogsend/engine";
import { eq, sql } from "drizzle-orm";

// The hooks are passed INTO createHogsendClient, which is what builds `db`, so
// they read a deferred handle wired after the client exists.
let dbHandle: Database | undefined;
export function setAccountLinkDb(db: Database): void {
  dbHandle = db;
}

export const accountLinkHooks: AccountLinkHooks = {
  // Post-commit, at-least-once, fail-open, bounded at 5s. Idempotent by
  // construction: one UPDATE setting the same keys to the same values.
  async afterLink(ctx) {
    if (!dbHandle) return;
    const patch = {
      [`${ctx.provider}_user_id`]: ctx.identity.providerUserId,
      [`${ctx.provider}_username`]: ctx.identity.username ?? null,
      // The bigint version as a STRING. Never parseInt it.
      [`${ctx.provider}_link_version`]: ctx.version,
    };
    await dbHandle
      .update(contacts)
      .set({
        properties: sql`jsonb_strip_nulls(COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb)`,
      })
      .where(eq(contacts.id, ctx.contactId));
  },
};

const client = createHogsendClient({
  accountLinks: {
    hooks: accountLinkHooks,
    allowedOrigins: ["https://play.yourgame.com"],
  },
});
setAccountLinkDb(client.db);

The SteamID lands on contacts.properties, so journeys, buckets and the Studio contact panel read it with no new machinery.

Add a platform we do not ship

Any OAuth2 platform is oauth2Link() plus a field mapping. No package to install and no engine change. PlayStation is shown here as a hypothetical — it requires PlayStation Partners Program access, which is why it is not built in.

import { AccountLinkCallbackError, oauth2Link } from "@hogsend/engine";

export const playstation = oauth2Link({
  meta: { id: "playstation", name: "PlayStation Network" },
  authorizeEndpoint: "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/authorize",
  tokenEndpoint: "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/token",
  clientId: process.env.PSN_CLIENT_ID ?? "",
  clientSecret: process.env.PSN_CLIENT_SECRET ?? "",
  scopes: ["psn:s2s"],
  usePkce: true,
  userInfo: {
    url: "https://auth.api.sonyentertainmentnetwork.com/2.0/oauth/userinfo",
    map: (json) => {
      const profile = json as { sub?: string; online_id?: string };
      if (!profile.sub) {
        throw new AccountLinkCallbackError(
          "exchange_failed",
          "userinfo carried no sub",
        );
      }
      return {
        providerUserId: profile.sub,
        ...(profile.online_id ? { username: profile.online_id } : {}),
      };
    },
  },
});

Pass it as accountLinks: { providers: [playstation] }.

The version guard in full, with the discard branch written out. This is the example to copy exactly.

// The verified account.linked / account.unlinked payload, narrowed to the
// fields a mirror needs.
interface AccountEvent {
  state: "linked" | "unlinked";
  provider: string;
  providerUserId: string;
  contactId: string;
  // A decimal string, because the column is a Postgres bigint.
  version: string;
}

export async function handleAccountEvent(event: AccountEvent) {
  const stored = await db.playerAccount.findUnique({
    where: {
      provider_providerUserId: {
        provider: event.provider,
        providerUserId: event.providerUserId,
      },
    },
  });

  // DISCARD. A duplicate, a reordered pair and a late delivery all land here.
  if (stored && BigInt(event.version) <= BigInt(stored.version)) {
    return "discarded";
  }

  // APPLY. Keyed on the pair, never on contactId: a relink moves the pair
  // between contacts and both mutations share one version sequence.
  await db.playerAccount.upsert({
    where: {
      provider_providerUserId: {
        provider: event.provider,
        providerUserId: event.providerUserId,
      },
    },
    create: {
      provider: event.provider,
      providerUserId: event.providerUserId,
      state: event.state,
      contactId: event.contactId,
      version: event.version,
    },
    update: {
      state: event.state,
      contactId: event.contactId,
      version: event.version,
    },
  });
  return "applied";
}

Verify the webhook signature before this runs, and remember version is a decimal string.

The reverse lookup. Your game server knows a SteamID and nothing else. This turns it into the contact and puts the event on the lifecycle spine under the contact's own key, where a journey can trigger on it.

import { contacts } from "@hogsend/db";
import { defineWebhookSource, getLiveLink } from "@hogsend/engine";
import { eq } from "drizzle-orm";
import { z } from "zod";

export const gameServerSource = defineWebhookSource({
  meta: { id: "game-server", name: "Game server" },
  auth: {
    type: "match",
    header: "x-game-server-secret",
    envKey: "GAME_SERVER_WEBHOOK_SECRET",
  },
  schema: z.object({
    steamId: z.string().regex(/^\d{17}$/),
    event: z.string(),
  }),
  async transform(payload, ctx) {
    const link = await getLiveLink({
      db: ctx.db,
      provider: "steam",
      providerUserId: payload.steamId,
    });
    // No link means we do not know who this is. A SteamID is not a contact.
    if (!link) return null;

    const [contact] = await ctx.db
      .select()
      .from(contacts)
      .where(eq(contacts.id, link.contactId))
      .limit(1);
    if (!contact) return null;

    return {
      event: payload.event,
      // The canonical contact key: external_id ?? anonymous_id ?? id.
      userId: contact.externalId ?? contact.anonymousId ?? contact.id,
      ...(contact.email ? { userEmail: contact.email } : {}),
      eventProperties: { steam_id: payload.steamId },
    };
  },
});

The link events reach the journey plane, so you trigger on them directly — no webhook source needed:

export const steamWelcome = defineJourney({
  meta: {
    id: "steam-welcome",
    trigger: {
      event: "account.linked",
      where: (b) => b.prop("provider").eq("steam"),
    },
    entryLimit: { type: "once" },
  },
  async run(user, ctx) {
    await sendEmail({ to: user.email, template: Templates.STEAM_LINKED });
  },
});

account.unlinked and account.link_failed trigger the same way, but they do not carry the same properties. A where clause on a property an event does not have reads undefined, never matches, and enrols nothing — with nothing logged — so check before you write one:

Propertyaccount.linkedaccount.unlinkedaccount.link_failed
provider✓✓✓
state"linked""unlinked"—
providerUserId✓✓—
version✓✓—
username✓——
method✓——
relink✓——
reason—✓✓

A failed link carries only provider and reason — it never established a providerUserId. reason is player, api or relinked on an unlink, and denied, vetoed, exchange_failed or state_invalid on a failure.

Two planes, one fact

The journey plane fires journeys inside Hogsend. The outbound webhook ships state to your own subscriber. Both receive every link event, and neither replaces the other.

A link_failed journey can enrol with no contact

A failed link never creates a contact, but the cold path — a browser that failed before identifying — still routes the event with no contact attached. user.email is empty and the journey's contactId is null, so sendEmail({ to: user.email }) would throw and fail the run. Guard on user.email first, or trigger only where the subject is known.

Reading and unlinking

# Who owns this platform account right now?
curl $API/v1/accounts/steam/76561197960287930 -H "Authorization: Bearer $SECRET_KEY"

# Every live link for a contact.
curl "$API/v1/accounts?email=player@example.com" -H "Authorization: Bearer $SECRET_KEY"

There are two unlink surfaces. POST /v1/accounts/me/revoke is the primary one: your site already knows the signed-in player, so it needs a userToken and no email. DELETE /v1/accounts/{provider}/{providerUserId} is the operator path for reconciliation and support.

GET /v1/accounts/me returns display fields only, and never confirms existence. An absent, malformed, expired or forged token returns 200 {"accounts":[]}, identical to a real player with no links. It is browser-reachable, so it must not become an enumeration oracle.

POST /v1/accounts/import is insert-only. A pair that already has a live owner is reported under conflicts with the existing row untouched: only a completed sign-in may move a link, because moving one on an API call would let a bad import reassign a player's identity. A conflicting batch still applies its clean rows.

A backfill does not enrol journeys

enrollJourneys defaults to false. A backfill is a statement about the past, so importing 1000 existing links does not run your account.linked journey 1000 times — nobody sends a welcome email to their whole back catalogue on migration day. Pass "enrollJourneys": true when you do want it.

The outbound webhook fires either way: the customer's mirror must converge whether or not a journey ran. Imported rows are stamped method: "import", so an opted-in trigger can still exclude them with where: (b) => b.prop("method").neq("import").

Adding another platform

Six providers ship built-in: Steam (OpenID 2.0, no credentials), Twitch, Battle.net, Epic Games, Xbox and Riot Games (all OAuth2, each needing a client id and secret). Providers are configuration, not packages — oauth2Link() takes an authorize URL, a token URL, a profile URL and a field mapping, which is all most OAuth2 platforms need. The PlayStation example above is a whole provider.

Platforms that are NOT built in and why:

  • GOG — no public OAuth2 API. GOG Galaxy has no third-party authorization endpoint.
  • PlayStation — Sony's auth APIs require PlayStation Partners Program membership, which is an NDA-gated program with no public docs.

Discord is deliberately not an account-link provider: it already links through @hogsend/plugin-discord, and a second writer on the same contact field would drift.

Limitations

Known and named, so you do not hunt for them.

  • Steam returns no username or avatarUrl unless STEAM_WEB_API_KEY is set. The OpenID assertion carries the steamid64 and nothing else.
  • Riot Games returns no username from the RSO userinfo endpoint. The link carries only the PUUID.
  • Xbox links to the Microsoft account identity (Graph API). The Xbox gamertag is not fetched (it requires a separate Xbox Live token exchange that oauth2Link cannot express).
  • Not built yet: the hosted manage page, an embed SDK button, the @hogsend/client accounts.* resource, the periodic property sync, and the Studio panel.
  • The hosted result pages are functional and unbranded.