AhadiCare AI
API Documentation
← Playground GET /v1/health

AhadiCare AI

A multi-tenant conversational care API — same core, different brand + safety policy per tenant. Ships with symptom triage, 22 offline-cacheable clinical assessments, results storage and nearest-facility routing.

The catalogue is 3 bilingual instruments (PHQ-9, GAD-7, PC-PTSD-5) plus 19 ported from the AHADI website. Each carries a direction of symptom (higher = more difficulty) or wellbeing (higher = better) — read it before rendering a score, or a top WHO-5 result reads as severe. Instruments with mixed answer sets per question expose scale on the item rather than the instrument, so resolve item.scale || tool.scale. A tenant can curate the list via clinical.enabledAssessments; omit it for the full catalogue.

On this page

Authentication

Every tenant call requires a Bearer token. Admin routes require a separate X-Admin-Key header.

Authorization: Bearer <tenant_api_key>
X-Admin-Key:  <admin_key>    # admin routes only

Rate limits are enforced per tenant (default 60 req/min). Exceeded requests return 429.

Chat

POST/v1/chatbearer

Single-turn or multi-turn reply. The service enforces the tenant's safety policy (hotline or facility) automatically.

Request body

FieldTypeNotes
messagestringrequired; max 4000 characters (413 above that)
languageen · sw · mix · autooptional; omit it (or send auto) and the language is detected from the message. Only if the message carries no signal does it fall back to sticky_language, then the tenant default. An explicit value always wins.
sticky_languagestringoptional; the language this conversation is already in. Used only when the current turn carries no signal of its own — a filler like "ok" or "ndio" then stays in the conversation's language instead of snapping back to the tenant default.
historyarrayoptional; [{role,content}], last 24 kept (override with MAX_HISTORY_MESSAGES). Bring your own history — the API will use it and store nothing more than trace metadata when no_store is true.
session_idstringoptional
user_refstringoptional; opaque id used for results storage
no_storebooloptional; when true we don't persist any message content on our side. See Custom chat storage.

Response

{
  "reply":     "…",
  "language":  "sw",
  "safety":    { "crisis": false, "protocolEnforced": false },
  "quality":   { "overall": 0.87, "safety": 1, "empathy": 0.25,
                 "conciseness": 1, "languageMatch": 1, "protocolAdherence": 1 },
  "usage":     { "prompt_tokens": 645, "completion_tokens": 32, "total_tokens": 677 },
  "trace_id":  "trc_…",
  "latency_ms": 1470
}

Symptom analyzer

POST/v1/analyzebearer

Deterministic triage — sub-millisecond, no LLM cost. Returns condition candidates and the recommended assessment tool.

{ "symptoms": "I feel anxious and cannot sleep", "language": "en" }
{
  "crisis": false,
  "conditions": [{ "condition": "anxiety",
                   "recommendedTool": "gad7", "confidence": 1.0,
                   "matchedTerms": ["anxious","cannot sleep"] }],
  "top":     { "condition": "anxiety", "recommendedTool": "gad7" },
  "guidance":"Try the GAD7 self-assessment for a clearer picture."
}

Assessments

Public-domain screening instruments shipped as JSON. Clients cache them locally and can score offline; the server can also score on demand.

GET/v1/assessmentsbearer

List assessments enabled for the tenant.

GET/v1/assessments/:idbearer

Full instrument definition (items, scale, bands, crisis-flag rules). Cache-Control: public, max-age=86400. Supported ids: phq9, gad7, pcptsd5.

POST/v1/assessments/:id/scorebearer

Score user answers. If user_ref is provided, the result is auto-saved.

The response carries a debrief in narrative: a warm, plain-language explanation grounded in the specific items the person rated highest, so the result explains why it is what it is rather than only naming a band. Render this as the reply the user reads — total and severity are context, not the message. Pass debrief: false when you only need the numbers (dashboards, batch re-scoring); that also skips the model call.

When crisis is true the safety protocol is already woven into narrative, so do not append crisisProtocol as well or the user reads it twice. Without an OPENAI_API_KEY, or if the model call fails, a deterministic template debrief is returned instead; narrativeSource says which was used.

{
  "user_ref": "user-abc-123",
  "language": "sw",
  "debrief":  true,
  "answers":  [{ "itemId": 1, "value": 2 }, { "itemId": 2, "value": 3 } … ]
}
{
  "assessmentId": "phq9", "assessmentName": "PHQ-9", "measures": "depression",
  "total": 15, "maxTotal": 27, "severity": "moderately_severe",
  "crisis": true,
  "recommendation": "Clinical review recommended.",
  "narrative": "Are you safe right now? ... What you rated highest was ...",
  "narrativeSource": "openai",
  "items":    [{ "id": 1, "value": 2, "text": "Little interest or pleasure...",
                 "answerLabel": "More than half the days", "elevated": true }],
  "elevated": [ "highest-rated items first - what the debrief leads with" ],
  "crisisProtocol": {
    "safetyQuestion": "Are you safe right now?",
    "directive":      "This needs urgent clinical attention. Please go to the nearest health facility right away."
  },
  "savedId": "res_…"
}

Results

