DevRev PLuG · Production Guide

Custom Chat on DevRev Conversations

A Flask backend + browser chat input, built the production way: per-customer session tokens, signed webhooks, Redis fan-out, and Server-Sent Events for live replies.

The chat input UI

This is the customer-facing widget — just the chat surface and input, styled like the mock. It is fully interactive on this page (echoes locally); wire the handlers to the backend below.

HighAQ Support
          Hi! How can we help you today?

          Build it step by step

          Step 0 — Setup

          Server-only secrets. The AAT never touches the browser.

          pip install flask requests redis pyjwt gunicorn gevent
          
          export DEVREV_AAT='<AAT from Settings > Support > Plug Tokens>'
          export DEVREV_WEBHOOK_SECRET='<8-32 byte secret>'
          export APP_JWT_SECRET='<random secret for browser session JWTs>'
          export REDIS_URL='redis://localhost:6379/0'
          export PUBLIC_URL='https://chat.yourdomain.com'   # stable TLS URL, not ngrok

          Step 1 — Auth helper (Bearer for every call)

          All DevRev APIs use Authorization: Bearer <token> — for both the AAT and session tokens.

          DEVREV_API = "https://api.devrev.ai"
          AAT = os.environ["DEVREV_AAT"]
          HTTP = requests.Session()
          
          def _devrev_post(path, payload, token):
              resp = HTTP.post(
                  f"{DEVREV_API}/{path}",
                  headers={"Authorization": f"Bearer {token}",
                           "Content-Type": "application/json"},
                  json=payload, timeout=15,
              )
              if resp.status_code not in (200, 201):
                  raise RuntimeError(f"DevRev {path} failed ({resp.status_code})")
              return resp.json()

          Step 2 — Mint a per-customer session token

          This is the key production fix: messages are authored AS the customer (rev_user), not as your dev org.

          def mint_session_token(user_ref, account_ref=None, email=None, display_name=None):
              rev_info = {"user_ref": user_ref}
              if account_ref:   rev_info["account_ref"]   = account_ref
              if email:         rev_info["email"]         = email
              if display_name:  rev_info["display_name"]  = display_name
              result = _devrev_post("auth-tokens.create", {"rev_info": rev_info}, token=AAT)
              return result["access_token"]        # short-lived rev session token

          Step 3 — Open chat → create conversation

          type is required; created_by is NOT a request field (the token identity sets the author).

          @app.route("/api/chat/session", methods=["POST"])
          def open_chat():
              d = request.get_json(silent=True) or {}
              rev_token = mint_session_token(d["user_ref"], d.get("account_ref"),
                                             d.get("email"), d.get("display_name"))
              result = _devrev_post("conversations.create",
                                    {"type": "support", "title": "Support Chat"},
                                    token=rev_token)
              conversation_id = result["conversation"]["id"]
              browser_jwt = _issue_browser_jwt(conversation_id, rev_token)  # scoped, short-lived
              return jsonify({"conversation_id": conversation_id, "token": browser_jwt})

          Step 4 — Send a message

          Timeline comment, authored as the customer via their token.

          @app.route("/api/chat/send", methods=["POST"])
          def send_message():
              claims = _require_browser_jwt()          # validates the browser JWT
              if not claims: return jsonify({"error":"unauthorized"}), 401
              body = (request.get_json(silent=True) or {}).get("body","").strip()
              if not body: return jsonify({"error":"body required"}), 400
              _devrev_post("timeline-entries.create",
                  {"object": claims["conv"], "type": "timeline_comment",
                   "body": body, "body_type": "text"},
                  token=claims["rev"])
              return jsonify({"ok": True})

          Step 5 — Webhook: verify signature + handshake

          Verify the HMAC over the raw body and answer the challenge before trusting anything.

          @app.route("/devrev-webhook", methods=["POST"])
          def devrev_webhook():
              raw = request.get_data()
              sig = request.headers.get("x-devrev-signature","")
              expected = hmac.new(WEBHOOK_SECRET, raw, hashlib.sha256).hexdigest()
              if not hmac.compare_digest(sig, expected): return "", 401
          
              data = json.loads(raw or b"{}")
              if data.get("type") == "verify":                     # handshake
                  return jsonify({"challenge": data["verify"]["challenge"]})
          
              if data.get("type") == "timeline_entry_created":
                  entry = data["timeline_entry_created"]["entry"]
                  if entry.get("type") == "timeline_comment" and \
                     (entry.get("created_by") or {}).get("type") == "dev_user":
                      rdb.publish(f"conv:{entry['object']}",       # fan out agent replies
                          json.dumps({"body": entry.get("body",""), "author":"dev_user"}))
              return "", 200

          Step 6 — Push to browser over SSE (Redis-backed)

          Redis pub/sub so it works across multiple gunicorn workers.

          @app.route("/api/chat/stream")
          def stream():
              claims = jwt.decode(request.args["token"], APP_JWT_SECRET, algorithms=["HS256"])
              channel = f"conv:{claims['conv']}"
              def gen():
                  ps = rdb.pubsub(); ps.subscribe(channel)
                  yield "retry: 3000\n\n"
                  for msg in ps.listen():
                      if msg["type"] == "message":
                          yield f"data: {msg['data']}\n\n"
              return Response(gen(), mimetype="text/event-stream",
                              headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})

          Step 7 — The browser chat input

          Exactly the handlers behind the live preview above.

          const API = "https://chat.yourdomain.com";
          let session = null;
          
          async function openChat() {
            const r = await fetch(API + "/api/chat/session", {
              method:"POST", headers:{"Content-Type":"application/json"},
              body: JSON.stringify({ user_ref:"customer@example.com",
                                     account_ref:"highradius.com" })
            });
            session = await r.json();
          
            const es = new EventSource(API + "/api/chat/stream?token=" +
                                       encodeURIComponent(session.token));
            es.onmessage = e => { const m = JSON.parse(e.data); if (m.body) add(m.body, "them"); };
          }
          
          async function sendMsg() {
            const box = document.getElementById("chatInput");
            const body = box.value.trim(); if (!body) return;
            add(body, "me"); box.value = "";
            await fetch(API + "/api/chat/send", {
              method:"POST",
              headers:{ "Content-Type":"application/json",
                        "Authorization":"Bearer " + session.token },
              body: JSON.stringify({ body })
            });
          }
          
          function add(text, who) {
            const d = document.createElement("div");
            d.className = "bubble " + who; d.textContent = text;
            const body = document.getElementById("chatBody");
            body.appendChild(d); body.scrollTop = body.scrollHeight;
          }

          Step 8 — Home: recent conversations, tickets & help docs

          One backend endpoint fans out to three DevRev APIs. Because every call uses the customer's session token, conversations and tickets are auto-scoped to that customer.

          @app.route("/api/home")
          def home():
              claims = _require_browser_jwt()
              if not claims: return jsonify({"error":"unauthorized"}), 401
              tok = claims["rev"]                       # customer session token -> auto-scoped
          
              # Recent conversations (this customer's).
              convs = _devrev_post("conversations.list", {"limit": 10}, token=tok)
              conversations = [
                  {"id": c["id"], "title": c.get("title") or "Conversation",
                   "updated": c.get("modified_date")}
                  for c in convs.get("conversations", [])
              ]
          
              # This customer's tickets (works.list, type=ticket).
              works = _devrev_post("works.list", {"type": ["ticket"], "limit": 10}, token=tok)
              tickets = [
                  {"id": w["id"], "display_id": w.get("display_id"),
                   "title": w.get("title"), "stage": (w.get("stage") or {}).get("name")}
                  for w in works.get("works", [])
              ]
          
              return jsonify({"conversations": conversations, "tickets": tickets})
          
          
          @app.route("/api/help/search")
          def help_search():
              claims = _require_browser_jwt()
              if not claims: return jsonify({"error":"unauthorized"}), 401
              q = request.args.get("q", "").strip()
          
              if q:                                     # search knowledge base
                  res = _devrev_post("search.core",
                                     {"query": q, "namespaces": ["article"], "limit": 8},
                                     token=claims["rev"])
                  items = [r.get("article", r) for r in res.get("results", [])]
              else:                                     # browse published articles
                  res = _devrev_post("articles.list", {"limit": 8}, token=claims["rev"])
                  items = res.get("articles", [])
          
              return jsonify({"articles": [
                  {"id": a["id"], "title": a.get("title"),
                   "url": a.get("url") or (a.get("resource") or {}).get("url")}
                  for a in items
              ]})

          Frontend — render a Home tab from the two endpoints:

          async function loadHome() {
            const home = await fetch(API + "/api/home", {
              headers:{ "Authorization":"Bearer " + session.token }
            }).then(r => r.json());
          
            renderList("recentConvos", home.conversations,
              c => `<a href="#" data-conv="${c.id}">${c.title}</a>`);
            renderList("myTickets", home.tickets,
              t => `${t.display_id} · ${t.title} <em>${t.stage || ""}</em>`);
          
            const docs = await fetch(API + "/api/help/search", {
              headers:{ "Authorization":"Bearer " + session.token }
            }).then(r => r.json());
            renderList("helpDocs", docs.articles,
              a => `<a href="${a.url}" target="_blank">${a.title}</a>`);
          }
          
          // Live help search as the user types.
          document.getElementById("helpSearch").addEventListener("input", async e => {
            const docs = await fetch(API + "/api/help/search?q=" +
              encodeURIComponent(e.target.value),
              { headers:{ "Authorization":"Bearer " + session.token } }).then(r => r.json());
            renderList("helpDocs", docs.articles,
              a => `<a href="${a.url}" target="_blank">${a.title}</a>`);
          });
          
          function renderList(id, items, tpl) {
            document.getElementById(id).innerHTML =
              items.length ? items.map(tpl).map(h => `<li>${h}</li>`).join("")
                           : "<li class='empty'>Nothing yet</li>";
          }
          Endpoint notes: conversations.list and works.list are POST with a JSON body; the session token scopes results to the customer automatically (no owner filter needed). Help search uses the search.core API with the article namespace — confirm the exact response key (results[].article) against your org.

          Step 9 — Handle rich replies, DON references & read state

          Agent replies are NOT plain text. Forward the full body + resolve any ticket/article references server-side (scoped to the customer), so the browser renders clickable chips, not raw DONs.

          a) Webhook: forward body_type + resolve references before fan-out.

          import re
          DON_RE = re.compile(r"don:core:[^\s\"']+:(ticket|article|issue)/[^\s\"']+")
          
          def _resolve_refs(text, token):
              """Turn DONs / display-ids in a body into {don, kind, label, url} chips.
                 Resolve with the CUSTOMER token so we never leak objects they can't see."""
              chips = []
              for don in {m.group(0) for m in DON_RE.finditer(text)}:
                  kind = don.split("/")[0].split(":")[-1]
                  try:
                      if kind in ("ticket", "issue"):
                          w = _devrev_post("works.get", {"id": don}, token=token)["work"]
                          chips.append({"don": don, "kind": kind,
                                        "label": f"{w.get('display_id')} · {w.get('title')}",
                                        "url": None})            # customers can't open the dev app
                      elif kind == "article":
                          a = _devrev_post("articles.get", {"id": don}, token=token)["article"]
                          chips.append({"don": don, "kind": "article",
                                        "label": a.get("title"),
                                        "url": a.get("url")})     # public help-center URL
                  except Exception:
                      pass   # not visible to this customer -> silently drop the chip
              return chips
          
          # inside devrev_webhook(), when a timeline_comment arrives:
          entry = data["timeline_entry_created"]["entry"]
          payload = {
              "author":    (entry.get("created_by") or {}).get("type"),
              "body_type": entry.get("body_type", "text"),
              "body":      entry.get("body", ""),
              "artifacts": [a.get("id") for a in entry.get("artifacts", [])],
              "refs":      _resolve_refs(entry.get("body",""), token=SERVICE_TOKEN),
          }
          # snap_kit / snap_widget replies: forward the structured body, don't flatten it
          if entry.get("body_type") == "snap_kit":
              payload["snap_kit_body"] = entry.get("snap_kit_body")
          rdb.publish(f"conv:{entry['object']}", json.dumps(payload))

          b) Browser: render chips + rich body safely (escape text, then linkify refs).

          function renderReply(m) {
            const wrap = document.createElement("div");
            wrap.className = "bubble them";
          
            if (m.body_type === "snap_kit") {
              wrap.appendChild(renderSnapKit(m.snap_kit_body));   // buttons/forms
            } else {
              // escape first (never innerHTML raw text), then swap DONs for chips
              let html = escapeHtml(m.body || "");
              (m.refs || []).forEach(ref => {
                const chip = ref.url
                  ? `<a class="chip" href="${ref.url}" target="_blank">${ref.label}</a>`
                  : `<span class="chip">${ref.label}</span>`;   // no customer-facing URL
                html = html.replaceAll(escapeHtml(ref.don), chip);
              });
              wrap.innerHTML = html;
            }
            if (m.artifacts?.length) wrap.appendChild(renderAttachments(m.artifacts));
            chatBody.appendChild(wrap); chatBody.scrollTop = chatBody.scrollHeight;
          }
          
          function escapeHtml(s){ const d=document.createElement("div"); d.textContent=s; return d.innerHTML; }

          c) Mark the conversation read when the panel is focused (drives the unread badge).

          function markRead() {
            navigator.sendBeacon(API + "/api/chat/read?token=" +
              encodeURIComponent(session.token));   // server records last-seen timestamp
          }
          window.addEventListener("focus", markRead);
          document.getElementById("chatInput").addEventListener("focus", markRead);
          Two rules that keep this safe: (1) always escapeHtml the body BEFORE substituting chips — never inject a raw reply as HTML. (2) Resolve references with a token scoped to the customer (or verify visibility) and drop any that fail — otherwise a ticket title from another account can leak. Also: DevRev app URLs are internal; give customers a help-center or portal URL, or render the chip as non-clickable text.

          Step 10 — Open rows, folder-tree articles & the search SDK

          Rows are clickable: a conversation opens its thread, a ticket opens its detail. Articles browse as a nested folder tree (DevRev Collections), and search hands off to the DevRev PLuG search agent.

          a) Backend — collections build the tree; articles.list filters by parent.

          @app.route("/api/help/tree")
          def help_tree():
              claims = _require_browser_jwt()
              if not claims: return jsonify({"error":"unauthorized"}), 401
              parent = request.args.get("parent")        # None = top level
              tok = claims["rev"]
          
              # Sub-collections (folders) under this parent.
              cols = _devrev_post("dev-orgs.directories.list",   # collections/directories
                                  {"parent": [parent] if parent else None}, token=tok)
              folders = [{"id": c["id"], "title": c.get("name"), "type": "folder"}
                         for c in cols.get("directories", [])]
          
              # Articles directly under this parent collection.
              arts = _devrev_post("articles.list",
                                  {"parent": [parent] if parent else None, "limit": 50}, token=tok)
              articles = [{"id": a["id"], "title": a.get("title"),
                           "url": a.get("url"), "type": "article"}
                          for a in arts.get("articles", [])]
          
              return jsonify({"folders": folders, "articles": articles})

          b) Frontend — clickable rows open a detail view; the tree navigates by parent.

          // One delegated handler covers every row in the Home surface.
          document.querySelector(".home").addEventListener("click", e => {
            const row = e.target.closest("li.row[data-open]");
            if (!row) return;
            const id = row.dataset.id;
            if (row.dataset.open === "conv")    openConversation(id);   // -> chat/detail
            if (row.dataset.open === "ticket")  openTicket(id);         // -> ticket detail
            if (row.dataset.open === "folder")  loadTree(id);           // descend a level
            if (row.dataset.open === "article") window.open(articleUrl(id), "_blank");
          });
          
          async function loadTree(parent){
            const t = await fetch(API + "/api/help/tree?parent=" + (parent||""),
              { headers:{ Authorization:"Bearer " + session.token } }).then(r => r.json());
            helpDocs.innerHTML =
              t.folders.map(f  => row("folder",  f.id, "▸ " + f.title)).join("") +
              t.articles.map(a => row("article", a.id, a.title + " ↗")).join("");
          }
          const row = (kind,id,label) =>
            `<li class="row" data-open="${kind}" data-id="${id}">${label}</li>`;

          c) Search — hand the query to the DevRev PLuG search agent (loaded via the Web SDK).

          // Requires the PLuG SDK on the page + initSearchAgent() once.
          function runHelpSearch(q){
            window.plugSDK.prefillSearchQuery(q);      // seed the query
            window.plugSDK.toggleSearchAgent(true);    // open DevRev's search overlay
          }
          helpSearch.addEventListener("input", e => runHelpSearch(e.target.value.trim()));
          
          // Standard Cmd/Ctrl+K hotkey to summon search.
          document.addEventListener("keydown", e => {
            if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"){
              e.preventDefault();
              window.plugSDK.toggleSearchAgent();
            }
          });
          Hierarchy note: article folders are DevRev Collections (nestable, 1–2 levels recommended) — distinct from Parts. Both a collection and its articles must be published to the customer audience, or they won't return for a customer session token. The search agent is part of the PLuG Web SDK (initSearchAgent, prefillSearchQuery, toggleSearchAgent) — it renders DevRev's own semantic search UI, so you don't build a results list yourself.

          Step 11 — Attachments as artifacts on the conversation

          DevRev uploads are a 3-step flow: artifacts.prepare returns a signed URL + form fields → POST the file to that URL → attach the artifact id to the timeline comment. All done with the customer's session token so the file is owned by them.

          a) Backend — prepare an upload URL (the browser never gets the AAT).

          @app.route("/api/chat/attach/prepare", methods=["POST"])
          def attach_prepare():
              claims = _require_browser_jwt()
              if not claims: return jsonify({"error":"unauthorized"}), 401
              name = (request.get_json(silent=True) or {}).get("name", "upload.bin")
              prep = _devrev_post("artifacts.prepare", {"file_name": name}, token=claims["rev"])
              # prep => { "id": "", "url": "", "form_data": [ {key,value}, ... ] }
              return jsonify({"id": prep["id"], "url": prep["url"], "form_data": prep["form_data"]})

          b) Browser — POST the file to the signed URL as multipart, in the exact field order.

          async function uploadArtifact(file){
            // 1) ask our backend to prepare (returns signed URL + required form fields)
            const prep = await fetch(API + "/api/chat/attach/prepare", {
              method:"POST",
              headers:{ "Content-Type":"application/json", Authorization:"Bearer " + session.token },
              body: JSON.stringify({ name: file.name })
            }).then(r => r.json());
          
            // 2) POST the file straight to storage. form_data fields MUST come before the file.
            const fd = new FormData();
            prep.form_data.forEach(f => fd.append(f.key, f.value));
            fd.append("file", file);                       // 'file' part goes last
            const res = await fetch(prep.url, { method:"POST", body: fd });
            if (!res.ok) throw new Error("upload failed " + res.status);
          
            return prep.id;                                // artifact DON — keep for send
          }

          c) Attach on send — the ready artifact ids ride on the timeline comment.

          @app.route("/api/chat/send", methods=["POST"])
          def send_message():
              claims = _require_browser_jwt()
              if not claims: return jsonify({"error":"unauthorized"}), 401
              d = request.get_json(silent=True) or {}
              body      = (d.get("body") or "").strip()
              artifacts = d.get("artifacts", [])           # list of artifact DONs
              if not body and not artifacts:
                  return jsonify({"error":"empty message"}), 400
              _devrev_post("timeline-entries.create", {
                  "object": claims["conv"], "type": "timeline_comment",
                  "body": body, "body_type": "text",
                  "artifacts": artifacts,                  # <- attach the uploaded files
              }, token=claims["rev"])
              return jsonify({"ok": True})
          Gotchas: append form_data fields to the FormData before the file part — object storage rejects the upload otherwise. Enforce size/type limits server-side in prepare (customers can upload anything). Agent replies with attachments come back through the webhook's artifacts array (Step 9) — resolve each id to a download URL with artifacts.locate before showing it, and only for artifacts the customer is allowed to see.

          Run

          # Register the webhook ONCE, out of band (not on every boot):
          #   _devrev_post("webhooks.create", {
          #       "url": f"{PUBLIC_URL}/devrev-webhook",
          #       "event_types": ["timeline_entry_created"],
          #       "secret": os.environ["DEVREV_WEBHOOK_SECRET"]}, token=AAT)
          
          gunicorn -k gevent -w 4 -b 0.0.0.0:8000 devrev_chat_prod:app
          Verify before prod: DevRev nests vary by API version — confirm conversation.id, the timeline_entry_created.entry path, and the webhook signature header name against a real call in staging.