Repository navigation
API Reference
The widget ships a small, deliberately narrow HTTP API. Three chat endpoints carry the whole conversation; everything else is either a page (the widget itself, the landing page, a per-client demo) or the admin panel, which is server-rendered HTML.
All chat endpoints live under the prefix /api/chat and are mounted from
app/routers/chat.py.
The chat API authenticates with a tenant API key, not with a user account.
POST /api/chat/session
Content-Type: application/json
{
"api_key": "your-tenant-api-key",
"token": null
}The key identifies the clinic. It selects:
- the flow the visitor walks through,
- the welcome message,
- the announcement banner (if one is active),
- the counselor roster used for matching,
- the allowed domains (optional).
Every key belongs to exactly one tenant. A key that does not exist returns 401.
If the tenant has allowed_domains configured, the server checks the Origin header
(falling back to Referer) against that list. The match is on the exact host or one of
its subdomains — example.com matches www.example.com and app.example.com, but
not notexample.com and not example.com.evil.net.
- No
Originheader (for example a server-to-server call) → the check is skipped. - A host outside the list →
403with"دامنه مجاز نیست.".
Creates a new session, or restores an existing one.
| Field | Type | Required | Notes |
|---|---|---|---|
api_key |
string | yes | Max 128 characters. Must resolve to an active tenant. |
token |
string | null | no | An existing session token. If it is valid and belongs to the same tenant, the session is restored. |
New session response (200):
{
"token": "3f8a…",
"state": 0,
"messages": [
{ "role": "bot", "text": "سلام! خوش آمدید.", "html": "<p>سلام! خوش آمدید.</p>" }
],
"quick_replies": [],
"announcement": null
}Restored session response — same shape, but messages contains the single prompt
that matches the current state, so the client can re-render mid-conversation after a
page reload:
{
"token": "3f8a…",
"state": 4,
"messages": [ { "role": "bot", "text": "شماره تماس خود را وارد کنید…", "html": "…" } ],
"quick_replies": [],
"input_type": "phone",
"announcement": "ساعت کاری مرکز تغییر کرده است."
}The announcement field is the active broadcast for the tenant, or null.
Sends one visitor message and advances the flow.
| Field | Type | Required | Notes |
|---|---|---|---|
token |
string | yes | Max 64 characters. The session token from /session. |
text |
string | yes | Max MAX_MESSAGE_LENGTH + 500 characters. |
Response (200):
{
"token": "3f8a…",
"state": 5,
"reply": {
"text": "لطفاً نام و نام خانوادگی خود را بنویسید.",
"html": "<p>لطفاً نام و نام خانوادگی خود را بنویسید.</p>",
"quick_replies": ["بله", "خیر"],
"input_type": "text"
},
"done": false
}| Field | Meaning |
|---|---|
state |
The new state. 0…N is a flow step, -1 is the GHQ screen, -2 means the session is finished, -3 is the GHQ opt-in question. |
reply.text |
Plain text, Markdown source. |
reply.html |
The same message rendered to HTML by md_to_html(). |
reply.quick_replies |
Chips to render under the message. Empty for free-text prompts. |
reply.input_type |
text, phone, or number — tells the client which keyboard to show and which client-side validation to apply. |
done |
true once the flow has reached an ending. |
Errors:
| Status | When | Body |
|---|---|---|
401 |
Unknown or invalid API key. | {"detail": "کلید API نامعتبر است."} |
403 |
The tenant's allowed_domains list rejects the request's host. |
{"detail": "دامنه مجاز نیست."} |
403 |
The tenant has been disabled. | {"detail": "سرویس این مرکز موقتاً غیرفعال است."} |
404 |
The token does not exist, or the session expired. | {"detail": "session not found or expired"} |
429 |
Rate limit exceeded. | {"detail": "درخواست بیش از حد مجاز. لطفاً کمی صبر کنید."} |
{ "status": "ok" }No authentication. Useful as a liveness probe behind a reverse proxy.
Both write endpoints are rate limited, 30 requests per 60 seconds, by an in-process sliding window. The key is derived from the client IP:
| Endpoint | Rate-limit key |
|---|---|
POST /api/chat/session |
{ip}:session |
POST /api/chat/message |
{ip}:{token[:12]} |
Consequences worth knowing:
- The limit is per process. Behind multiple workers or replicas, the effective limit
is multiplied by the number of workers, because the counter is a module-level
dict[str, deque]and not shared. - The message key is the first 12 characters of the token, so two different long tokens that share a prefix would share a bucket.
- Stale keys are garbage-collected once per window, and the map is capped at 10,000 keys — the oldest key is evicted when the cap is hit.
A 429 is returned before any work is done, so the message is not consumed: the
visitor can simply resend it.
These return HTML, not JSON, and are not part of the chat API.
| Route | What it does |
|---|---|
GET / |
The landing page (static/landing.html). |
GET /widget?key={api_key} |
The embeddable widget shell (static/widget.html). __TENANT_NAME__ is replaced with the tenant's name, or "سامانه پذیرش" when the key is missing or unknown. |
GET /demo/{api_key} |
A per-client demo page (static/demo.html). The key is baked into the page as __API_KEY__, and the demo is recorded like a real conversation under that tenant. An unknown key returns a 404 page in Persian. |
GET /static/* |
The widget's static assets, served with Cache-Control: no-cache, max-age=300. |
The widget is served from /widget?key=… and framed by the host page:
<iframe
src="https://your-host/widget?key=YOUR_TENANT_API_KEY"
style="position:fixed; bottom:0; left:0; width:400px; height:640px; border:0; z-index:9999;"
></iframe>Because the widget lives in an iframe, the server sends X-Frame-Options: SAMEORIGIN
for every route except /widget. See Architecture for the full
header and CORS story.
static/widget.js is the loader that a client site includes. It:
- reads the API key from the
<script>tag'sdata-keyattribute (or the?key=query string), - injects a floating bubble labelled
"ستیار انتخاب مشاور", - lazily loads
static/widget-app.jswhen the visitor opens the bubble, - and the app script drives the session loop:
POST /session→ render the first prompt →POST /messageper visitor input → renderreply.htmlandreply.quick_replies.
The same ?key= also selects the per-client demo, so a clinic can share one link with
a prospect and have that prospect's conversation recorded under their own key.
# 1. Start a session
curl -X POST https://your-host/api/chat/session \
-H 'Content-Type: application/json' \
-d '{"api_key":"YOUR_TENANT_API_KEY","token":null}'
# 2. Send an answer (use the token from step 1)
curl -X POST https://your-host/api/chat/message \
-H 'Content-Type: application/json' \
-d '{"token":"3f8a…","text":"مینا رضایی"}'
# 3. Liveness
curl https://your-host/api/chat/health- Architecture — how the layers fit together, and where each of these endpoints lives.
- Admin Panel — the server-rendered side: inbox, leads, flow editor.
- Multi-Tenancy — how a key maps to a clinic, and what is isolated.
- Embedding — the loader contract, the iframe, and the client-side loop.
- Home — the wiki index.