Developers

Optional UI components

The server SDK (Get started) is required for contact updates and webhooks. UI helpers in anemone-server-js are optional — use them when you want a ready-made account connection widget or sync status strip instead of building your own.

Package exports

  • anemone-server-js/embed — framework-agnostic account widget (createAnemoneWidget) plus session probe helpers
  • anemone-server-js/ui — React AnemoneSyncCard for sync feedback (requires React 18+)

React is an optional peer dependency. Import only the subpath you need; tree-shaking keeps the main server client out of browser bundles when you bundle carefully.

1. Expose tenant connection info (server)

Both the embed and a hand-rolled UI call a route on your backend that returns connect/disconnect URLs and an opaque link token. Never put your API key in the browser — mint tokens server-side with client.mintDirectConnectToken (preferred) or sign offline with encodeExternalUserId using your API key prefix. For auto-connect, mount the SDK's fixed signed match endpoint at /api/anemone/auto-connect/match; when an Anemone user adds a new address, Anemone sends that endpoint only the verified email needed for a one-user lookup. See the demo implementations: GET /api/anemone/connection and POST /api/anemone/auto-connect/match.

// GET /api/tenant/connection — authenticated session required
const { linkToken } = await client.mintDirectConnectToken({ externalUserId: session.userId });

{
  "externalUserId": "user-123",
  "connectExternalUserId": "anlt1....",
  "returnUrl": "https://your-app.com/account",
  "primaryBaseUrl": "https://app.anemone.com",
  "connectUrl": "https://app.anemone.com/connect?external_user_id=anlt1....",
  "disconnectUrl": "https://app.anemone.com/disconnect?external_user_id=anlt1....",
  "connectSignInUrl": "https://app.anemone.com/sign-in?redirect_url=...",
  "disconnectSignInUrl": "https://app.anemone.com/sign-in?redirect_url=...",
  "connected": true,
  "autoConnected": true,
  "mockMode": false
}

The auto-connect match endpoint receives a signed account.email_match.requested request and should return only { "matched": true, "externalUserId": "user-123" } or { "matched": false }.

Primary stores the mapping in tenant_link_token (opaque) and user_tenant_link (after connect). Paths are /connect?external_user_id=… and /disconnect?external_user_id=… — no internal tenant id or name in URLs or SDK types.

2. Initialize the account widget embed

Call createAnemoneWidget in the browser after your page mounts. It renders sign-in / connect / disconnect actions, probes the user's Clerk session on the primary origin via a hidden iframe, and refreshes when the tab becomes visible again after a redirect.

Live preview · mock data

