Skip to main content

Customer portal

A support screen inside your own application: every conversation this user has ever had with you, in your own layout, under your own navigation. Email tickets from last year and the chat they opened this morning are one list.

Why not an API key

The obvious move is to call the REST API from the page. Do not: a project key reads and writes every ticket in the project, so shipping one to a browser publishes your whole inbox to anyone who opens devtools. CORS does not help — it constrains browsers, and a stolen key is used with curl.

What a page needs instead is a credential that reaches one customer and expires. That is a portal session: your server proves who the visitor is, gets a short-lived token back, and hands it to the page. Nothing secret ever reaches the browser.

Minting a session

One call, from your backend, for the user it has already signed in. The proof is an HMAC-SHA256 of your user id keyed with the project's identity secret — the same signature the widget calls userHash, so if you already do identity verification there is nothing new to build. The secret is under Chat widget → Identity.

bash
curl -X POST https://api.helpwing.app/portal/pk_your_public_key/sessions/ \
  -H 'Content-Type: application/json' \
  -d '{
    "external_id": "usr_123",
    "email": "ada@example.com",
    "name": "Ada Lovelace",
    "user_hash": "<hex hmac-sha256 of \"usr_123\">"
  }'
external_idstring
Your own user id, up to 120 characters. The customer is matched on it, and it is the value the signature covers. Required.
user_hashstring
Hex HMAC-SHA256 of external_id, keyed with the project identity secret. Required — there is no unverified mode here.
emailstring
Used the first time only, to find the record this person already had before they ever signed in.
namestring
Display name. Fills a blank; it never overwrites what an agent corrected.
metadataobject
Anything worth seeing beside their tickets: plan, account id, seat count.

The signature is not optional, which is where this parts company with the widget. There an unproved claim still gets the visitor a conversation of their own; here it would be a request to read a stranger's support history, so an unsigned or wrongly signed claim is refused outright whatever the project's identity setting says.

json
{
  "token": "hws_…",
  "expires_at": "2026-08-30T09:42:44Z",
  "expires_in": 1800,
  "customer": {
    "id": "0f5d2a1c-…",
    "external_id": "usr_123",
    "email": "ada@example.com",
    "name": "Ada Lovelace",
    "avatar_url": ""
  }
}

Their old email tickets come along

The first time you mint a session for someone, an email that matches a customer nobody has ever identified takes that record over. That is usually the whole point: most people reaching a support screen for the first time wrote in months ago, and those conversations are the history they came to read. A record already carrying a different user id belongs to someone else and is left alone.

javascript
// server-side only — the identity secret never leaves this file
import { createHmac } from 'node:crypto'

const HELPWING = 'https://api.helpwing.app'
const PUBLIC_KEY = 'pk_your_public_key'

export async function supportSession(user) {
  const userHash = createHmac('sha256', process.env.HELPWING_IDENTITY_SECRET)
    .update(user.id)
    .digest('hex')

  const response = await fetch(`${HELPWING}/portal/${PUBLIC_KEY}/sessions/`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      external_id: user.id,
      email: user.email,
      name: user.name,
      user_hash: userHash,
    }),
  })

  if (!response.ok) throw new Error(`Helpwing refused the claim: ${response.status}`)
  return response.json()   // { token, expires_at, expires_in, customer }
}
python
# server-side only — the identity secret never leaves this file
import hashlib, hmac, os, requests

HELPWING = "https://api.helpwing.app"
PUBLIC_KEY = "pk_your_public_key"

def support_session(user) -> dict:
    user_hash = hmac.new(
        os.environ["HELPWING_IDENTITY_SECRET"].encode(),
        user.id.encode(),
        hashlib.sha256,
    ).hexdigest()

    response = requests.post(
        f"{HELPWING}/portal/{PUBLIC_KEY}/sessions/",
        json={
            "external_id": user.id,
            "email": user.email,
            "name": user.name,
            "user_hash": user_hash,
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

Call it from an endpoint of your own — GET /api/support/session, say — that returns what it got. Do not call the exchange from the page: it needs the identity secret, and it answers without CORS headers precisely so that trying is a visible failure rather than a quiet one.

Calling from the browser

Send the token back in X-Helpwing-Session on every other call. It reaches exactly one customer's conversations — there is no endpoint here that takes a customer id, so there is nothing to get wrong.

javascript
const { results } = await fetch(`${HELPWING}/portal/${PUBLIC_KEY}/conversations/`, {
  headers: { 'X-Helpwing-Session': token },
}).then(response => response.json())

// [{ id, reference, subject, status, source,
//    last_message_at, last_message_preview, message_count, created_at }, …]

The list holds every channel — email, chat, portal — newest activity first, in the same paginated envelope as the rest of the API. Conversations an agent marked as spam are left out: nobody is going to answer one, and listing it promises a reply that is not coming.

Add your application's origin under Chat widget → Allowed origins. It is the project's list rather than the widget's alone, and it is checked here too; leaving it empty allows any origin, as it always has.

Renewing

A session lasts half an hour and then answers 401. That is deliberate: the browser holding it was vouched for by your server, and your server is the only party that knows whether that person is still signed in — a sign-out, a password change or a cancelled account is news we are never told. Renewing means asking you again.

Keep the token in memory, not in localStorage: it outlives the page by nothing useful, and a shared computer should not keep one lying around. One wrapper covers the whole lifecycle.

javascript
// Your page asks *your* server for a session; your server does the exchange.
let session = null

async function token() {
  if (!session || Date.now() > session.until - 30_000) {
    const minted = await fetch('/api/support/session').then(r => r.json())
    session = { token: minted.token, until: Date.now() + minted.expires_in * 1000 }
  }
  return session.token
}

export async function portal(path, init = {}) {
  const call = async () => fetch(`${HELPWING}/portal/${PUBLIC_KEY}${path}`, {
    ...init,
    headers: { ...init.headers, 'X-Helpwing-Session': await token() },
  })

  let response = await call()
  if (response.status === 401) {   // it aged out mid-visit; mint another and retry once
    session = null
    response = await call()
  }
  return response
}

Opening and answering

A new conversation takes a subject the person typed, which makes a better inbox row than the first line of a chat message. It is routed like anything else that arrives: the project's default inbox, default priority and default assignee, who is notified exactly as a human assignment would notify them.

javascript
await portal('/conversations/', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    subject: 'Export keeps failing',
    body_text: 'The CSV download returns a 500 every time.',
    metadata: {                       // whatever your form knows
      category: 'bug',
      page: location.pathname,
      account_id: user.accountId,
    },
  }),
})

