Docs/Get started

Connect your app

Three steps. Nothing in help-bites knows about your app beyond what you put on the project; your users keep their existing accounts.

1. Create a project#

Admin panel → Projects → New project. You get:

  • a public key pk_… — safe to ship to browsers; identifies the project
  • a secret sk_… — server only; signs identity tokens

Set the site URL (used in email links), the origins allowed to host the widget, the name replies are signed with, and the email addresses to notify when a user writes.

2. Mint identity tokens on your server#

Add one authenticated route to your backend that returns a short-lived HS256 JWT for the signed-in user. Claims:

claim value
iss the project public key
sub your user's id (stable, unique per project)
name optional display name
email optional; enables email notifications for that user
username optional; the public handle feedback boards show and @mention by — never an email address
avatar optional; an http(s) image URL for feedback boards
locale optional; the person's language, a tag such as es or pt-BR. It fills the contact's language the first time (one set in the panel or over the API is never replaced), and campaigns and sequences then send the matching translation of a template. See Languages
role optional; admin lets one of your staff moderate the feedback board
iat, exp issued-at and expiry (an hour is plenty)

Node, no dependencies:

import { createHmac } from 'node:crypto'

export function mintSupportToken(user) {
  const b64 = v => Buffer.from(JSON.stringify(v)).toString('base64url')
  const now = Math.floor(Date.now() / 1000)
  const head = b64({ alg: 'HS256', typ: 'JWT' })
  const body = b64({ iss: process.env.HELP_BITES_PROJECT_KEY, sub: user.id, name: user.name, email: user.email, iat: now, exp: now + 3600 })
  const sig = createHmac('sha256', process.env.HELP_BITES_SECRET).update(`${head}.${body}`).digest('base64url')
  return `${head}.${body}.${sig}`
}

Any JWT library works too (jose, jsonwebtoken, PyJWT, …): algorithm HS256, secret = the project secret.

Never send the secret to a browser. Rotate it from the project page if it leaks; tokens minted with the old one stop verifying immediately.

3. Show the chat#

Drop-in widget#

<script src="https://YOUR-SUPPORT-HOST/widget.js" data-project="pk_…" defer></script>
<script>
  window.HelpBites = window.HelpBites || []
  HelpBites.push(['init', {
    getToken: () => fetch('/api/support/token').then(r => r.json()).then(r => r.token),
    context: { type: 'bookmark', id: 'abc', label: 'The thing they are looking at', url: location.href } // optional
  }])
</script>
  • getToken is called when the chat opens and again whenever a token expires.
  • context is attached to new conversations so you know what the user was looking at. Update it with HelpBites.push(['setContext', {...}]).
  • HelpBites.push(['open']) / (['close']) control the panel.
  • data-position="left" and data-accent="#hex" on the script tag tweak the launcher.
  • A link with ?sb=<conversationId> (what notification emails use) opens that conversation on load.

Only origins listed on the project can host the widget; anything else sees an error inside the frame and never gets a token.

One tag for everything#

Instead of a script per feature, helpbites.js asks the project what it has and adds the chat, the feedback board, the changelog and onboarding that are set up:

<script src="https://YOUR-SUPPORT-HOST/helpbites.js" data-project="pk_…" async></script>