import { createAnemoneWidget } from "anemone-server-js/embed"; const widget = createAnemoneWidget({ mount: "#anemone-account", tenantName: "Your App", theme: "default", // or "minimal" | "dark" | "glass" | "vibrant" | "corporate" fetchConnection: () => fetch("/api/tenant/connection").then((r) => { if (!r.ok) throw new Error("Could not load connection info"); return r.json(); }), // Optional: show sync status + Try again below the account widget sync: { onRetry: async () => { const res = await fetch("/api/user/contact", { method: "PUT", /* body */ }); if (!res.ok) throw new Error("Save failed"); return res.json(); // ContactSaveResult shape }, }, }); // After your own save handler succeeds: // widget.reportSyncResult(result); // widget.reportSyncError(error); // On page teardown: // widget.destroy();

Styles are injected automatically (injectStyles: true by default). To use your own CSS, set injectStyles: false and import anemone-server-js/embed/widget.css, or copy the bundled stylesheet and theme tokens from the SDK source.

When to use the embed. Vanilla JS, legacy stacks, or anywhere you want the full account widget without adopting React. For sync-only feedback in React, use AnemoneSyncCard (below) instead of the embed's optional sync block.

3. React sync status card (optional)

If you already have your own account UI but want consistent sync messaging (syncing, synced, queued, failed with retry), render AnemoneSyncCard and drive it with helpers from the main package export.

Live preview · mock data

Synced with Anemone

import { contactSyncStateFromError, contactSyncStateFromSaveResult, type ContactSyncDisplayState, } from "anemone-server-js"; import { AnemoneSyncCard } from "anemone-server-js/ui"; const [syncState, setSyncState] = useState<ContactSyncDisplayState>({ status: "idle", }); async function onSave() { setSyncState({ status: "syncing" }); try { const res = await fetch("/api/user/contact", { method: "PUT", /* ... */ }); const body = await res.json(); setSyncState(contactSyncStateFromSaveResult(body)); } catch (e) { setSyncState(contactSyncStateFromError(e)); } } <AnemoneSyncCard state={syncState} onRetry={() => void onSave()} retryDisabled={saving} />

4. Hand-roll your own UI

You do not have to use the embed or React card. Many partners keep full control of layout and styling by reimplementing the same flow the SDK encodes: load connection metadata from your API, probe primary sign-in state, redirect to connect/disconnect URLs, and invalidate the session cache when the user returns from Anemone.

Use these building blocks from anemone-server-js/embed:

  • probePrimaryClerkSession(primaryBaseUrl, connectExternalUserId) — hidden iframe to /auth/embed-session on the primary origin; returns signedIn, email, and linked
  • invalidatePrimarySessionCache() — call before navigating to sign-in, connect, or disconnect so the next probe is fresh

Refresh Clerk status when document.visibilityState becomes visible again and the tab was hidden for at least ~2 seconds (the user likely completed a redirect on the primary site).

Implementation example (React)

The following is a condensed version of the demo's hand-rolled connection UI — same behavior as the embed, styled with your design system. Full source: CustomAnemoneContactUI (also see useAnemoneConnection, fetchAnemoneConnection, and probePrimaryClerkSession).

"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import {
  invalidatePrimarySessionCache,
  probePrimaryClerkSession,
  type TenantConnectionInfo,
} from "anemone-server-js/embed";

const VISIBILITY_REFRESH_MS = 2_000;

export function AnemoneConnectionPanel() {
  const [info, setInfo] = useState<TenantConnectionInfo | null>(null);
  const [linked, setLinked] = useState(false);
  const [signedIn, setSignedIn] = useState(false);
  const [email, setEmail] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const hiddenAt = useRef<number | null>(null);
  const infoRef = useRef<TenantConnectionInfo | null>(null);

  const refreshSession = useCallback(async (connection: TenantConnectionInfo, force = false) => {
    if (connection.mockMode || !connection.primaryBaseUrl) {
      setLinked(false);
      setSignedIn(connection.mockMode);
      setEmail(null);
      return;
    }
    const status = await probePrimaryClerkSession(
      connection.primaryBaseUrl,
      connection.connectExternalUserId,
      { force },
    );
    setLinked(status.linked);
    setSignedIn(status.signedIn);
    setEmail(status.email);
  }, []);

  useEffect(() => {
    void (async () => {
      const res = await fetch("/api/tenant/connection");
      if (!res.ok) return;
      const connection = (await res.json()) as TenantConnectionInfo;
      setInfo(connection);
      infoRef.current = connection;
      setLoading(false);
      await refreshSession(connection);
    })();
  }, [refreshSession]);

  useEffect(() => {
    function onVisibilityChange() {
      if (document.visibilityState === "hidden") {
        hiddenAt.current = Date.now();
        return;
      }
      const connection = infoRef.current;
      if (!connection) return;
      const hiddenFor = hiddenAt.current ? Date.now() - hiddenAt.current : 0;
      hiddenAt.current = null;
      if (hiddenFor < VISIBILITY_REFRESH_MS) return;
      invalidatePrimarySessionCache();
      void refreshSession(connection, true);
    }
    document.addEventListener("visibilitychange", onVisibilityChange);
    return () => document.removeEventListener("visibilitychange", onVisibilityChange);
  }, [refreshSession]);

  if (loading || !info) return <p>Loading…</p>;

  function goToSignIn() {
    if (!info?.connectSignInUrl) return;
    invalidatePrimarySessionCache();
    window.location.assign(info.connectSignInUrl);
  }

  function goToConnect() {
    if (!info?.connectUrl || !signedIn) return;
    invalidatePrimarySessionCache();
    window.location.assign(info.connectUrl);
  }

  function goToDisconnect() {
    if (!info?.disconnectUrl) return;
    if (!signedIn && info.disconnectSignInUrl) {
      window.location.assign(info.disconnectSignInUrl);
      return;
    }
    invalidatePrimarySessionCache();
    window.location.assign(info.disconnectUrl);
  }

  return (
    <section aria-label="Anemone account">
      <h2>Anemone account</h2>
      <p>
        {linked
          ? "Your account is connected to Anemone."
          : signedIn
            ? "Connect your account to sync contact updates."
            : "Sign in to Anemone to connect your account."}
      </p>
      {!info.mockMode && (
        <span>{signedIn ? (email ?? "Signed in") : "Not signed in"}</span>
      )}
      <span>{linked ? "Connected" : "Not connected"}</span>
      {linked ? (
        <button type="button" onClick={goToDisconnect}>
          {signedIn ? "Disconnect account" : "Sign in to disconnect"}
        </button>
      ) : signedIn ? (
        <button type="button" onClick={goToConnect}>
          Connect account
        </button>
      ) : (
        <button type="button" onClick={goToSignIn}>
          Sign in
        </button>
      )}
    </section>
  );
}

Pair this panel with your own contact form and optionally AnemoneSyncCard for save/sync feedback. See Examples & sample app and the full paws-and-tails-demo project (account/contact page).