Developers
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.
anemone-server-js/embed — framework-agnostic account widget (createAnemoneWidget) plus session probe helpersanemone-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.
Direct Connect for partner merchants is experimental and coming soon. APIs and UI below describe the planned integration surface so you can prepare; availability in shared environments will follow.
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.
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
The widget always ends with a small attribution row: the Anemone logo and name plus links to the Anemone Terms of Service and Privacy Policy on primaryBaseUrl. Don't hide or restyle it away; custom UIs must include the same row (see required attribution).
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.
AnemoneSyncCard (below) instead of the embed's optional sync block.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
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 linkedinvalidatePrimarySessionCache() — call before navigating to sign-in, connect, or disconnect so the next probe is freshRefresh 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).
Every custom connect UI must show, somewhere inside the connection panel:
{primaryBaseUrl}/terms){primaryBaseUrl}/privacy)It can be small and low-contrast, for example a muted footer row below the connect button, but it must stay visible whenever the connect, disconnect, or sign-in actions are shown. Get the URLs from resolveAnemoneAttribution(info.primaryBaseUrl), which returns name, logoUrl, termsUrl, and privacyUrl. Outside React, renderAnemoneAttribution(host, info.primaryBaseUrl) renders the same row the packaged widget uses (the host needs the an-widget-host class and injected widget styles).
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,
resolveAnemoneAttribution,
type TenantConnectionInfo,
} from "anemone-server-js/embed";
const VISIBILITY_REFRESH_MS = 2_000;
// Required: Anemone logo + name and links to its Terms and Privacy.
function AnemoneAttribution({ primaryBaseUrl }: { primaryBaseUrl: string }) {
const { name, logoUrl, termsUrl, privacyUrl } = resolveAnemoneAttribution(primaryBaseUrl);
return (
<footer style={{ display: "flex", justifyContent: "space-between", gap: 12, fontSize: 11, opacity: 0.7 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={logoUrl} alt="" width={14} height={14} />
{name}
</span>
<nav aria-label={`${name} legal`}>
<a href={termsUrl} target="_blank" rel="noopener noreferrer">Terms</a>
{" · "}
<a href={privacyUrl} target="_blank" rel="noopener noreferrer">Privacy</a>
</nav>
</footer>
);
}
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>
)}
<AnemoneAttribution primaryBaseUrl={info.primaryBaseUrl} />
</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).