It adds the chat by default; name more with data-features="chat,feedback" or give one its own setting (data-changelog-target="#changelog", data-flow="welcome"). data-position, data-accent, data-mode, data-token and data-locale pass through to the scripts, HelpBites.push([...]) keeps working, and a script already on the page is never added twice. The project can also turn on a Home tab in the chat (what's new, top feedback, onboarding) and a public page at /hub/<pk>. All of it is in Public hub and the one-tag loader.

Your own UI#

Everything the widget does is on the API: GET /_docs/scalar. Send Authorization: Bearer <identity token> and use:

  • GET /v1/conversations, POST /v1/conversations
  • GET /v1/conversations/:id/messages?before=, POST /v1/conversations/:id/messages
  • POST /v1/conversations/:id/read, DELETE /v1/messages/:id, GET /v1/unread
  • POST /v1/attachments (multipart file)
  • GET /v1/events — server-sent events; use fetch + a stream reader so the token stays in a header

Retries are safe: pass a clientId with each message and a repeated POST returns the message that already landed.

Email-only users (no backend)#

An app with no server of its own (a native app, a static site) cannot sign identity tokens, and must never hold the project secret. Turn on Allow email-only users in Project > Settings and the app can use chat and feedback with just an email address:

POST /v1/anonymous/session   { "pk": "pk_…", "email": "[email protected]", "name": "Ann" }
  • A new address gets 200 { status: "token", token: "eu_…", externalId, verified: false }. Send Authorization: Bearer eu_… on every end-user route (conversations, messages, attachments, /v1/events, feedback, changelog comments). Keep the token (the Keychain on iOS); the person does not enter their email again.
  • An address already known (someone used it before, or it is a contact) never gets a token from this call. It gets 202 { status: "code_sent" } and a 6-digit code by email, valid 10 minutes. Trade it with POST /v1/anonymous/verify/confirm { pk, email, code } for a verified token; POST /v1/anonymous/verify { pk, email } sends a new code. Five wrong guesses lock the code. Confirming ends every earlier session of that address.
  • After 5 writes (messages, posts, comments, votes, ...) an unverified person gets 403 with data.code = "email_verification_required" on the next write. Show a code field and call the two verify routes above. Verified people are never asked again.
  • Until verified, the address is a claim only: the session has no email, so it cannot trigger reply mail, flag allow-lists or changelog subscriptions, and unverified people cannot upload images.
  • DELETE /v1/anonymous/me with the token erases everything the person left.
  • Turning the setting off ends all email-only access at once.

An email-only user is an ordinary contact whose externalId starts with anon_, so conversations, feedback and the CRM work as for any other user.

Guest access (no account, no email)#

Email-only users still hand over an address. For a visitor who gives nothing, an admin can open guest access per surface: Project → Settings → Chat widget → Guest access, or PATCH /v1/admin/projects/:id { "guestSurfaces": ["chat", "feedback", "comments"] } (any of the three; [] turns guests off). It is off by default.

Surface A guest can
chat Start conversations and message you (replies show in the chat; there is no address to email)
feedback Post, vote, react and comment on the feedback board
comments Comment and react on blog posts and changelog entries

Lead forms and sign-up forms are public already and need no guest.

POST /v1/anonymous/guest   { "pk": "pk_…" }
→ 200 { "token": "eu_…", "externalId": "guest_…", "name": "Guest 7f3a", "surfaces": ["chat", "feedback"] }

Send Authorization: Bearer eu_… on the end-user routes of the opened surfaces, and keep the token: every call makes a new guest, so a returning visitor should reuse theirs. A guest is an ordinary contact whose externalId starts with guest_ and whose name is Guest 7f3a, so conversations, feedback and the CRM work as for anyone. DELETE /v1/anonymous/me erases a guest's data.

Built-in protections, all on by default and not switchable:

  • Only where the admin opened it. A write on any other surface is 403 with data.code = "guest_not_allowed". Closing every surface ends all guest sessions at once (401).
  • Rate limited. Making guests: 10 an hour per IP and 500 an hour per project (429). Writing: 20 per ten minutes and 100 per day per guest, and 600 an hour across the project, on top of the limits every user has (messages, posts, comments, votes).
  • No files. Guests cannot upload images (403, data.code = "guest_upload_not_allowed").
  • No identity to borrow. A guest session carries no email, so it cannot trigger reply mail, feature-flag allow-lists, changelog subscriptions or the host's role: "admin".
  • Sessions expire. 30 days without use ends a guest session; the token is random, opaque and stored only as a hash. Expired sessions, and guests who never wrote anything, are deleted every six hours.
  • Your own sign-in wins. The built-in chat only makes a guest when the page gave it no getToken; the board when it has no onSignIn; blog comments when the page has neither.

Web#

No code beyond the embed. With guest access open for chat:

<script src="https://YOUR-SUPPORT-HOST/widget.js" data-project="pk_…" defer></script>
<script>
  window.HelpBites = window.HelpBites || []
  HelpBites.push(['init', {}])
</script>

The feedback board (feedback.js) and blog comments (blog.js) do the same: with no onSignIn (and, for blog comments, no getToken), a visitor becomes a guest on their first action (reading never makes one), and the browser keeps that guest in its own storage. If storage is blocked (a third-party frame in some browsers), the guest lasts until the page closes. Colours, position and labels are the embed's usual options and the project's Brand kit; nothing guest-specific needs styling.

For your own UI, call the route above and use the token like any other (the API reference is /_docs/scalar, tag End user).

Swift#

// GuestChat.swift. MIT. Gets a guest session once, keeps it in the Keychain, and lists the guest's conversations.
import SwiftUI
import Security

struct GuestChat: View {
  let host: URL                  // https://support.example.com
  let projectKey: String         // pk_…
  var accent: Color = .accentColor
  var cornerRadius: CGFloat = 10

  struct Conversation: Decodable, Identifiable { let id: String; let subject: String }

  @State private var conversations: [Conversation] = []
  @State private var draft = ""
  @State private var message = ""

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      ForEach(conversations) { Text($0.subject.isEmpty ? "Conversation" : $0.subject) }
      TextField("How can we help?", text: $draft, axis: .vertical)
        .padding(10).background(.quaternary, in: RoundedRectangle(cornerRadius: cornerRadius))
      Button("Send") { Task { await send() } }
        .padding(.horizontal, 14).padding(.vertical, 8)
        .background(accent, in: RoundedRectangle(cornerRadius: cornerRadius)).foregroundStyle(.white)
        .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty)
      if !message.isEmpty { Text(message).font(.footnote).foregroundStyle(.secondary) }
    }
    .task { await load() }
  }

  private func request(_ path: String, body: [String: Any]? = nil) async throws -> Data {
    var request = URLRequest(url: host.appending(path: "v1/" + path))
    request.setValue("Bearer " + (try await token()), forHTTPHeaderField: "Authorization")
    if let body {
      request.httpMethod = "POST"
      request.setValue("application/json", forHTTPHeaderField: "Content-Type")
      request.httpBody = try JSONSerialization.data(withJSONObject: body)
    }
    let (data, response) = try await URLSession.shared.data(for: request)
    if (response as? HTTPURLResponse)?.statusCode == 401 { Keychain.delete(); throw URLError(.userAuthenticationRequired) }
    return data
  }

  private func load() async {
    struct List: Decodable { let conversations: [Conversation] }
    if let data = try? await request("conversations"), let list = try? JSONDecoder().decode(List.self, from: data) { conversations = list.conversations }
  }

  private func send() async {
    do {
      _ = try await request("conversations", body: ["body": draft, "clientId": UUID().uuidString])
      draft = ""
      await load()
    } catch { message = "Could not send. Try again in a moment." }
  }

  /// The saved guest token, or a new guest (one call, rate limited by the server).
  private func token() async throws -> String {
    if let saved = Keychain.read() { return saved }
    var request = URLRequest(url: host.appending(path: "v1/anonymous/guest"))
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONSerialization.data(withJSONObject: ["pk": projectKey])
    let (data, _) = try await URLSession.shared.data(for: request)
    struct Guest: Decodable { let token: String }
    let guest = try JSONDecoder().decode(Guest.self, from: data)
    Keychain.save(guest.token)
    return guest.token
  }
}

