Median

Identity

Tie conversations to the signed in person, require sign in, and reset on sign out.

Updated Oct 1, 20267 minute read
app/api/median/identity/route.ts
import { auth } from "@clerk/nextjs/server";
import { medianIdentity } from "@mediansh/agent-tools";

export const { GET } = medianIdentity(async () => (await auth()).userId);
app/layout.tsx
<MedianSupport identity="/api/median/identity" />

Without identity, a conversation belongs to the browser. The widget keeps a random token in localStorage, so one person on two devices is two visitors with two histories. A signed user.id ties the conversation to the person on every device.

Median only believes an id that arrives with a hash your server signed.

Serve the identity route

medianIdentity builds a GET route that returns the median_pk_ id and the signed in person, already signed. It reads MEDIAN_KEY from the server environment, so the browser needs no Median value of its own.

Return an object to show your team more than an id:

app/api/median/identity/route.ts
import { medianIdentity } from "@mediansh/agent-tools";

export const { GET } = medianIdentity(async (request) => {
  const session = await getSession(request);
  if (!session) return null;

  return {
    id: session.user.id,
    name: session.user.name,
    email: session.user.email,
    avatarUrl: session.user.image,
    metadata: { plan: session.org.plan },
  };
});

medianIdentity(resolver, options) takes an optional key that replaces MEDIAN_KEY. signIn and apiUrl are for site sign-in.

Resolver return values

ReturnsResult
"user_123"That id, signed
{ id, name?, email?, avatarUrl?, metadata? }The id, signed, with the details your team sees
null or undefinedNobody is signed in. The visitor is anonymous
"", or an object whose id is empty or not a stringAnonymous
ThrowsAnonymous. The server logs Median: the identity resolver threw.

Empty name and email values are dropped. An avatarUrl that is not https is dropped. An empty metadata object is dropped.

Response

CaseStatusBody
Signed in200{ apiKey, user: { id, hash, name?, email?, avatarUrl?, metadata? } }
Nobody signed in, or the resolver threw200{ apiKey }
No key500{ error: { code: "missing_median_key", message } }
The key is not a Median key, or is malformed500{ error: { code: "invalid_median_key", message } }

Every response carries cache-control: private, no-store, max-age=0 and vary: cookie, authorization.

The request the widget sends

Value
MethodGET to the identity path
Credentialsinclude, so your cookies are sent
Headersaccept: application/json. No Authorization header
WhenOnce when the component mounts, and again if the path changes
  • A session kept in a cookie works as is. A session kept only in a bearer token never reaches the resolver. Use Sign it yourself instead.
  • medianIdentity sets no CORS headers. Serve the route from the same origin as the page.
  • Components that mount together share one request. MedianSupport, MedianSupportModal, MedianFeedback, MedianContactForm and useMedianContact all take identity.
<MedianSupport identity="/api/median/identity" />
<MedianFeedback identity="/api/median/identity">
  <button>Share feedback</button>
</MedianFeedback>

Pass the key as well

<MedianSupport apiKey="median_pk_..." identity="/api/median/identity" />
PropsUntil the route answersIf the route fails
identity onlyNothing rendersNothing renders
apiKey and identityThe launcher shows. The open panel shows a loading stateAnonymous visitor

A route fails when the request errors or answers with a status outside 200 to 299. A resolver that returns null or throws is not a failure.

Sign it yourself

Use this when your session lives where the widget renders, or when your server cannot serve a Web Request route. Sign on the server with the same MEDIAN_KEY the widget id came from.

On the server:

server/me.ts
import { signMedianUser } from "@mediansh/agent-tools";

app.get("/api/me", async (req, res) => {
  const user = req.session.user;
  if (!user) return res.json(null);

  res.set("cache-control", "private, no-store");
  res.json({
    id: user.id,
    name: user.name,
    email: user.email,
    medianHash: await signMedianUser(user.id),
  });
});

In the browser:

src/support.tsx
const me = useMe(); // your own hook that reads /api/me

<MedianSupport
  apiKey={import.meta.env.VITE_MEDIAN_PUBLIC_KEY}
  user={
    me
      ? { id: me.id, hash: me.medianHash, name: me.name, email: me.email }
      : undefined
  }
/>;
  • signMedianUser(userId, key?) reads MEDIAN_KEY when key is omitted. Call it on the server only.
  • VITE_MEDIAN_PUBLIC_KEY holds the median_pk_ id from publicKeyFromMedianKey(MEDIAN_KEY). The id is safe in client code.
  • Sign the exact string you pass as id. The check is character for character.
  • Any server that answers GET with { apiKey, user }, where user carries hash, also works as an identity route.

Site sign-in

The same route signs visitors in to your public site, so the site and the widget share one customer. Return an email, and say where your login page is:

app/api/median/identity/route.ts
export const { GET } = medianIdentity(resolveUser, {
  signIn: (returnTo) => `/login?next=${encodeURIComponent(returnTo)}`,
});

Then pick Your app under Site → Sign-in and paste the route's URL. See Site sign-in.