Per-user, tenant-scoped result storage. Retrieve via an opaque user_ref — you control the id scheme, no PII stored.

GET/v1/results/:userRefbearer
POST/v1/resultsbearer

Save arbitrary {user_ref, type, payload}.

DELETE/v1/results/:userRef/:idbearer

Custom chat storage

Every organisation can keep chat history, assessments and results in their own ecosystem (Firebase, Postgres, Salesforce, internal EMR — anything). We support three modes:

ModeWhere messages / results are storedHow to configure
serverOn our side (default). Retrievable via /v1/results.Do nothing.
clientOnly on your side.Set tenant storage.mode = "client", or send no_store: true on any request.
bothOurs + mirrored to your webhook in real time.Set tenant storage.mode = "both" and storage.webhookUrl.

Bring your own history

Pass the full conversation on every POST /v1/chat request:

POST /v1/chat
{
  "message":  "I feel worthless",
  "history":  [
    { "role": "user",      "content": "hi" },
    { "role": "assistant", "content": "Hi, I'm here to help." },
    { "role": "user",      "content": "I can't sleep" },
    { "role": "assistant", "content": "Sorry to hear that. How long has this been going on?" }
  ],
  "no_store": true
}

The model uses everything in history. With no_store: true, we don't persist previews of the user or assistant text — only trace metadata (latency, safety flags, quality) which you can turn off entirely by using storage mode client.

Real-time mirror webhook

If you set storage.webhookUrl on your tenant, we POST every event to that URL as it happens:

POST https://your-app.example.com/ahadicare/events
X-AhadiCare-Tenant:    emkf
X-AhadiCare-Signature: <hmac_sha256(body, tenant.storage.webhookSecret)>
Content-Type:          application/json

{
  "eventType":  "chat.response",
  "timestamp":  "2026-09-02T…Z",
  "tenantId":   "emkf",
  "traceId":    "trc_…",
  "userRef":    "user-42",
  "sessionId":  "web-…",
  "safety":     { "crisis": false, "harmMethodRequested": false, "enforced": false },
  "quality":    { "overall": 0.87, "safety": 1, … },
  "preview":    { "user": "…", "assistant": "…" }
}

Event types you'll receive: chat.response, clinical.assessment.scored, clinical.analyze. Verify each request by recomputing HMAC-SHA256(body, webhookSecret) and comparing to the X-AhadiCare-Signature header.

Assessment results

Send no_store: true on POST /v1/assessments/:id/score to receive the score without saving it on our side. The response includes "stored": false so your client knows to persist it locally.

Facilities

GET/v1/facilities/nearby?lat=..&lng=..&limit=3bearer

Returns nearby facilities sorted by great-circle distance. Used when a tenant's emergency.policy = facility.

Safety screen (heuristic only)

POST/v1/safety/screenbearer

Run the deterministic crisis heuristic without spending on an LLM call. Returns {crisis, harmMethodRequested, protocolLines}.

Tenants (admin)

GET/v1/admin/tenantsX-Admin-Key
POST/v1/admin/tenantsX-Admin-Key

Create a tenant with a full Tenant model.

PATCH/v1/admin/tenants/:idX-Admin-Key
DELETE/v1/admin/tenants/:idX-Admin-Key

Telemetry

GET/v1/eval/summarybearer

Rolling totals: messages, avg latency, avg quality, crisis count.

GET/v1/eval/events?limit=50&eventType=chat.responsebearer

Tenant model

Every tenant is a full brand + safety config. Same runtime, wildly different behavior.

{
  "tenantId":     "emkf",
  "displayName":  "EMKF",
  "organisation": "Emmanuel Mental Kindness Foundation",
  "assistantName":"EMKF Assistant",
  "persona":      "a supportive mental wellness assistant…",
  "domain":       "mental wellness triage",
  "supportedLanguages": ["en","sw","mix"],
  "defaultLanguage":    "en",
  "emergency": {
    "policy":            "facility",   // or "hotline"
    "urgentPhraseEn":    "This needs urgent clinical attention. Please go to the nearest health facility right away.",
    "urgentPhraseSw":    "Hii inahitaji msaada wa kimatibabu haraka. Tafadhali nenda kituo cha afya kilicho karibu nawe sasa hivi.",
    "safetyQuestionEn":  "Are you safe right now?",
    "safetyQuestionSw":  "Uko salama sasa hivi?"
  },
  "style":    { "maxSentences": 2, "allowEmoji": false, "allowAsterisks": false },
  "clinical": { "enabledAssessments": ["phq9","gad7","pcptsd5"], "offlineCacheable": true },
  "storage":  {
    "mode":          "both",                              // "server" | "client" | "both"
    "webhookUrl":    "https://your-app.example.com/…",    // set for real-time mirror
    "webhookSecret": "***"                                // HMAC signing secret
  },
  "rateLimits": { "rpm": 120, "monthlyTokenCap": 10000000 }
}

Quick copy-paste

curl -X POST http://localhost:4000/v1/chat \
  -H "Authorization: Bearer sk_demo_emkf_public_key_replace_me" \
  -H "Content-Type: application/json" \
  -d '{"message":"Ninajisikia vibaya sana leo","language":"sw"}'