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.

v1REST / HTTPSNDJSON streamingServer-to-server only

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.

Never expose your API key client-side. Your key grants full operator-scoped access. All calls must originate from your server backend. Relay the NDJSON stream to your frontend via WebSocket, SSE, or your own streaming HTTP endpoint.

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/json

Contact partners@sensiblestats.com to obtain an API key.

HTTPError codeCause
401MissingOperatorContextAuthorization header absent or token format invalid.
401MissingOperatorContextAPI 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.

WindowReference limit
Per minute120 requests
Per day50 000 requests
Limits are contract-defined. Contact partners@sensiblestats.com to discuss limit adjustments or higher-volume arrangements.

On limit exceeded the API returns 429 with error code OperatorRateLimitExceeded and a Retry-After header.

Chat stream

POST/api/chat-streamSend a chat turn, receive NDJSON stream

Submits 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
}
FieldTypeDescription
messagestringrequiredThe end-user's message verbatim. 1 – 2 000 characters. Supports natural-language football questions, follow-ups referencing prior turns, and contextual continuations.
conversationIdstringoptionalLinks 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.
turnIdempotencyKeystringoptionalOperator-generated unique key for this turn. Used to deduplicate retried requests. Max 128 chars.
userContext.operatorUserIdstringoptionalOpaque 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.defaultLanguagestringoptionalPreferred locale for the assistant response (e.g. en, en-US, tr-TR). Falls back to en if omitted or unsupported. Max 32 chars.
debugbooleanoptionalWhen 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": { ... }
}
FieldTypeDescription
typestring enumEvent category. See Event types below.
requestIdstringCorrelation ID. Identical across all events in a single turn. Include in support requests.
dataobjectEvent payload. Shape varies by type.
Design for extensibility. New event types and new fields within existing 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

TypeDescriptionRender to user?
progressInternal pipeline phase notification. Use data.phase / data.message to label your loading indicator.Optional
textThe assistant's primary answer in CommonMark Markdown. May contain tables, headings, and bold stats. Always sanitise before DOM insertion.Yes — main answer bubble
entity_cardStructured entity card (player, team, competition). Shape varies by entity type. Gracefully omit if unrecognised.Yes — inline card
action_buttonSuggested action tied to the answer (e.g. View match). Includes a label and operator-routable target.Yes — CTA button
intent_chipSuggested follow-up question. Contains a label and a pre-filled query to submit as the next message.Yes — tappable chip
turn_completeTerminal event marking end of a successful turn. Always the last event. Contains full assembled answer, conversationId, and latency metadata.No — use for bookkeeping
errorTerminal 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": []
  }
}
FieldTypeDescription
conversationIdstringPass as conversationId in the next request to continue the conversation with full context.
assistantTextstringComplete assistant response in Markdown. Same text as the text event(s) — provided here for transcript storage.
routeDecisionstringInternal routing label (e.g. "stats_agent"). Useful for diagnostic logging.
isAgentRoutedbooleanWhether the turn was handled by an AI agent rather than a deterministic handler.
totalLatencyMsintegerEnd-to-end server-side processing time in ms.
actionsarrayAction button payloads surfaced during this turn (mirrors action_button events).
cardsarrayEntity card payloads surfaced during this turn (mirrors entity_card events).
slipobject | nullBetting slip context when the assistant surfaced slip-related content.
toolCallLogarrayDiagnostic 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.

When to send signals: Fire one every time a user views a match page, a player profile, or a betting market. Signal delivery is best-effort — dropped signals are logged server-side and will not change the HTTP response code.

Signal endpoint

POST/api/intelligence/signalIngest a behavioural signal

Accepts 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"
}
FieldTypeDescription
signalTypeinteger enumrequiredSignal category integer. See Signal types.
entityTypeinteger enumrequiredEntity category integer. See Entity types.
entityIdstringrequiredOperator-scoped entity identifier from your own platform (your internal match ID, player ID, market ID, etc.). Max 128 chars.
operatorUserIdstringrequiredOpaque 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"
}
FieldTypeDescription
requestIdstringCorrelation ID. Reference in support requests.
acceptedAtUtcstring (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.

ValueNameDescription
1MatchA football match. Use with MatchPageView.
2TeamA club or national team. Use with TeamPageView.
3PlayerA football player. Use with PlayerPageView.
4CompetitionA league, cup, or tournament.
5MarketA betting market. Use with MarketView, InsightMarketClicked, BetPlacedFromInsight.
6ArticleA content article or editorial piece.
7SessionA user session entity. Use with SessionDepth.
8QueryA search query entity. Use with SearchQuery.
9ConversationA 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.

Phase 2 feature. The Betting Insights endpoint is currently available to partners enrolled in the early-access programme. Contact partners@sensiblestats.com to enrol.
GET/api/betting-insightsRetrieve AI betting intelligence cards for a match

Returns a structured insights payload for the specified match. All insight cards include a confidence score and evidence summary derived from live stats data.

Query paramRequiredDescription
matchIdYesYour operator-scoped match identifier (the same entityId you send with MatchPageView signals).
languageNoPreferred 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.

GET/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.

GET/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"
}
FieldTypeDescription
typestring (URI)URI identifying the error type. Stable and bookmarkable.
titlestringShort human-readable summary of the error type.
statusintegerHTTP status code mirrored in the body.
detailstringHuman-readable explanation of this specific occurrence.
requestIdstringCorrelation ID. Include in support requests to enable log lookup.
errorCodestringMachine-readable error code. Use this for programmatic error handling.

Error codes

HTTPerrorCodeDescription
400BadRequestMalformed JSON payload or missing Content-Type.
401MissingOperatorContextAuthorization header absent, token format invalid, or key not recognised.
403OperatorFeatureNotEnabledThe requested endpoint is not enabled for your operator account.
422ValidationErrorRequest body failed schema validation. Check the detail field for the specific constraint.
429OperatorRateLimitExceededRequest rate exceeds your operator plan limits. Respect the Retry-After header.
500InternalErrorUnexpected server-side error. Retry with exponential backoff. File a support ticket if it persists.
503ServiceUnavailableThe service is temporarily unavailable (deployment or upstream outage). Retry after a short delay.
Retry strategy. Retry only on 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.

01

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.

02

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.

03

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.

04

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. 1. Signal ingestion. Your backend sends POST /api/intelligence/signal with an operatorUserId and the entity they interacted with.
  2. 2. Profile building. Sensible Stats processes signals asynchronously to build an entity interest graph per user: which teams, players, and competitions they follow.
  3. 3. Context injection. When the same operatorUserId appears in a chat turn's userContext, the assistant automatically receives their interest profile as context.
  4. 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.
Privacy-safe by design. You never send PII to Sensible Stats. Only your opaque 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/signal

Changelog

v1.02025-06Current
  • 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
Upcoming2025 H2Planned
  • 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