Build your own widget
A custom support panel on the messaging API.
A custom panel needs three pieces: your UI, a server proxy, and a webhook receiver.
MEDIAN_KEY stays on your server. Your panel calls your own routes and your
server relays to Median. For replies, either push webhook events to the browser
over a websocket or server-sent events, or poll your thread route while the
panel is open.
Proxy the API
Two routes are enough:
const MEDIAN = "https://api.median.sh/v1";
const headers = {
Authorization: `Bearer ${process.env.MEDIAN_KEY}`,
"Content-Type": "application/json",
};
app.get("/support/thread", async (req, res) => {
const response = await fetch(
`${MEDIAN}/thread?session=${encodeURIComponent(sessionFor(req))}`,
{ headers },
);
res.status(response.status).json(await response.json());
});
app.post("/support/messages", async (req, res) => {
const response = await fetch(`${MEDIAN}/messages`, {
method: "POST",
headers,
body: JSON.stringify({
session: sessionFor(req),
body: req.body.text,
user: { name: req.user?.name, email: req.user?.email },
}),
});
res.status(response.status).json(await response.json());
});
Render the thread
Everything the panel shows is in the thread response:
- Group consecutive
sender: "agent"messages, and draw a divider whereconversationIdchanges. - A
pendingmessage is a reply in progress. Show a typing indicator; it fills in place with the same id. - While
awaitingHumanis true, tell the visitor a person is coming. - After
statusbecomesresolved, the next message starts a new conversation automatically.
Fetch the agent’s name and avatar from
/v1/config instead of hardcoding them,
so renaming the agent in the dashboard carries through.
Receive replies
Subscribe your receiver to message.created,
verify the signature, and forward
data.message to the right visitor. Keep a map from conversationId to
session. Update it from every POST /v1/messages response, which returns
{ conversationId, messageId }. The id changes when a resolved conversation
is followed by a new message.
Filter what arrives. message.created also fires for the visitor’s own
messages (sender: "visitor"), which your panel already shows, and for
conversations from every other channel. Skip visitor messages and any
conversationId your map does not hold.
What you give up
| Feature | Status |
|---|---|
| Signed identity | Widget only. The API already trusts your server’s session |
| Realtime without your own server | Widget only. It ships its own connection |