XUND
Guides

Your first check

Go from credentials to a printed symptom check report with a single dependency-free Node.js script, in about 10 minutes.

This guide runs a complete symptom check end to end from one runnable Node.js script: get a token, start a check, auto-answer the question loop, and print the report. It is the hands-on complement to API Concept, which walks through an illness check at the JSON level, one request and response at a time — read that page for the concepts; run this one for a working result.

Prerequisites

  • A Client Id and API key, provided by XUND during onboarding — see Authentication.
  • Node.js 18 or newer — the script uses the built-in fetch, so there is nothing to install.

All API calls go to the base URL https://medical.api.xund.solutions and carry an authorization: Bearer <token> header plus — on the endpoints that define one — a language header; every call after initialization also carries the check-id header. See the Overview for the header reference.

1. Get a token

Exchange your credentials for a bearer token at the login service (full details on the Authentication page). Replace the two placeholders with your own values:

const CLIENT_ID = '<YOUR_CLIENT_ID>'
const API_KEY = '<YOUR_API_KEY>'

async function getToken() {
  const res = await fetch('https://login.xund.solutions/api/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      clientId: CLIENT_ID,
      clientSecret: API_KEY,
    }),
  })
  if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`)
  return (await res.json()).access_token
}

If you log the raw response, you should see:

{ "access_token": "eyJhbGciOiJI...", "token_type": "Bearer" }

2. Initialize a symptom check

A small helper adds the shared headers to every call, and POST /v1/chatbot/initialize/symptomCheck starts the check. This example uses the body-part variant — a bodyPartCode plus basic bodyParameters — so the check already knows the patient's age, gender (bodyParameters.gender takes the Gender enum values MALE or FEMALE), and where it hurts, and can go straight to symptom selection. (The other documented variant initializes with FHIR patient/observations resources; see the reference.) The valid bodyPartCode values — 34 body parts plus skip — are published on the initialize reference page. The body also sets "isCreatedByAutotest": true — include it whenever you run checks for testing or development, so they are excluded from XUND's analytics; drop it for real patient traffic.

const BASE_URL = 'https://medical.api.xund.solutions'
let token

async function api(method, path, { checkId, body, language = 'en' } = {}) {
  const res = await fetch(BASE_URL + path, {
    method,
    headers: {
      authorization: `Bearer ${token}`,
      ...(language ? { language } : {}),
      ...(checkId ? { 'check-id': checkId } : {}),
      ...(body ? { 'content-type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  })
  if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status} ${await res.text()}`)
  const text = await res.text()
  return text ? JSON.parse(text) : undefined // confirm endpoints return empty bodies
}

token = await getToken()
const { checkId } = await api('POST', '/v1/chatbot/initialize/symptomCheck', {
  // isCreatedByAutotest excludes this test run from XUND's analytics — drop it for real traffic
  body: { bodyPartCode: 'stomach', bodyParameters: { birthYear: 1985, gender: 'MALE' }, isCreatedByAutotest: true },
})
console.log('Check started:', checkId)

You should see a fresh check id (yours will differ):

Check started: 5e93d7a1-f240-43b5-8937-33419ea9cb6a

From now on, that checkId goes into the check-id header of every call — it is the only handle you have on the session.

3. Auto-answer the question loop

A check is driven by repeating two calls: GET /v1/chatbot/question fetches the next question, POST /v1/chatbot/answer answers it. Each question's type tells you how to answer it — this demo picks an answer mechanically:

