Chat Assistant API — v1
Developer Reference
A server-to-server intelligence platform for white-label sportsbook operators and sports media partners. Conversational football Q&A, behavioural signal ingestion, and betting insights — all under a single authenticated API key.
What the API provides
- Chat Stream— multi-turn conversational football Q&A streamed as newline-delimited JSON events, ready to relay to the end-user's UI.
- Intelligence Signals — fire-and-forget ingestion of behavioural events (page views, market clicks, bet placements) that feed the personalisation engine.
- Betting Insights — per-match AI-generated betting intelligence cards (Phase 2).
High-level request flow
Your app
User types message
Your backend
Relay to API
Chat Stream
POST /api/chat-stream
Your backend
Stream events
Your app
Renders answer
Integration model
The Chat Assistant API is a server-to-server API. Your operator backend authenticates with a Bearer key and relays events between your end-users and the API. No browser or mobile client should ever call this API directly.
Why server-to-server?
- Your operator key stays private — no risk of client-side leakage.
- You control user identity mapping — the API works with your opaque
operatorUserId; no PII reaches Sensible Stats. - You can apply your own content filtering, rate limiting, or caching before relaying events.
- Signals and personalisation data are scoped per-operator — strict data isolation between partners.
Authentication
Every request must include a valid API key as a Bearer token in the Authorization header. Keys are issued per operator.
Authorization: Bearer <YOUR_OPERATOR_API_KEY>
Content-Type: application/jsonContact partners@sensiblestats.com to obtain an API key.
| HTTP | Error code | Cause |
|---|---|---|
| 401 | MissingOperatorContext | Authorization header absent or token format invalid. |
| 401 | MissingOperatorContext | API key not recognised or revoked. |
Rate limits
Rate limits are enforced per operator API key. Your specific limits are defined in your partnership agreement. The values below are indicative.
| Window | Reference limit |
|---|---|
| Per minute | 120 requests |
| Per day | 50 000 requests |
On limit exceeded the API returns 429 with error code OperatorRateLimitExceeded and a Retry-After header.
Chat stream
/api/chat-streamSend a chat turn, receive NDJSON streamSubmits an end-user message and streams the assistant's response as a sequence of typed newline-delimited JSON events. Each event is a self-contained object terminated with \n. The stream ends after a turn_complete or terminal error event.
Response Content-Type is application/x-ndjson. HTTP status is always 200 OK once the stream has started — errors that occur mid-stream are emitted as error events, not as HTTP status changes. Most turns complete in 2 – 6 seconds; complex questions may take up to 17 seconds.
Request body
Send a JSON object. Maximum payload size is 8 KB.
{
"message": "How many Premier League goals did Haaland score in 2023/24?",
"conversationId": "conv_9a1b2c3d",
"turnIdempotencyKey": "turn_idem_00123",
"userContext": {
"operatorUserId": "usr_abc123",
"defaultLanguage": "en"
},
"debug": false
}| Field | Type | Description |
|---|---|---|
| message | string | requiredThe end-user's message verbatim. 1 – 2 000 characters. Supports natural-language football questions, follow-ups referencing prior turns, and contextual continuations. |
| conversationId | string | optionalLinks this turn to a prior conversation. Omit for a fresh conversation; include the value returned in the previous turn_complete event to continue multi-turn context. Max 64 chars. |
| turnIdempotencyKey | string | optionalOperator-generated unique key for this turn. Used to deduplicate retried requests. Max 128 chars. |
| userContext.operatorUserId | string | optionalOpaque operator-owned user identifier. Do not send PII. When provided, the personalisation engine attaches the user's entity interest profile to the assistant context. Max 128 chars. |
| userContext.defaultLanguage | string | optionalPreferred locale for the assistant response (e.g. en, en-US, tr-TR). Falls back to en if omitted or unsupported. Max 32 chars. |
| debug | boolean | optionalWhen true and operator config permits it, the stream includes additional diagnostic progress events exposing internal pipeline phases. Do not surface to end-users. |
NDJSON event contract
Every line in the response body is a JSON object followed by \n. Consume the stream line-by-line. Each object shares this envelope:
{
"type": "<event_type>",
"requestId": "req_8f3a92b1",
"data": { ... }
}| Field | Type | Description |
|---|---|---|
| type | string enum | Event category. See Event types below. |
| requestId | string | Correlation ID. Identical across all events in a single turn. Include in support requests. |
| data | object | Event payload. Shape varies by type. |
data objects may be introduced without a version bump. Your parser must silently ignore unknown type values and unknown fields.Typical event sequence
{"type":"progress","requestId":"req_abc","data":{"phase":"resolving","message":"Resolving entities..."}}
{"type":"progress","requestId":"req_abc","data":{"phase":"querying","message":"Running stats query..."}}
{"type":"text","requestId":"req_abc","data":{"text":"## Erling Haaland — Premier League 2023/24\n\n..."}}
{"type":"intent_chip","requestId":"req_abc","data":{"intent":{"label":"Top scorers 2023/24","query":"Who were the top 5 scorers?"}}}
{"type":"turn_complete","requestId":"req_abc","data":{"conversationId":"conv_9a1b2c3d","assistantText":"...","totalLatencyMs":3104}}Event types
| Type | Description | Render to user? |
|---|---|---|
| progress | Internal pipeline phase notification. Use data.phase / data.message to label your loading indicator. | Optional |
| text | The assistant's primary answer in CommonMark Markdown. May contain tables, headings, and bold stats. Always sanitise before DOM insertion. | Yes — main answer bubble |
| entity_card | Structured entity card (player, team, competition). Shape varies by entity type. Gracefully omit if unrecognised. | Yes — inline card |
| action_button | Suggested action tied to the answer (e.g. View match). Includes a label and operator-routable target. | Yes — CTA button |
| intent_chip | Suggested follow-up question. Contains a label and a pre-filled query to submit as the next message. | Yes — tappable chip |
| turn_complete | Terminal event marking end of a successful turn. Always the last event. Contains full assembled answer, conversationId, and latency metadata. | No — use for bookkeeping |
| error | Terminal event emitted when the stream fails after starting. data.terminal is always true. | Show a fallback message |
Event data schemas
progress
{
"type": "progress",
"requestId": "req_abc",
"data": {
"phase": "resolving",
"message": "Resolving entities..."
}
}text
{
"type": "text",
"requestId": "req_abc",
"data": {
"text": "## Erling Haaland — Premier League 2023/24\n\nErling Haaland scored **27 goals** in 31 appearances..."
}
}intent_chip
{
"type": "intent_chip",
"requestId": "req_abc",
"data": {
"intent": {
"label": "Top scorers in 2023/24",
"query": "Who were the top 5 scorers in the Premier League 2023/24?"
}
}
}error (mid-stream terminal)
{
"type": "error",
"requestId": "req_abc",
"data": {
"code": "InternalError",
"message": "An unexpected error occurred. Please try again.",
"terminal": true
}
}Turn complete event
The turn_complete event is always the last event in a successful stream. It carries the full assembled answer, the conversation ID for the next turn, and diagnostic metadata.
{
"type": "turn_complete",
"requestId": "req_abc",
"data": {
"conversationId": "conv_9a1b2c3d",
"assistantText": "## Erling Haaland...",
"routeDecision": "stats_agent",
"isAgentRouted": true,
"totalLatencyMs": 3104,
"actions": [],
"cards": [],
"slip": null,
"onboarding": null,
"toolCallLog": []
}
}| Field | Type | Description |
|---|---|---|
| conversationId | string | Pass as conversationId in the next request to continue the conversation with full context. |
| assistantText | string | Complete assistant response in Markdown. Same text as the text event(s) — provided here for transcript storage. |
| routeDecision | string | Internal routing label (e.g. "stats_agent"). Useful for diagnostic logging. |
| isAgentRouted | boolean | Whether the turn was handled by an AI agent rather than a deterministic handler. |
| totalLatencyMs | integer | End-to-end server-side processing time in ms. |
| actions | array | Action button payloads surfaced during this turn (mirrors action_button events). |
| cards | array | Entity card payloads surfaced during this turn (mirrors entity_card events). |
| slip | object | null | Betting slip context when the assistant surfaced slip-related content. |
| toolCallLog | array | Diagnostic log of internal tool calls. Debug use only — do not surface to end-users. |
Intelligence signals
The Intelligence Signal endpoint is a fire-and-forget behavioural data pipeline. Send signals whenever your users interact with sports content — match pages, player profiles, markets. Sensible Stats processes them asynchronously to build a per-user entity interest profile that is injected into the chat assistant's context on future turns.
The result is an assistant that opens conversations with personalised greetings, surfaces stats about entities the user cares about, and orders suggested follow-ups around observed interests — without you building or maintaining a recommendation system yourself.
Signal endpoint
/api/intelligence/signalIngest a behavioural signalAccepts a single behavioural signal, persists it to the management database, and enqueues it for asynchronous downstream processing. Returns 202 Accepted when the payload has been persisted.
Request body
{
"signalType": 2,
"entityType": 3,
"entityId": "player_123456",
"operatorUserId": "usr_abc123"
}| Field | Type | Description |
|---|---|---|
| signalType | integer enum | requiredSignal category integer. See Signal types. |
| entityType | integer enum | requiredEntity category integer. See Entity types. |
| entityId | string | requiredOperator-scoped entity identifier from your own platform (your internal match ID, player ID, market ID, etc.). Max 128 chars. |
| operatorUserId | string | requiredOpaque identifier for the end-user within your operator scope. Must match the userContext.operatorUserId you pass on chat turns so the interest profile is correctly linked. Do not send PII. Max 128 chars. |
Response — 202 Accepted
{
"requestId": "req_8f3a92b1",
"acceptedAtUtc": "2025-06-21T14:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
| requestId | string | Correlation ID. Reference in support requests. |
| acceptedAtUtc | string (ISO 8601) | UTC timestamp when the signal was accepted by the API. |
Signal types
Pass the integer value in the signalType field.
Page & content views
- 1
MatchPageView
User viewed a match page.
- 2
PlayerPageView
User viewed a player profile.
- 3
TeamPageView
User viewed a team page.
- 4
MarketView
User viewed a betting market.
- 5
SearchQuery
User executed a search query.
Engagement depth
- 6
SessionDepth
Session depth update signal.
- 7
TimeOnPage
Time-on-page interaction signal.
- 11
InsightScrollDepth
Insight scroll depth interaction.
- 16
RecommendationDismissed
User dismissed a recommendation.
Betting signals
- 8
InsightViewed
Reason-to-bet insight was viewed.
- 9
InsightMarketClicked
User clicked from an insight to a market.
- 10
BetPlacedFromInsight
User placed a bet from an insight.
Chat signals
- 12
ToolCall
Chat tool call signal.
- 13
EntityMention
Chat entity mention signal.
- 14
Handoff
Chat handoff signal.
- 15
FollowUpQuery
Chat follow-up query signal.
Entity types
Pass the integer value in the entityType field.
| Value | Name | Description |
|---|---|---|
| 1 | Match | A football match. Use with MatchPageView. |
| 2 | Team | A club or national team. Use with TeamPageView. |
| 3 | Player | A football player. Use with PlayerPageView. |
| 4 | Competition | A league, cup, or tournament. |
| 5 | Market | A betting market. Use with MarketView, InsightMarketClicked, BetPlacedFromInsight. |
| 6 | Article | A content article or editorial piece. |
| 7 | Session | A user session entity. Use with SessionDepth. |
| 8 | Query | A search query entity. Use with SearchQuery. |
| 9 | Conversation | A chat conversation entity. Use with chat-channel signals. |
Betting insights
Returns AI-generated pre-match betting intelligence cards for a given football match. Cards surface key stats, recent form, head-to-head records, and model-derived confidence signals to help operators enrich their match pages.
/api/betting-insightsRetrieve AI betting intelligence cards for a matchReturns a structured insights payload for the specified match. All insight cards include a confidence score and evidence summary derived from live stats data.
| Query param | Required | Description |
|---|---|---|
| matchId | Yes | Your operator-scoped match identifier (the same entityId you send with MatchPageView signals). |
| language | No | Preferred locale for insight copy (e.g. en, tr-TR). Defaults to en. |
GET /api/betting-insights?matchId=match_88abc123&language=en
Authorization: Bearer <YOUR_OPERATOR_API_KEY>Health endpoints
Probe endpoints for container orchestration liveness and readiness checks. No authentication required.
/health/liveLiveness probe — is the process alive?Returns 200 OK with { "status": "live" } when the process is running. Use as a Kubernetes liveness probe or Docker health check.
/health/readyReadiness probe — is the process ready to serve traffic?Returns 200 OK when the database connection pool is healthy and the service is ready to accept requests. Returns 503 Service Unavailable during startup or when downstream dependencies are unhealthy.
Error format
All HTTP-level errors (4xx/5xx) use RFC 7807 ProblemDetails with two additional fields: requestId and errorCode. Mid-stream errors use the error NDJSON event type instead.
{
"type": "https://api.sensiblestats.com/errors/validation",
"title": "Validation error",
"status": 422,
"detail": "The 'message' field is required and must not be empty.",
"requestId": "req_8f3a92b1",
"errorCode": "ValidationError"
}| Field | Type | Description |
|---|---|---|
| type | string (URI) | URI identifying the error type. Stable and bookmarkable. |
| title | string | Short human-readable summary of the error type. |
| status | integer | HTTP status code mirrored in the body. |
| detail | string | Human-readable explanation of this specific occurrence. |
| requestId | string | Correlation ID. Include in support requests to enable log lookup. |
| errorCode | string | Machine-readable error code. Use this for programmatic error handling. |
Error codes
| HTTP | errorCode | Description |
|---|---|---|
| 400 | BadRequest | Malformed JSON payload or missing Content-Type. |
| 401 | MissingOperatorContext | Authorization header absent, token format invalid, or key not recognised. |
| 403 | OperatorFeatureNotEnabled | The requested endpoint is not enabled for your operator account. |
| 422 | ValidationError | Request body failed schema validation. Check the detail field for the specific constraint. |
| 429 | OperatorRateLimitExceeded | Request rate exceeds your operator plan limits. Respect the Retry-After header. |
| 500 | InternalError | Unexpected server-side error. Retry with exponential backoff. File a support ticket if it persists. |
| 503 | ServiceUnavailable | The service is temporarily unavailable (deployment or upstream outage). Retry after a short delay. |
500 and 503. Use exponential backoff with jitter, starting at 1 s and capping at 30 s. Do not retry 4xx errors — they indicate a problem with the request, not the server. For 429, wait until the Retry-After value elapses before retrying.Integration workflow
A typical integration involves three steps: provision credentials, relay the chat stream, and optionally enrich with signals.
Obtain an API key
Contact partners@sensiblestats.com. You will receive an operator API key scoped to your organisation. Store it securely — treat it as a secret, not a config value.
Build a relay endpoint on your backend
Create a server-side endpoint that authenticates your end-users, calls POST /api/chat-stream with the Bearer key, and streams the NDJSON response back to the client — via Server-Sent Events, WebSocket, or a chunked HTTP response.
Render events in your UI
Parse each newline-delimited JSON event. Render text events as Markdown, append intent_chip events as tappable suggestions, and handle action_button events as CTAs. Store the conversationId from turn_complete for multi-turn continuity.
Fire Intelligence Signals
On each page navigation (match page, player profile, market), POST /api/intelligence/signal from your backend. The personalisation engine uses these signals to inject user interest context into future chat turns.
Personalisation via signals
Personalisation is additive — it improves the chat experience without requiring any changes to your chat relay. Send signals whenever possible, but the chat endpoint works without them.
How it works
- 1. Signal ingestion. Your backend sends
POST /api/intelligence/signalwith anoperatorUserIdand the entity they interacted with. - 2. Profile building. Sensible Stats processes signals asynchronously to build an entity interest graph per user: which teams, players, and competitions they follow.
- 3. Context injection. When the same
operatorUserIdappears in a chat turn'suserContext, the assistant automatically receives their interest profile as context. - 4. Personalised responses. The assistant opens with greetings referencing the user's favourite clubs, prioritises stats for their preferred entities, and orders suggested follow-ups around observed interests.
operatorUserId travels across the wire. User identity resolution happens entirely inside your platform.Best practices
Never call from the browser
Your operator API key grants full access. Always relay through your server backend. Never include it in client-side bundles.
Stream — don't buffer
Relay NDJSON events to your frontend as they arrive. Buffering the full response before rendering degrades perceived latency by 2–6 s.
Handle unknown event types gracefully
New event types may be added without a version bump. Silently skip any type your parser doesn't recognise — don't throw.
Persist conversationId
Store the conversationId from each turn_complete event and pass it back on the next message. Context window continuity is managed server-side.
Sanitise Markdown before rendering
The text event contains CommonMark Markdown. Parse and sanitise it before DOM insertion to prevent XSS. Libraries like marked + DOMPurify work well.
Fire signals from your backend
Signal calls contain your operator key. They should originate from your server, not from client JavaScript, even though the endpoint is fire-and-forget.
Use idempotency keys for retries
Pass a unique turnIdempotencyKey on each chat turn. If your relay retries a failed request, the API will deduplicate and return the original response.
Log requestId at your relay layer
Capture the requestId from each turn_complete or error event in your server logs. You'll need it when filing support requests.
Code examples
Node.js — backend relay (TypeScript)
A minimal Express endpoint that relays the NDJSON stream from the Chat Assistant API to the browser via Server-Sent Events.
import express from "express";
const CHAT_API = "https://api.sensiblestats.com";
const API_KEY = process.env.SENSIBLE_STATS_API_KEY!;
const app = express();
app.use(express.json());
app.post("/chat", async (req, res) => {
const { message, conversationId, userId } = req.body;
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const upstream = await fetch(`${CHAT_API}/api/chat-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
conversationId,
userContext: { operatorUserId: userId, defaultLanguage: "en" },
}),
});
if (!upstream.ok || !upstream.body) {
res.status(502).json({ error: "upstream_error" });
return;
}
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// Forward each NDJSON line as an SSE event
for (const line of chunk.split("\n")) {
if (line.trim()) res.write(`data: ${line}\n\n`);
}
}
res.end();
});Node.js — fire-and-forget signal
async function sendSignal(
operatorUserId: string,
signalType: number,
entityType: number,
entityId: string,
): Promise<void> {
// Don't await — fire and forget
fetch("https://api.sensiblestats.com/api/intelligence/signal", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SENSIBLE_STATS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ signalType, entityType, entityId, operatorUserId }),
}).catch((err) => console.error("[signal] delivery failed", err));
}
// On match page view:
sendSignal("usr_abc123", 1 /* MatchPageView */, 1 /* Match */, "match_88abc123");React — rendering the stream
import { useState } from "react";
import ReactMarkdown from "react-markdown";
export function ChatWidget() {
const [answer, setAnswer] = useState("");
const [chips, setChips] = useState<string[]>([]);
const [convId, setConvId] = useState<string | undefined>();
async function send(message: string) {
setAnswer("");
setChips([]);
const res = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, conversationId: convId }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
for (const line of text.split("\n")) {
const clean = line.startsWith("data: ") ? line.slice(6) : line;
if (!clean.trim()) continue;
const event = JSON.parse(clean);
if (event.type === "text") setAnswer(event.data.text);
if (event.type === "intent_chip") {
setChips((c) => [...c, event.data.intent.label]);
}
if (event.type === "turn_complete") setConvId(event.data.conversationId);
}
}
}
return (
<div>
<ReactMarkdown>{answer}</ReactMarkdown>
<div className="flex gap-2">
{chips.map((c) => (
<button key={c} onClick={() => send(c)} className="rounded-full border px-3 py-1 text-sm">
{c}
</button>
))}
</div>
</div>
);
}Python — backend relay
import httpx
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
CHAT_API = "https://api.sensiblestats.com"
API_KEY = os.environ["SENSIBLE_STATS_API_KEY"]
@app.post("/chat")
async def relay_chat(request: Request):
body = await request.json()
async def event_stream():
async with httpx.AsyncClient(timeout=30) as client:
async with client.stream(
"POST",
f"{CHAT_API}/api/chat-stream",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"message": body["message"],
"conversationId": body.get("conversationId"),
"userContext": {
"operatorUserId": body.get("userId"),
"defaultLanguage": "en",
},
},
) as r:
async for line in r.aiter_lines():
if line:
yield f"data: {line}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")cURL — quick test
# Chat stream (prints raw NDJSON lines)
curl -s -N \
-H "Authorization: Bearer <YOUR_OPERATOR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"message":"How many goals did Salah score in 2023/24?"}' \
https://api.sensiblestats.com/api/chat-stream
# Intelligence signal
curl -s -X POST \
-H "Authorization: Bearer <YOUR_OPERATOR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"signalType":1,"entityType":1,"entityId":"match_88abc123","operatorUserId":"usr_abc123"}' \
https://api.sensiblestats.com/api/intelligence/signalChangelog
- POST /api/chat-stream — multi-turn football Q&A with NDJSON streaming
- POST /api/intelligence/signal — behavioural signal ingestion with 16 signal types and 9 entity types
- GET /api/betting-insights — AI betting intelligence cards (early access)
- RFC 7807 ProblemDetails error format with errorCode and requestId
- Personalisation engine: entity interest profiles injected into chat context
- Health endpoints: /health/live and /health/ready
- Bulk signal ingestion endpoint (batch up to 50 signals per call)
- Entity card structured data for players, teams, and competitions
- Webhook support for async signal processing confirmation
- Expanded locale support