private enum Keychain {
  static let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "helpbites.guest"]
  static func read() -> String? {
    var found: AnyObject?
    guard SecItemCopyMatching(query.merging([kSecReturnData as String: true]) { $1 } as CFDictionary, &found) == errSecSuccess, let data = found as? Data else { return nil }
    return String(data: data, encoding: .utf8)
  }
  static func save(_ token: String) { delete(); SecItemAdd(query.merging([kSecValueData as String: Data(token.utf8)]) { $1 } as CFDictionary, nil) }
  static func delete() { SecItemDelete(query as CFDictionary) }
}

Colours and corner radius are parameters. Feedback and comments work the same way with /v1/feedback and /v1/blog/…/comments.

Feedback board#

The same token also signs users into the project's public feedback board. Turn it on under Project → Feedback, then either drop in the board:

<div id="feedback"></div>
<script src="https://YOUR-SUPPORT-HOST/feedback.js" data-project="pk_…" data-target="#feedback" defer></script>
<script>
  window.HelpBitesFeedback = window.HelpBitesFeedback || []
  HelpBitesFeedback.push(['init', {
    getToken: () => fetch('/api/support/token').then(r => r.ok ? r.json() : null).then(r => r && r.token),
    onSignIn: () => openLoginModal()
  }])
</script>

or build your own on /v1/feedback (public reads with ?pk=). Add role: "admin" to the token claims of your own staff to let them moderate. Everything — options, the API, importing an existing board, account deletion — is in FEEDBACK.md.

Feature flags#

Switch features of your app from Project → Flags: each flag is off, on, or admins (your admins plus a per-flag allow list), with an optional fallback for when HelpBites can't be reached. Read them with GET /v1/flags — from your server with the secret, or from a browser with ?pk= or the identity token. Details in FLAGS.md.

Error tracking#

If the operator enables Project → Install & API → Error tracking, copy its DSN into any Sentry-compatible SDK and call Sentry.setUser({ id: user.id }), where user.id is the same stable value used as this document's token sub. That associates a customer's recent errors with their HelpBites conversations. See ERRORS.md.

Typed tickets and host actions#

support is available on every project. Create additional types under /v1/admin/projects/:id/types to define their statuses, intake fields, and operator actions. New conversations accept type, fields, and subjectRef ({ type, id, label, url?, thumbnailUrl? }); URL values are restricted to http(s).

For a host backend to file a ticket for one of its users, call POST /v1/tickets with the project secret—not an identity token:

await fetch('https://support.example.com/v1/tickets', {
  method: 'POST',
  headers: { authorization: `Bearer ${process.env.HELP_BITES_SECRET}`, 'content-type': 'application/json' },
  body: JSON.stringify({
    externalId: user.id, name: user.name, email: user.email,
    type: 'report', subject: 'Report an icon', body: details,
    fields: { reason: 'copyright', sourceUrl },
    subjectRef: { type: 'icon', id: icon.id, label: icon.name, url: icon.url },
    idempotencyKey: `report:${user.id}:${icon.id}`
  })
})

Set actionWebhookUrl and actionWebhookSecret on the project. Operator actions POST JSON to that URL with X-HelpBites-Signature: sha256=<HMAC-SHA256(body)>; verify it before applying the host-side effect. The payload includes actionKey, the ticket snapshot, and an idempotency key.

Work with an AI assistant#

The operator side has an MCP server too: connect Claude, ChatGPT or Cursor to https://YOUR-SUPPORT-HOST/mcp and it can read the inbox, reply, triage feedback and write changelog entries as you.