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.
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.
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
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()
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
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})
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})
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
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"})
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;
}
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>";
}
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.
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);
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.
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();
}
});
initSearchAgent,
prefillSearchQuery, toggleSearchAgent) —
it renders DevRev's own semantic search UI, so you don't build a results list yourself.
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})
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.
# 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
conversation.id, the timeline_entry_created.entry path,
and the webhook signature header name against a real call in staging.