Median

Build your own widget

A custom support panel on the messaging API.

Updated Oct 1, 20261 minute read

A custom panel needs three pieces: your UI, a server proxy, and a webhook receiver.

flowchart LR
  B[Browser] --> S[Your server]
  S --> P[api.median.sh]
  P -- "webhook POST" --> R[Your receiver]
  R -- "your channel" --> B

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());
});

:::danger Derive the session on your server from your own auth. Use the signed in user's id, or a random id you set in a cookie. If the browser chooses it, anyone can read anyone's thread. :::

Render the thread

Everything the panel shows is in the thread response:

  • Group consecutive sender: "agent" messages, and draw a divider where conversationId changes.
  • A pending message is a reply in progress. Show a typing indicator; it fills in place with the same id.
  • While awaitingHuman is true, tell the visitor a person is coming.
  • After status becomes resolved, 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

FeatureStatus
Signed identityWidget only. The API already trusts your server's session
Realtime without your own serverWidget only. It ships its own connection

Still need help?

    Esc