Identity
Tie conversations to the signed in person, require sign in, and reset on sign out.
import { auth } from "@clerk/nextjs/server";
import { medianIdentity } from "@mediansh/agent-tools";
export const { GET } = medianIdentity(async () => (await auth()).userId);
<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:
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
| Returns | Result |
|---|---|
"user_123" |
That id, signed |
{ id, name?, email?, avatarUrl?, metadata? } |
The id, signed, with the details your team sees |
null or undefined |
Nobody is signed in. The visitor is anonymous |
"", or an object whose id is empty or not a string |
Anonymous |
| Throws | Anonymous. 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
| Case | Status | Body |
|---|---|---|
| Signed in | 200 | { apiKey, user: { id, hash, name?, email?, avatarUrl?, metadata? } } |
| Nobody signed in, or the resolver threw | 200 | { apiKey } |
| No key | 500 | { error: { code: "missing_median_key", message } } |
| The key is not a Median key, or is malformed | 500 | { 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 | |
|---|---|
| Method | GET to the identity path |
| Credentials | include, so your cookies are sent |
| Headers | accept: application/json. No Authorization header |
| When | Once 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.
medianIdentitysets no CORS headers. Serve the route from the same origin as the page.- Components that mount together share one request.
MedianSupport,MedianSupportModal,MedianFeedback,MedianContactFormanduseMedianContactall takeidentity.
<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" />
| Props | Until the route answers | If the route fails |
|---|---|---|
identity only |
Nothing renders | Nothing renders |
apiKey and identity |
The launcher shows. The open panel shows a loading state | Anonymous 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:
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:
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?)readsMEDIAN_KEYwhenkeyis omitted. Call it on the server only.VITE_MEDIAN_PUBLIC_KEYholds themedian_pk_id frompublicKeyFromMedianKey(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
GETwith{ apiKey, user }, whereusercarrieshash, also works as anidentityroute.
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:
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.
| Request | Answer |
|---|---|
No median_request parameter |
JSON for the widget, as above |
?median_request=<id>, signed in |
A 302 to Median with a signed token |
?median_request=<id>, signed out, with signIn |
A 302 to signIn(returnTo) |
?median_request=<id>, signed out, no signIn |
A 302 back to the site, which says to sign in first |
?median_request=<id>, no email |
500 with Median: site sign-in needs the person's email. |
Behavior
| Situation | Result |
|---|---|
id and hash match |
The conversation follows the person on every device |
Only one of id and hash is set |
Both are ignored. The visitor stays tied to the browser |
hash does not match, or id is longer than 128 characters |
Anonymous, with a console error |
The widget id and hash come from different keys |
Anonymous, with a console error |
| The browser already has anonymous conversations | They move to the signed in person when the signed user arrives |
The route’s user has no hash |
It is ignored |
user prop and identity are both set |
The user prop wins. The route’s user is ignored |
name, email, avatarUrl, metadata |
Shown to your team. Never verified |
| The key is revoked in Settings → API | Its 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")}
/>
user |
The visitor gets |
|---|---|
| Any field set to a non-empty value | The conversation |
| Missing or empty | Sign 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
useras 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.
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 pass | After reset() |
|---|---|
user |
The 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 |
identity |
The 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.
Troubleshooting
| Message | Where | Fix |
|---|---|---|
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 console | Pass 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 console | Open 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_key |
Set 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_key |
Use 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_key |
Copy the whole key again |
Median: the identity resolver threw. |
Server log | Fix 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 console | Sign 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 console | Derive 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 signMedianUser |
Set MEDIAN_KEY, or pass key |
| Symptom | Cause |
|---|---|
| The same customer shows twice in your inbox | They used two devices without a signed id |
| A signed in person’s conversations do not follow them to another device | The resolver returned an empty id, or the route’s user had no hash |
| The sign in panel shows for a signed in person | user is empty or arrives late |
| A new visitor sees the last person’s thread | reset() was not called |