RequestAnswer
No median_request parameterJSON for the widget, as above
?median_request=<id>, signed inA 302 to Median with a signed token
?median_request=<id>, signed out, with signInA 302 to signIn(returnTo)
?median_request=<id>, signed out, no signInA 302 back to the site, which says to sign in first
?median_request=<id>, no email500 with Median: site sign-in needs the person's email.

Behavior

SituationResult
id and hash matchThe conversation follows the person on every device
Only one of id and hash is setBoth are ignored. The visitor stays tied to the browser
hash does not match, or id is longer than 128 charactersAnonymous, with a console error
The widget id and hash come from different keysAnonymous, with a console error
The browser already has anonymous conversationsThey move to the signed in person when the signed user arrives
The route's user has no hashIt is ignored
user prop and identity are both setThe user prop wins. The route's user is ignored
name, email, avatarUrl, metadataShown to your team. Never verified
The key is revoked in Settings → APIIts median_pk_ id stops working, and the widget hides itself

Nothing is stored until the visitor sends a first message. After that, a change of signed user syncs as it happens.

Require sign in

Use it for a product nobody uses signed out. Until somebody is signed in, the panel asks them to sign in, and no conversation is opened or read.

<MedianSupport
  identity="/api/median/identity"
  requireSignIn
  onSignIn={() => router.push("/login")}
/>

With a session you already have:

<MedianSupport
  apiKey="median_pk_..."
  user={session?.user}
  requireSignIn
  onSignIn={() => router.push("/login")}
/>
userThe visitor gets
Any field set to a non-empty valueThe conversation
Missing or emptySign in to chat and Ask Acme anything once you are signed in. No thread, no composer
  • Any field counts, not only id. A name or an email says somebody is there.
  • Sign in shows only with onSignIn. Without it, the panel shows the same text and no button.
  • With identity, the panel shows a loading state until the route answers, rather than inviting a signed in person to sign in.
  • Pass user as soon as your session resolves. The panel shows the sign in state until it arrives.
  • The launcher stays in the corner.
  • Nothing is read while the panel is locked. There is no thread and no unread count.

:::warning requireSignIn controls what the widget shows. It does not stop a crafted request to the API. A signed hash is what proves who somebody is. :::

Reset on sign out

import { medianSupport } from "@mediansh/widget";

medianSupport.reset();

reset() removes this browser's session token and saved email. The next message starts a new, anonymous visitor. The old conversations stay in your inbox.

reset() also signs out the user the widget holds at that moment.

You passAfter reset()
userThe old user is not sent again, even if your state still passes them. Once you pass someone else or nobody, or the widget unmounts, they can sign back in
identityThe widget reads the route again. If the route still answers with the old user, that user is not sent

With user, call reset() before or after your own sign out. With identity, call it after, so the route already answers with nobody when it is read again.

A mounted widget reads the identity route when it mounts and when reset() runs. For a sign in that happens without a page load, give the widget a key that changes with the user, so it remounts and reads the route again.

:::warning Without reset(), the next person on that browser sees the previous person's conversation, and anything they send before signing in goes into it. :::

Troubleshooting

MessageWhereFix
Median: <MedianSupport> was given neither apiKey nor identity, so nothing is on the page. Pass the median_pk_ id from publicKeyFromMedianKey(MEDIAN_KEY), or the path of a route built with medianIdentity().Browser consolePass apiKey or identity
Median: could not read the identity route. followed by /api/median/identity answered 500. It should return { apiKey, user } from medianIdentity().Browser consoleOpen the route in a browser tab and read the error in its body
Median: MEDIAN_KEY is missing. Add the key from Settings under API to your server environment, or pass { key } to medianIdentity.Route body, missing_median_keySet MEDIAN_KEY on the server
Median: MEDIAN_KEY must start with median_key_. Copy the Median key from Settings under API.Route body, invalid_median_keyUse the median_key_ value, not the median_pk_ id
Median: MEDIAN_KEY is malformed. Copy it again from Settings under API.Route body, invalid_median_keyCopy the whole key again
Median: the identity resolver threw.Server logFix the resolver. Until then, every visitor is anonymous
Median: the user hash passed to <MedianSupport> did not match. Sign the same string you pass as `user.id` with the Median key that produced this widget id. Until it matches, this visitor gets a conversation of their own on every device.Browser consoleSign the exact string you pass as id, with the key this widget id came from
Median: a user hash was passed to <MedianSupport>, but its widget id and hash came from different Median keys. Derive both from the same key.Browser consoleDerive apiKey and hash from one current MEDIAN_KEY
Median: MEDIAN_KEY is missing. Add the Median key from Settings under API to your server environment.Thrown by signMedianUserSet MEDIAN_KEY, or pass key
SymptomCause
The same customer shows twice in your inboxThey used two devices without a signed id
A signed in person's conversations do not follow them to another deviceThe resolver returned an empty id, or the route's user had no hash
The sign in panel shows for a signed in personuser is empty or arrives late
A new visitor sees the last person's threadreset() was not called

Still need help?

    Esc