function buildAnswer(question) {
  switch (question.type) {
    case 'SINGLE_SELECT': {
      // Pick the first option — except on loop-driving questions like
      // CHECK_IF_ADD_MORE_SYMPTOMS, where the first option is the affirmative
      // "add more symptoms": there, pick the LAST option (the declining one).
      const options = question.options.values
      const pick = question.detailedType === 'CHECK_IF_ADD_MORE_SYMPTOMS' ? options[options.length - 1] : options[0]
      return { questionId: question.id, answer: { id: pick.id } }
    }
    case 'NUMBER': { // send a value inside the allowed range
      const { min, max } = question.detailedTypeData?.numberConstraints ?? {}
      return { questionId: question.id, answer: { value: String(min ?? max ?? 1) } }
    }
    case 'TEXT': { // send a plausible value if it passes the question's own validator
      const value = '1990'
      const regex = question.answerValidatorRegex && new RegExp(question.answerValidatorRegex)
      if (!regex || regex.test(value)) return { questionId: question.id, answer: { value } }
      // otherwise skip — if the question is non-skippable, this fails with 400 INVALID_SKIP (demo limitation)
      return { questionId: question.id }
    }
    default: // INFO and EXTENDING advance via a skip answer: a body without `answer`
      return { questionId: question.id }
  }
}

One trap to know about: a naive "always pick the first option" strategy can loop forever. Some questions drive the flow itself — CHECK_IF_ADD_MORE_SYMPTOMS asks whether to add more symptoms, and its affirmative option comes first, so always answering with the first option keeps extending the check instead of finishing it. That is why the strategy above prefers the last option (the declining one) for that detailedType, and why the full script caps the loop at 200 iterations as a safety net.

This auto-answering exists only to try the API. A real integration renders every question to the patient exactly as returned and sends back their actual choice — see the Implementation Guidelines for the binding rules, and The question-answer loop for what each question type requires.

While the loop runs (the full loop is in the script below), you should see output like:

Answered "Which symptom bothers you the most?" (SINGLE_SELECT)
Answered "How severe is your abdominal pain?" (SINGLE_SELECT)
Answered "Will the pain be better if you lean forward?" (SINGLE_SELECT)
Skipped "Which other symptoms are you experiencing?" (EXTENDING)

4. Handle the overview

Eventually GET /v1/chatbot/question returns a question with type: "FUNCTION" — a question that cannot be answered through POST /v1/chatbot/answer. The first one has detailedType: "OVERVIEW": fetch GET /v1/chatbot/overview (mandatory — confirming without it is rejected with a 403), then confirm with POST /v1/chatbot/overview/confirm and keep looping:

if (question.type === 'FUNCTION' && question.detailedType === 'OVERVIEW') {
  const overview = await api('GET', '/v1/chatbot/overview', { checkId })
  console.log('Overview — added symptoms:', overview.addedSymptoms.map((s) => s.name).join(', '))
  await api('POST', '/v1/chatbot/overview/confirm', { checkId })
}

You should see the symptoms the check collected so far:

Overview — added symptoms: Abdominal pain, Fever

5. Fetch and print the report

After the overview, the check asks illness-survey questions, then the loop hits its second FUNCTION question — detailedType: "SYMPTOM_CHECK_REPORT". The results are ready: fetch them with GET /v1/chatbot/report (a PDF version is available from GET /v1/chatbot/report/pdf). Fetching the report automatically closes the check — it is the terminal call of the flow, so the script just prints the result and exits.

const report = await api('GET', '/v1/chatbot/report', { checkId })
printReport(report) // a helper of this script, defined in the full script below; not part of the XUND API

You should see output shaped like this — the actual illnesses depend on the answers the script happened to pick:

=== Report ===

Tumors
  Pancreatic carcinoma (C25) — score 75.6: Requires prompt medical consultation

Important: Please note that this is not a medical diagnosis. ...

The full script

Save as first-check.mjs, substitute the two placeholders, and run node first-check.mjs:

const BASE_URL = 'https://medical.api.xund.solutions'
const CLIENT_ID = '<YOUR_CLIENT_ID>'
const API_KEY = '<YOUR_API_KEY>'

let token

