LFF Assessment API · guides

Integrating with React

A complete client: signing in, rendering a schema-driven battery of fourteen forms in English or Yoruba, and submitting answers the API will accept.

Before you start

One idea decides the whole design of your client:

The forms are data, not code. Questions, choice lists and translations live in the database. An administrator can add a fifteenth form or a third language with no release. So do not build screens per form — build one renderer per question type. There are nine, and they cover every form that will ever exist.

ThingValue
Base URLhttps://api.sortingout.theoslogion.org/v1
AuthAuthorization: Bearer <token>
No token neededauth/register, auth/login, option-sets/{code}
Rate limit120/min; auth/* is 10/min

Responses and errors

Every success is wrapped in data, with meta alongside it:

{
  "data": { "code": "form-0", "title": "Personal Challenges", "sections": [ … ] },
  "meta": { "locale": "en" }
}

Every error is RFC 9457 application/problem+json:

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "errors": {
    "answers.0.value.text": ["The text field is required."]
  }
}

The errors object appears on 422s only, keyed by field path. Those paths map onto the request body you sent, so you can attach messages to the exact input that caused them.

The API client

One module handles the base URL, the token, the locale and error translation, so nothing else in your app has to think about them.

src/api/client.js
const BASE = import.meta.env.VITE_API_URL ?? 'https://api.sortingout.theoslogion.org/v1';

// Thrown for any non-2xx. Carries the parsed problem+json so callers can
// read `.errors` for field-level messages without re-parsing anything.
export class ApiError extends Error {
  constructor(problem, status, retryAfter) {
    super(problem?.detail || problem?.title || `Request failed (${status})`);
    this.name = 'ApiError';
    this.status = status;
    this.title = problem?.title;
    this.errors = problem?.errors ?? {};
    this.retryAfter = retryAfter;   // seconds, present on 429
  }

  // Field path -> first message, e.g. { 'answers.0.value.text': 'Required.' }
  fieldErrors() {
    return Object.fromEntries(
      Object.entries(this.errors).map(([field, msgs]) => [field, msgs[0]]),
    );
  }
}

let token = localStorage.getItem('lff.token');
export const setToken = (t) => {
  token = t;
  t ? localStorage.setItem('lff.token', t) : localStorage.removeItem('lff.token');
};
export const getToken = () => token;

export async function api(path, { method = 'GET', body, locale } = {}) {
  const url = new URL(BASE + path);
  // An explicit ?locale= beats every other signal, so pass it when the user
  // has picked a language in the UI rather than relying on their browser.
  if (locale) url.searchParams.set('locale', locale);

  const res = await fetch(url, {
    method,
    headers: {
      Accept: 'application/json',
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (res.status === 204) return null;

  const payload = await res.json().catch(() => null);

  if (!res.ok) {
    // 401 means the token is gone or expired — clear it so the app can
    // redirect to login instead of retrying forever.
    if (res.status === 401) setToken(null);
    throw new ApiError(payload, res.status, Number(res.headers.get('Retry-After')) || undefined);
  }

  return payload;
}

Registration

Registration collects a full profile. Five fields are choice fields and must be sent as option codes, not labels. Fetch the lists from the public option-set endpoint so the labels come back already translated.

FieldOption setExample codes
countryOfResidencecountriesnigeria
religionreligionschristianity, islam
maritalStatusmarital-statusessingle, married
sexsexesmale, female
previousAttendanceprevious-attendancesfirst-time
src/components/CountrySelect.jsx
import { useEffect, useState } from 'react';
import { api } from '../api/client';

export function OptionSelect({ setCode, value, onChange, locale, ...props }) {
  const [options, setOptions] = useState([]);

  useEffect(() => {
    let live = true;
    api(`/option-sets/${setCode}`, { locale })
      .then((r) => live && setOptions(r.data.options))
      .catch(() => live && setOptions([]));
    return () => { live = false; };
  }, [setCode, locale]);

  return (
    <select value={value ?? ''} onChange={(e) => onChange(e.target.value)} {...props}>
      <option value="">—</option>
      {options.map((o) => (
        <option key={o.code} value={o.code}>{o.label}</option>
      ))}
    </select>
  );
}

Then post the profile. Both camelCase and snake_case are accepted:

await api('/auth/register', {
  method: 'POST',
  body: {
    surname: 'Adebayo', otherNames: 'Grace', email, password,
    passwordConfirmation: password,
    dateOfBirth: '1990-01-01',
    homeAddress: '…', nationality: 'Nigerian', countryOfResidence: 'nigeria',
    homeTown: 'Lagos', religion: 'christianity',
    churchName: '…', churchAddress: '…',
    maritalStatus: 'single', sex: 'female', previousAttendance: 'first-time',
    securityQuestion: 'Favourite colour?', securityAnswer: 'blue',
    waiverAccepted: true,
    preferredLocale: 'yo',   // remembered; drives their language from now on
  },
});

Registration cannot create an administrator. The role field is ignored on this endpoint by design. Admins are promoted server-side.

Login and tokens

import { api, setToken } from '../api/client';

export async function login(email, password) {
  const { data } = await api('/auth/login', {
    method: 'POST',
    body: { email, password },
  });
  setToken(data.token);
  return data.user;
}

export async function logout() {
  await api('/auth/logout', { method: 'POST' });
  setToken(null);
}

On storage. The example uses localStorage for brevity. It is readable by any script on the page, so an XSS bug becomes a stolen session. These forms carry disclosures about abuse, health and family history — for production, prefer an httpOnly cookie set by your own backend-for-frontend, and treat localStorage as a prototype shortcut.

Language

The locale is resolved in this order, first match wins:

  1. ?locale=yo on the request
  2. the Accept-Language header
  3. the signed-in user's stored preferredLocale
  4. the default, en

Because of step 3, a user who registered with preferredLocale: 'yo' gets Yoruba everywhere without you appending anything. Pass ?locale= only when they actively switch language in your UI. Every localized response carries a Content-Language header and meta.locale so you can confirm what you actually received.

What actually changes

The same request, twice. Titles, section headings, question labels and option labels are all translated:

GET /v1/assessments/form-0/schema?locale=en

{
  "data": {
    "code": "form-0",
    "title": "General Information",
    "sections": [{
      "code": "f0-s1",
      "title": "Part 1 of 1 (General Information)",
      "questions": [{
        "code": "f0-duration",
        "type": "select",
        "label": "How long have you had this challenge?",
        "options": [
          { "code": "lt-1", "label": "Under 1 year" },
          { "code": "1",    "label": "1 year" },
          { "code": "2",    "label": "2 years" }
        ]
      }]
    }]
  },
  "meta": { "locale": "en" }
}
GET /v1/assessments/form-0/schema?locale=yo

{
  "data": {
    "code": "form-0",
    "title": "Àlàyé Gbogbogbò",
    "sections": [{
      "code": "f0-s1",
      "title": "Apá 1 nínú 1 (Àlàyé Gbogbogbò)",
      "questions": [{
        "code": "f0-duration",
        "type": "select",
        "label": "Ìgbà wo ni o ti ní ìṣòro yìí?",
        "options": [
          { "code": "lt-1", "label": "Kò Tíì Pé Ọdún Kan" },
          { "code": "1",    "label": "Ọdún 1" },
          { "code": "2",    "label": "Ọdún 2" }
        ]
      }]
    }]
  },
  "meta": { "locale": "yo" }
}

Every code is identical in both. form-0, f0-duration and lt-1 do not move. Only the human-facing strings change.

That is why you send codes and never labels: an answer saved in Yoruba reads correctly in English for the review team, and switching language mid-form cannot invalidate anything already stored.

Option sets translate too

The public registration dropdowns behave the same way, so a signup form can be Yoruba before an account exists:

GET /v1/option-sets/marital-statuses?locale=yo

{
  "data": {
    "code": "marital-statuses",
    "options": [
      { "code": "single",    "label": "Àpọ́n" },
      { "code": "married",   "label": "Onígbéyàwó" },
      { "code": "widow",     "label": "Opó (Obìnrin)" },
      { "code": "widower",   "label": "Opó (Ọkùnrin)" },
      { "code": "divorced",  "label": "Ẹni Tí A Kọ̀sílẹ̀" }
    ]
  },
  "meta": { "locale": "yo" }
}

Render labels, store codes, and never build UI logic on a label. A label is translated content and may be corrected at any time; a code is a contract. if (label === 'Single') breaks the moment someone switches language or an editor fixes a typo.

Attempts

An instance is one person's attempt at the whole battery. Create it once, then read and write answers form by form.

const { data: instance } = await api('/instances', { method: 'POST' });
// instance.id -> "01kz20yry3kmntwf7112thrjr6"

const { data: list } = await api('/instances');   // paginated
// list is an array; pagination lives in meta.pagination
//   { page, perPage, total, lastPage }   ?perPage= is clamped to 100

The schema

Ask for a form's schema and render whatever comes back:

const { data: form } = await api('/assessments/form-0/schema', { locale: 'yo' });

The shape, trimmed to the parts you render:

{
  "code": "form-0",
  "title": "Personal Challenges",
  "sections": [
    {
      "code": "f0-a",
      "title": "Your challenges",
      "questions": [
        {
          "code": "f0-challenge",
          "type": "textarea",
          "required": true,
          "label": "Please summarise your challenges or problems",
          "help": null,
          "group": "challenges",   // present -> repeatable, render as rows
          "maxRows": 5
        }
      ]
    }
  ]
}

Fields a question may carry, depending on its type:

FieldMeaning
typeWhich renderer to use. The only thing that decides your payload shape.
options[{ code, label }] for choice types
periodsAllowed period codes, e.g. ["past","present"]
answersAllowed answers for yes_no_unknown
allowsDetailWhether yes_no_unknown takes free text on “yes”
group, maxRowsRepeatable — see below
visibleWhenConditional — see below

The nine question types

This table is the contract. Given type, send exactly this. Get it right once per type and every form works.

typeSend
text, textarea{ question, value: { text } }
select{ question, option: "code" }
multi_select{ question, options: ["a","b"] }
yes_no{ question, value: { answer: true } }
yes_no_unknown{ question, value: { answer: "yes", detail } } — detail only when "yes"
yes_no_period{ question, value: { answer: true, periods: ["past"] } } — periods empty unless answer is true
matrix{ question, options: ["self","father"] }
matrix_period{ question, cells: [{ option: "self", periods: ["past"] }] }

Turning UI state into an entry:

src/lib/payload.js
// `state` is whatever your input component keeps for one question.
// Returns the entry body — the caller adds `question` and any `row`.
export function toEntryBody(question, state) {
  switch (question.type) {
    case 'text':
    case 'textarea':
      return { value: { text: state ?? '' } };

    case 'select':
      return { option: state };                    // a single option code

    case 'multi_select':
    case 'matrix':
      return { options: state ?? [] };             // array of option codes

    case 'yes_no':
      return { value: { answer: Boolean(state) } };

    case 'yes_no_unknown': {
      const { answer, detail } = state ?? {};
      // detail is rejected unless the answer is "yes"
      return { value: answer === 'yes' && detail ? { answer, detail } : { answer } };
    }

    case 'yes_no_period': {
      const { answer, periods = [] } = state ?? {};
      // periods must be empty unless answer is true
      return { value: { answer: Boolean(answer), periods: answer ? periods : [] } };
    }

    case 'matrix_period':
      // state: { self: ['past'], 'father-side': ['past','present'] }
      return {
        cells: Object.entries(state ?? {})
          .filter(([, periods]) => periods?.length)
          .map(([option, periods]) => ({ option, periods })),
      };

    default:
      throw new Error(`Unhandled question type: ${question.type}`);
  }
}

Throw on unknown types rather than ignoring them. If an administrator introduces a type your build predates, a loud error in staging is far better than a question that silently renders nothing and drops a respondent's answer.

Repeatable rows

A question carrying group and maxRows repeats. Every shape above moves inside a rows array, each row carrying its own 0-based row index below maxRows:

{
  "question": "f0-challenge",
  "rows": [
    { "row": 0, "value": { "text": "…" } },
    { "row": 1, "value": { "text": "…" } }
  ]
}

Questions sharing a group are columns of one table. Row 3 of f0-challenge and row 3 of f0-duration describe the same thing, so render them side by side rather than as separate lists:

const groups = section.questions.reduce((acc, q) => {
  const key = q.group ?? q.code;      // ungrouped questions stand alone
  (acc[key] ??= []).push(q);
  return acc;
}, {});

Conditional logic

A question with visibleWhen stays hidden until its rule is met. There are four forms, and the rule always names a controlling question:

RuleVisible when
{ question, answered: true }the controlling question has any answer
{ question, equals: true }its answer equals that value…
{ question, equals: "yes" }…for scalars, or an option code for choice questions
{ question, includes: "head" }a multi-choice answer contains that option code

An unanswered controlling question means hidden. Follow-ups stay closed until the trigger exists.

src/lib/visibility.js
// `answers` is your UI state, keyed by question code.
export function isVisible(question, answers) {
  const rule = question.visibleWhen;
  if (!rule?.question) return true;

  const state = answers[rule.question];
  if (state === undefined || state === null || state === '') return false;

  if ('answered' in rule) return Boolean(rule.answered);

  const selected = asCodes(state);
  if ('includes' in rule) return selected.includes(rule.includes);

  if ('equals' in rule) {
    if (selected.length) return selected.includes(rule.equals);
    const scalar = typeof state === 'object' ? state.answer : state;
    return scalar === rule.equals;
  }

  return true;
}

// Normalises the several UI shapes down to a list of chosen option codes.
function asCodes(state) {
  if (Array.isArray(state)) return state;                       // multi_select, matrix
  if (typeof state === 'string') return [state];                // select
  if (state && typeof state === 'object' && !('answer' in state)) {
    return Object.keys(state);                                  // matrix_period
  }
  return [];
}

Hidden means do not send. Answering a hidden question is a 422. Prune state for questions that become invisible before you save, or the whole request fails.

Visibility is judged after the write, so a trigger and its dependent can safely go in the same request. And if a later edit strands an answer, the API prunes it for you.

Saving answers

PUT replaces the answers for one form on one instance. Send the whole form each time — it is a replace, not a merge.

src/lib/save.js
import { api } from '../api/client';
import { toEntryBody } from './payload';
import { isVisible } from './visibility';

export function buildAnswers(form, answers) {
  const out = [];

  for (const section of form.sections) {
    for (const q of section.questions) {
      if (!isVisible(q, answers)) continue;          // never send hidden ones

      const state = answers[q.code];
      if (state === undefined) continue;             // untouched: leave it out

      if (q.group && q.maxRows) {
        const rows = Object.entries(state)
          .filter(([, v]) => !isEmpty(v))
          .map(([row, v]) => ({ row: Number(row), ...toEntryBody(q, v) }));
        if (rows.length) out.push({ question: q.code, rows });
      } else if (!isEmpty(state)) {
        out.push({ question: q.code, ...toEntryBody(q, state) });
      }
    }
  }

  return out;
}

const isEmpty = (v) =>
  v === undefined || v === null || v === '' ||
  (Array.isArray(v) && v.length === 0) ||
  (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0);

export function saveForm(instanceId, formCode, form, answers) {
  return api(`/instances/${instanceId}/assessments/${formCode}/answers`, {
    method: 'PUT',
    body: { answers: buildAnswers(form, answers) },
  });
}

Reading answers back

Fetch saved answers to resume a part-finished attempt. One detail will trip you up if you assume the response mirrors your request:

Value-carrying answers come back wrapped in rows, even when the question is not repeatable. Send { value: { answer: true } } for an ungrouped yes_no and you will read back { rows: [{ row: 0, value: { answer: true } }] }. Choice answers (options) stay flat. Both forms are accepted on write, so it round-trips — but your parser must handle both.

// Collapses either shape into your UI state.
export function fromEntry(question, entry) {
  const repeatable = Boolean(question.group && question.maxRows);

  if (entry.rows) {
    const byRow = Object.fromEntries(
      entry.rows.map((r) => [r.row, readBody(question, r)]),
    );
    return repeatable ? byRow : byRow[0];   // unwrap the phantom single row
  }

  return readBody(question, entry);
}

function readBody(question, body) {
  switch (question.type) {
    case 'text':
    case 'textarea':      return body.value?.text ?? '';
    case 'select':        return body.option ?? null;
    case 'multi_select':
    case 'matrix':        return body.options ?? [];
    case 'yes_no':        return body.value?.answer ?? null;
    case 'yes_no_unknown':
    case 'yes_no_period': return body.value ?? {};
    case 'matrix_period':
      return Object.fromEntries((body.cells ?? []).map((c) => [c.option, c.periods]));
    default:
      throw new Error(`Unhandled question type: ${question.type}`);
  }
}

Submitting

Submitting finalises the attempt. The API re-checks every required, visible question across the battery and refuses if any is unanswered:

import { ApiError } from '../api/client';

try {
  await api(`/instances/${instanceId}/submit`, { method: 'POST' });
} catch (e) {
  if (e instanceof ApiError && e.status === 422) {
    // e.detail names the offending questions, e.g.
    //   "Required questions are unanswered: f0-challenge."
    showBlockingErrors(e.detail, e.fieldErrors());
  } else {
    throw e;
  }
}

On success the respondent gets a confirmation email and the review team is alerted. Both are queued, so a slow mail server never delays your request.

Check before you let them press submit. Run isVisible over every required question and disable the button while any is unanswered. A 422 at the end of a fourteen-form battery is a miserable way to discover a missed field.

Handling the data

Read this section even if you skim the rest. The answers your client collects are disclosures about abuse, sexual history, addiction, mental health and family occult involvement, often written by someone in distress, frequently on a borrowed phone.

Everything below is ordinary React practice that happens to be wrong here. None of it is exotic — which is exactly why it needs saying.

Never send answers to third parties

Error and session tools capture request bodies by default. Wire one up without thinking and you will ship a person's abuse disclosure to a vendor.

// Sentry: drop request bodies for this API entirely.
Sentry.init({
  dsn: '…',
  sendDefaultPii: false,
  beforeBreadcrumb(crumb) {
    if (crumb.category === 'fetch' || crumb.category === 'xhr') {
      delete crumb.data?.body;            // never record what was submitted
    }
    return crumb;
  },
  beforeSend(event) {
    if (event.request) delete event.request.data;
    return event;
  },
});
  • Mask every input in session replay (LogRocket, FullStory, Clarity) or do not run it on these screens at all
  • No console.log(answers) — browser consoles get screenshotted into support tickets
  • Do not put question codes or answers into analytics events; even completed:f6-sexual-history discloses something

Clean up after the session

The API already sends Cache-Control: no-cache, private, so nothing lands in a shared proxy. What survives is whatever you keep.

export async function logout() {
  try {
    await api('/auth/logout', { method: 'POST' });   // revokes the token server-side
  } finally {
    setToken(null);
    // Draft autosave is the easy thing to forget: a half-finished form left
    // in localStorage outlives the session on a shared device.
    for (const k of Object.keys(localStorage)) {
      if (k.startsWith('lff.')) localStorage.removeItem(k);
    }
    queryClient?.clear();          // and anything cached in memory
  }
}

If you autosave drafts, prefer sessionStorage over localStorage — it dies with the tab — and offer a visible “finish later” that saves to the server instead of the device.

Tokens

  • Never in a URL. Query strings end up in browser history, server logs and Referer headers. Always the Authorization header.
  • Clear on 401 — the client above already does.
  • Call /auth/logout, do not just drop the token locally; that leaves it valid server-side.
  • Consider a short idle timeout on shared devices.

Respect the rate limit

120 requests/min authenticated, 10/min on auth/*. Autosave on every keystroke will hit that. Debounce, and handle 429 rather than retrying in a loop:

if (e.status === 429) {
  // Retry-After is in seconds.
  const wait = Number(e.retryAfter ?? 5) * 1000;
  setTimeout(retry, wait);
}

What the server already does

ControlDetail
HTTPSEnforced; plain HTTP is redirected, HSTS is set
Cachingno-cache, private on every response
Headersnosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer
OwnershipAn instance is readable only by the user who owns it — others get 404, not 403, so ids cannot be probed
PrivilegeRegistration cannot create an admin; the admin surface is role-gated
Passwordsbcrypt; never returned by any endpoint

None of that protects data once it reaches your client. The server can refuse to leak; only your code decides whether an answer ends up in a log aggregator, a replay tool, or a stale localStorage key on a family computer.

Checklist

  • Render from type, never from a hard-coded form layout
  • Throw on unknown types rather than skipping them silently
  • Group by group and render shared rows as one table
  • Re-evaluate visibleWhen on every change, and never send hidden answers
  • Send option codes, never labels — labels change with language
  • Handle both the flat and rows-wrapped read shapes
  • Map 422 errors paths onto your inputs
  • Clear the token on 401 and send the user to login
  • Move tokens out of localStorage before production
  • Strip request bodies from error reporting, and mask inputs in session replay
  • Clear drafts and caches on logout, and call /auth/logout to revoke server-side
  • Debounce autosave and handle 429 instead of retrying blindly

Endpoint-by-endpoint detail, with request and response schemas you can call from the browser, is in the OpenAPI reference.