It arrives with source: "portal", which is what tells the agent the customer is reading replies in your application rather than in a mailbox — distinct from api, which means your server filed a ticket on somebody's behalf.

metadata is free-form JSON kept on the ticket and shown to the agent beside it: the dropdown your form asked about, the page they were on, the order they were looking at. Up to 4 KB — send it as a JSON-encoded string when the request is multipart, since a file upload leaves nowhere else to put an object.

It comes from the browser, so treat it as a claim rather than a fact. Anything that has to be true — the plan they are on, the seats they bought — belongs in the session exchange's own metadata instead: your server signs that one, and it lands on the customer's record where it is right on every conversation rather than on the one they happened to open.

javascript
await portal(`/conversations/${id}/messages/`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    body_text: 'Still happening this morning.',
    client_message_id: crypto.randomUUID(),   // safe to retry
  }),
})

A reply behaves exactly like an inbound email on the same ticket: the inbox reorders, a resolved conversation reopens if the project asks it to, and the assignee is notified. Send a client_message_id and a retry over a flaky connection returns the message it already stored rather than posting a second one. Attachments go in the same request, as multipart with one files part per file.

The email copy still goes out

Reading a conversation here does not suppress the emailed copy of an agent's reply, which is where the portal differs from the chat widget. A chat panel is open or it is not; a support page is something somebody may have left open in a tab since Tuesday, and a customer who never comes back to it should still hear the answer.

Live updates

Every read hands you a cursor. Send it back as ?since= to get what has happened since, and add &wait=25 to hold the request open until something does — which gives near-instant delivery over ordinary HTTP, with no socket to keep alive.

javascript
const opened = await portal(`/conversations/${id}/`).then(r => r.json())
render(opened.messages)

let cursor = opened.cursor
while (visible) {
  const update = await portal(
    `/conversations/${id}/updates/?since=${encodeURIComponent(cursor)}&wait=25`,
  ).then(r => r.json())

  cursor = update.cursor
  if (update.messages.length) render(update.messages)
  if (update.typing) showTypingIndicator(update.typing.name)
}

What comes back is what the customer is allowed to see: their own messages, agent replies, system entries and whether somebody is typing. Internal notes are not in the query at all, so no bug on your side can put one on the screen.

Every endpoint

Six, all under https://api.helpwing.app/portal/pk_your_public_key/.

POST/sessions/
The exchange. From your server only.
GET/conversations/
Their whole history, newest activity first. Paginated with page and page_size.
POST/conversations/
Opens one. Takes subject, body_text and multipart files.
GET/conversations/{id}/
The transcript, plus a cursor to follow it with.
POST/conversations/{id}/messages/
Answers one. Takes body_text, files and client_message_id.
GET/conversations/{id}/updates/
What changed since ?since=, optionally holding the request open with &wait=.

Each one's parameters, request body and response shape are in the portal API reference, generated from the API itself — with the same document as an OpenAPI file, if you would rather generate a client than read one.

When something goes wrong

The same error envelope as the rest of the API, with a stable code to switch on.

401session_expired
No token, an unknown one, or one that has aged out. Mint another and retry.
403permission_denied
On the exchange, a signature that does not match. On everything else, an origin that is not on the allow list.
404not_found
No project with that public key — or a conversation that is not this customer’s, which answers the same way on purpose.
429rate_limit_exceeded
Counted per session rather than per address, so one busy user cannot spend everybody else’s budget.
NextPortal API reference →Every portal endpoint, with its parameters, its body and what it returns.