async function getToken() {
  const res = await fetch('https://login.xund.solutions/api/token', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ grant_type: 'client_credentials', clientId: CLIENT_ID, clientSecret: API_KEY }),
  })
  if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`)
  return (await res.json()).access_token
}

async function api(method, path, { checkId, body, language = 'en' } = {}) {
  const res = await fetch(BASE_URL + path, {
    method,
    headers: {
      authorization: `Bearer ${token}`,
      ...(language ? { language } : {}),
      ...(checkId ? { 'check-id': checkId } : {}),
      ...(body ? { 'content-type': 'application/json' } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  })
  if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status} ${await res.text()}`)
  const text = await res.text()
  return text ? JSON.parse(text) : undefined // confirm endpoints return empty bodies
}

// Demo-only answer strategy — a real integration shows the question to the patient.
function buildAnswer(question) {
  switch (question.type) {
    case 'SINGLE_SELECT': {
      // Pick the first option — except on loop-driving questions like
      // CHECK_IF_ADD_MORE_SYMPTOMS, where the first option is the affirmative
      // "add more symptoms": there, pick the LAST option (the declining one).
      const options = question.options.values
      const pick = question.detailedType === 'CHECK_IF_ADD_MORE_SYMPTOMS' ? options[options.length - 1] : options[0]
      return { questionId: question.id, answer: { id: pick.id } }
    }
    case 'NUMBER': { // send a value inside the allowed range
      const { min, max } = question.detailedTypeData?.numberConstraints ?? {}
      return { questionId: question.id, answer: { value: String(min ?? max ?? 1) } }
    }
    case 'TEXT': { // send a plausible value if it passes the question's own validator
      const value = '1990'
      const regex = question.answerValidatorRegex && new RegExp(question.answerValidatorRegex)
      if (!regex || regex.test(value)) return { questionId: question.id, answer: { value } }
      // otherwise skip — if the question is non-skippable, this fails with 400 INVALID_SKIP (demo limitation)
      return { questionId: question.id }
    }
    default: // INFO and EXTENDING advance via a skip answer: a body without `answer`
      return { questionId: question.id }
  }
}

function printReport(report) {
  console.log('\n=== Report ===')
  for (const category of report.illnessCategories) {
    console.log(`\n${category.name}`)
    for (const illness of category.illnesses) {
      console.log(`  ${illness.name} (${illness.icd10}) — score ${illness.score.toFixed(1)}: ${illness.treatmentShortInfo.text}`)
    }
  }
  console.log(`\n${report.legal.disclaimer}`)
}

async function main() {
  token = await getToken()

  const { checkId } = await api('POST', '/v1/chatbot/initialize/symptomCheck', {
    // isCreatedByAutotest excludes this test run from XUND's analytics — drop it for real traffic
    body: { bodyPartCode: 'stomach', bodyParameters: { birthYear: 1985, gender: 'MALE' }, isCreatedByAutotest: true },
  })
  console.log('Check started:', checkId)

  for (let i = 0; i < 200; i++) {
    const question = await api('GET', '/v1/chatbot/question', { checkId })

    if (question.type === 'FUNCTION') {
      if (question.detailedType === 'OVERVIEW') {
        const overview = await api('GET', '/v1/chatbot/overview', { checkId })
        console.log('Overview — added symptoms:', overview.addedSymptoms.map((s) => s.name).join(', '))
        await api('POST', '/v1/chatbot/overview/confirm', { checkId })
        continue
      }
      // SYMPTOM_CHECK_REPORT — the results are ready; fetching the report closes the check
      const report = await api('GET', '/v1/chatbot/report', { checkId })
      printReport(report)
      return
    }

    const payload = buildAnswer(question)
    // the answer endpoint defines no language header, so the helper skips it here
    const { checkStatus } = await api('POST', '/v1/chatbot/answer', { checkId, body: payload, language: null })
    console.log(`${payload.answer ? 'Answered' : 'Skipped'} "${question.text}" (${question.type})`)
    if (checkStatus === 'FINISHED') return console.log('Check finished.')
  }
  throw new Error('Gave up after 200 questions — something is off.')
}

main().catch((error) => {
  console.error(error.message)
  process.exit(1)
})

Where next

Last updated on

On this page