This guide is for frontend developers integrating with the Scholia backend.
- Local:
http://localhost:8080 - API prefix:
/api/v1
Example full URL:
http://localhost:8080/api/v1/books
All endpoints use the same envelope.
{
"success": true,
"data": {},
"meta": {
"limit": 20,
"offset": 0,
"count": 20
}
}{
"success": false,
"error": {
"message": "Missing or invalid credentials"
}
}Notes:
datacan be an object or array.metaappears on endpoints that support pagination.- Standard HTTP status codes are used (200, 201, 400, 401, 403, 404, 500).
Auth is Supabase-backed. Users sign in on the client with any enabled provider and the API verifies the resulting access token locally against the project's public signing keys — no per-request round trip, and no credential held by this server.
Authorization: Bearer <token>— a Supabase access token, or an API keyX-API-Key: <token>— an API key
GET /api/v1/auth/me— returnsauthenticated:falsewith HTTP 200 when signed out, so the frontend can call it unconditionally
Authentication is handled entirely by Supabase on the client. Sign in with
supabase-js using any enabled provider — email/password, Google, magic link,
passkeys — then send the resulting access token:
Authorization: Bearer <access_token>
supabase-js refreshes the token automatically. This API has no sign-up or
sign-in endpoint by design: routing credentials through it would mean managing
refresh tokens server-side for no benefit. It only verifies tokens, against
public keys, with no network round trip per request.
Enabling a new provider needs no backend change.
For scripts and integrations that cannot run Supabase's refresh cycle.
GET /api/v1/auth/api-keys— list your keys (tokens are never returned)POST /api/v1/auth/api-keys— mint one; the plaintext token is in the response onceDELETE /api/v1/auth/api-keys/{key_id}— revoke, effective immediately
Send keys as Authorization: Bearer … or X-API-Key: …. Two deliberate
restrictions: a key cannot manage keys (those endpoints require a session), and
a key cannot hold scopes beyond read and write.
The backend includes a small CORS layer so browser-based frontend apps can call it from local dev servers.
http://localhost:3000http://127.0.0.1:3000http://localhost:4173http://127.0.0.1:4173http://localhost:5173http://127.0.0.1:5173http://localhost:8080http://127.0.0.1:8080
Set SCHOLIA_CORS_ORIGINS to a comma-separated list.
Example:
export SCHOLIA_CORS_ORIGINS="http://localhost:3000,http://localhost:5173"AuthorizationandX-API-Keyheaders are allowed.- Preflight
OPTIONSrequests are handled automatically. - If you need to allow every origin temporarily, set
SCHOLIA_CORS_ORIGINS=*.
For paginated endpoints, use query params:
limit(positive integer, capped per endpoint)offset(0 or more)
Example:
GET /api/v1/books?limit=20&offset=40
Pagination metadata is returned in meta.
- Verse IDs are normalized at the boundary (for example
gen.1.1->GEN.1.1). - Book slugs are normalized to lowercase.
- Generic IDs are trimmed.
This helps clients send user-entered IDs without perfect casing.
GET /verse/{osis_id}GET /verse/{osis_id}/contextGET /verse/{osis_id}/cross-referencesGET /analysis/{osis_id}
GET /verse/{osis_id}, GET /verse/{osis_id}/context, GET /verse/{osis_id}/cross-references, and GET /analysis/{osis_id} now accept either a single verse or a verse range.
Examples:
- Single verse:
GET /api/v1/verse/BSB.MAT.1.1 - Human-readable single verse:
GET /api/v1/verse/John%201:1 - Verse range:
GET /api/v1/verse/John%201:1-5
Single-verse requests keep the existing response shape.
Range requests:
/verse/{osis_id}returnsreference,start,end, andverses./verse/{osis_id}/contextreturns the same range fields plus aggregated entities (people,groups,locations,events,lexicon,notes,cross_references) andanalysis_by_verse. It also includes legacy keysverseand flattenedanalysisfor backward compatibility./verse/{osis_id}/cross-referencesreturns range fields pluscross_references, and includesverse_idfor backward compatibility./analysis/{osis_id}returns range fields plusanalysis_by_verse, and also includes legacy keysverseand flattenedanalysis.
GET /search?q=...&type=all|verse|entity&limit=...&offset=...GET /suggest?q=...&limit=...&offset=...
GET /lexicon/{strongs_id}?limit=...&offset=...
This route now returns the lexicon entry plus an occurrences array from verse analysis. That lets the frontend show both the meaning and the actual word usage.
GET /location/{location_id}GET /location/{location_id}/verses?limit=...&offset=...
GET /person/{person_id}GET /person/{person_id}/verses?limit=...&offset=...GET /group/{group_id}GET /group/{group_id}/members?limit=...&offset=...GET /event/{event_id}GET /event/{event_id}/participants?limit=...&offset=...
GET /books?limit=...&offset=...GET /books/{slug}/chaptersGET /timeline?limit=...&offset=...
GET /resolve/{rec_id}
- Notes are user-owned. Authenticated users only see their own notes.
GET /notes?limit=...&offset=...(auth required)GET /notes/{note_id}(auth required)POST /notes(auth required)PUT /notes/{note_id}(auth required)DELETE /notes/{note_id}(auth required)- Notes shown inside
/verse/{osis_id}/contextare also filtered to the authenticated user.
Below are practical TypeScript shapes for the most-used response payloads.
type ApiError = { message: string };
type ApiMeta = {
limit?: number;
offset?: number;
count?: number;
verses_count?: number;
entities_count?: number;
notes_count?: number;
cross_references_count?: number;
people_count?: number;
groups_count?: number;
};
type ApiEnvelope<T> = {
success: boolean;
data?: T;
error?: ApiError;
meta?: ApiMeta;
};type Verse = {
id: string;
translation: string;
book: string;
chapter: number;
verse: number;
text: string;
};
type VerseRange = {
reference: string;
start: string;
end: string;
verses: Verse[];
};
type VerseRangeCrossRefs = {
reference: string;
start: string;
end: string;
verse_id?: string;
cross_references: string[];
};
type VerseRangeAnalysis = {
reference: string;
start: string;
end: string;
verse?: Verse;
verses: Verse[];
analysis: VerseAnalysisToken[];
analysis_by_verse: Record<string, VerseAnalysisToken[]>;
};
type VerseRangeContext = {
reference: string;
start: string;
end: string;
verse?: Verse;
verses: Verse[];
analysis: VerseAnalysisToken[];
analysis_by_verse: Record<string, VerseAnalysisToken[]>;
lexicon: LexiconEntry[];
locations: Location[];
people: Person[];
groups: Group[];
events: Event[];
setting?: VerseSetting;
world?: WorldContext;
articles: DictionaryArticle[];
cross_references: string[];
notes: Note[];
};
type Note = {
id: number;
title: string;
main_reference: string;
content: string;
verse_ids?: string[];
created_at?: string;
updated_at?: string;
};
type Person = {
id: string;
name: string;
lookup_name: string;
gender: string;
birth_year: number;
death_year: number;
dictionary_text: string;
slug: string;
also_called?: string; // comma separated alternate names
birth_place?: string;
death_place?: string;
relations?: PersonRelation[];
};
type PersonRelation = {
relation: "father" | "mother" | "child" | "sibling" | "partner";
id: string;
name: string;
};
type Group = { id: string; name: string };
type Event = {
id: string;
title: string;
start_date: string;
duration: string;
sort_key: number;
notes?: string;
part_of?: { id: string; title: string }; // the larger episode
follows?: { id: string; title: string }; // the event before it
locations?: { id: string; name: string }[];
};
// When and where the passage sits. Every field is optional: the corpus dates
// about 90% of verses and names a writing place for only eleven books.
type VerseSetting = {
year_num?: number;
era?: {
id: string;
name: string;
start_year: number;
end_year: number;
summary: string;
};
book?: {
name: string;
division?: string;
testament?: string;
year_written?: string;
place_written?: string;
writers?: string[];
};
// "verse" when the verse carries its own date, "book" when it does not and
// the era was inferred from the rest of its book.
era_source?: "verse" | "book";
};
// The world outside the passage: who ruled the surrounding powers during its
// era, what was happening elsewhere, and background pieces on the period.
//
// Joined by ERA, not by year. The verse years use a traditional chronology and
// these dates use the conventional one; they disagree by up to fifty years in
// the Old Testament. `year_aligned` is true only where the two can be compared
// directly (the New Testament), and only then are `current`/`nearby` set.
type WorldContext = {
era_id: string;
era_name: string;
year_aligned: boolean;
rulers: {
id: string;
name: string;
title: string;
region: string;
start_year?: number;
end_year?: number;
note?: string;
current: boolean;
}[];
events: {
id: string;
title: string;
region: string;
year?: number;
summary?: string;
nearby: boolean;
}[];
backgrounds: { id: string; region: string; title: string; body: string }[];
};
// Public-domain reference articles for terms the passage uses (Easton's, 1897).
// Articles about the verse's own people and places are excluded: those already
// travel on the person and location records.
type DictionaryArticle = {
id: string;
term: string;
body: string;
source: string;
kind: string;
};
type Location = {
id: string;
name: string;
modern_name: string;
latitude?: number;
longitude?: number;
feature_type: string;
geometry_type: string;
image_file: string;
image_url: string;
credit_url: string;
image_author: string;
source_info: string;
};
type LexiconData = LexiconEntry & {
occurrences: LexiconOccurrence[];
};
type LexiconOccurrence = {
verse_id: string;
word_order: number;
surface_word: string;
english_gloss: string;
morph_code: string;
manuscript_type: string;
morphology?: MorphologyEntry;
};type LexiconResponse = ApiEnvelope<LexiconData>;
const example: LexiconData = {
strongs_id: "G3056",
word: "λόγος",
transliteration: "logos",
definition: "word, saying, message, discourse",
occurrences: [
{
verse_id: "1CO.1.18",
word_order: 2,
surface_word: "λόγος",
english_gloss: "message",
morph_code: "N-NSM",
manuscript_type: "NKO",
morphology: {
code: "N-NSM",
short_def: "Noun Nominative Singular Masculine",
long_exp: "a male PERSON OR THING that is doing something",
},
},
],
};Frontend usage pattern:
- Render
wordandtransliterationin the header. - Render
definitionas the main gloss. - Render
occurrencesas a list or table of actual verse hits. - Use
surface_word+english_glossto show word-by-word meaning, not just dictionary meaning.
// GET /books
type BooksData = Book[];
type Book = {
id: string;
osis_name: string;
book_name: string;
testament: string;
book_order: number;
slug: string;
};
// GET /books/{slug}/chapters
type BookChaptersData = {
book: Book;
chapter_count: number;
chapters: Chapter[];
};
type Chapter = {
id: string;
book_id: string;
osis_ref: string;
chapter_num: number;
};
// GET /verse/{osis_id}
type VerseData = Verse;
// GET /verse/{osis_id}/cross-references
type VerseCrossRefsData = {
verse_id: string;
cross_references: string[];
};
type VerseCrossRefsRangeData = VerseRangeCrossRefs;
// GET /analysis/{osis_id}
type VerseAnalysisData = {
verse: Verse;
analysis: VerseAnalysisToken[];
};
type VerseAnalysisRangeData = VerseRangeAnalysis;
type VerseAnalysisToken = {
word_order: number;
surface_word: string;
english_gloss: string;
strongs_id: string;
morph_code: string;
manuscript_type: string;
lexicon?: LexiconEntry;
morphology?: MorphologyEntry;
};
type LexiconEntry = {
strongs_id: string;
word: string;
transliteration: string;
definition: string;
};
type MorphologyEntry = {
code: string;
short_def: string;
long_exp: string;
};
// GET /verse/{osis_id}/context
type VerseContextData = {
verse: Verse;
analysis: VerseAnalysisToken[];
lexicon: LexiconEntry[];
locations: Location[];
people: Person[];
groups: Group[];
events: Event[];
setting?: VerseSetting;
world?: WorldContext;
articles: DictionaryArticle[];
cross_references: string[];
notes: Note[];
};
type VerseContextRangeData = VerseRangeContext;
// GET /search
type SearchData = {
query: string;
type: "all" | "verse" | "entity";
verses?: SearchVerseResult[];
entities?: SearchEntityResult[];
};
type SearchVerseResult = Verse;
type SearchEntityResult = {
type: "person" | "location" | "event";
id: string;
name: string;
extra?: string;
};
// GET /suggest
type SuggestData = {
query: string;
suggestions: Suggestion[];
};
type Suggestion = {
type: "person" | "location" | "lexicon" | "event";
id: string;
value: string;
};
// GET /event/{event_id}/participants
type EventParticipantsData = {
event_id: string;
participants: {
people: Person[];
groups: Group[];
};
};
// GET /auth/me
type AuthMeData =
| { authenticated: false }
| {
type: "api-key";
user_id: string;
key_id: string;
subject: string;
display_name?: string;
scopes: string[];
authenticated: true;
authentication: "api-key";
};{
"title": "Sermon notes",
"main_reference": "GEN.1.1",
"content": "In the beginning...",
"verse_ids": ["GEN.1.1", "JHN.1.1"]
}verse_ids supports both single references and ranges. Examples:
"JHN.1.1""John 1:1""John 1:1-5"
Ranges are expanded server-side into individual verse IDs before persistence.
Same shape as create.
Use one HTTP helper to consistently handle auth and envelope parsing.
const API_BASE = "http://localhost:8080/api/v1";
type ApiEnvelope<T> = {
success: boolean;
data?: T;
error?: { message: string };
meta?: { limit?: number; offset?: number; count?: number };
};
async function apiFetch<T>(
path: string,
init: RequestInit = {},
apiKey?: string
): Promise<ApiEnvelope<T>> {
const headers = new Headers(init.headers || {});
headers.set("Content-Type", "application/json");
if (apiKey) headers.set("X-API-Key", apiKey);
const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
const json = (await res.json()) as ApiEnvelope<T>;
if (!res.ok || !json.success) {
const message = json.error?.message || `Request failed (${res.status})`;
throw new Error(message);
}
return json;
}Scholia uses two databases with deliberately different lifecycles:
| Data | Store | Lifecycle |
|---|---|---|
| Bible corpus, lexicon, geography, history | Read-only SQLite baked into the image | Disposable — rebuilt from data/ by cmd/seed |
| Accounts, API keys, notes | Supabase Postgres | Durable — survives every deploy |
These previously shared one SQLite file, which was also committed to git.
Rebuilding the corpus — or just switching branches — destroyed user accounts and
notes. Keeping them apart is what fixes that. data/bible.db is now gitignored
and generated, never committed.
Required environment (see .env.example for the annotated version):
DATABASE_URL=… # Supabase Postgres, session pooler
SUPABASE_URL=… # https://<ref>.supabase.coThat is the whole server configuration. No Supabase API key is required: the API only verifies tokens, using public keys. The publishable key belongs in your frontend; the secret key is not used at all.
Full setup — project creation, applying migrations/0001_init.sql, enabling
asymmetric JWT signing keys, and creating the first admin — is in
docs/supabase-setup.md.
The project must use asymmetric JWT signing keys. Access tokens are verified locally against the project's public keys, which is what keeps authentication free of a network round trip per request; a project still on the legacy shared HS256 secret publishes no usable public key and every token will fail to verify.
# Public read
curl -s "http://localhost:8080/api/v1/books?limit=2&offset=0" | jq .
# Auth session (anonymous)
curl -s "http://localhost:8080/api/v1/auth/me" | jq .
# Auth session (signed in). TOKEN is a Supabase access token —
# `supabase.auth.getSession()` in the browser, or the redemption response below.
curl -s "http://localhost:8080/api/v1/auth/me" -H "Authorization: Bearer $TOKEN" | jq .
# Protected write
curl -s -X POST "http://localhost:8080/api/v1/notes" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Demo","main_reference":"GEN.1.1","content":"...","verse_ids":["GEN.1.1"]}' | jq .
# Mint an API key for a script (session auth required)
curl -s -X POST "http://localhost:8080/api/v1/auth/api-keys" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"label":"my script","scopes":["read"]}' | jq .
# Use that key (the token is only shown once, at creation)
curl -s "http://localhost:8080/api/v1/notes" -H "X-API-Key: sk_scholia_..." | jq .
# Revoke it
curl -s -X DELETE "http://localhost:8080/api/v1/auth/api-keys/$KEY_ID" \
-H "Authorization: Bearer $TOKEN" | jq .This section summarizes all frontend-relevant changes introduced in the recent backend updates.
- Unified API envelope is now standard everywhere
- Frontend should always parse responses as
{ success, data, error, meta }. - Error handling should read
error.messageinstead of relying only on HTTP status.
- Verse IDs and slugs are normalized at request boundaries
- Inputs like
gen.1.1andGEN.1.1resolve consistently. - Human-readable references like
John 1:1are accepted by verse endpoints.
- Verse range support was added across core verse surfaces
- The following now accept single verse or range:
GET /api/v1/verse/{osis_id}GET /api/v1/verse/{osis_id}/contextGET /api/v1/verse/{osis_id}/cross-referencesGET /api/v1/analysis/{osis_id}
- Range examples include
John 1:1-5.
- Backward compatibility fields were preserved for range responses
- Context and analysis range payloads still include legacy
verseand flattenedanalysis. - Cross-reference range payloads include legacy
verse_id. - Existing single-verse UI code should keep working while range-capable UI is added.
- Notes became user-private and auth-scoped
- Notes are no longer globally shared.
- Authenticated users only see their own notes.
- Notes inside verse context are filtered by the current authenticated user.
- Notes read routes are no longer public
GET /notesandGET /notes/{note_id}now require auth withreadscope.- Existing frontend flows that loaded notes anonymously must now attach an API key.
- Range references in note payloads are now supported
verse_idscan include single references or ranges.- Ranges are expanded server-side into individual verse IDs before save.
- Breaking: authentication moved to Supabase, invites removed
- Sign-up and sign-in happen on the client via
supabase-jswith any enabled provider (email, Google, magic link, passkeys). This API has no sign-in route. - Send
Authorization: Bearer <access_token>;supabase-jshandles refresh. - The old permanent API key and the invite-code flow are gone, along with
POST /api/v1/auth/exchange-codeandPOST /api/v1/admin/invites. - Access is open: anyone signed in can read and write their own notes. Notes stay private to their owner.
- API keys are now self-service
GET/POST /api/v1/auth/api-keys,DELETE /api/v1/auth/api-keys/{key_id}.- For scripts that cannot run a refresh cycle. Session-authenticated only; a key cannot mint or revoke keys.
- Lexicon endpoint now includes usage occurrences
GET /api/v1/lexicon/{strongs_id}returns entry data plusoccurrencesfrom verse analysis.- Frontend can render dictionary meaning and contextual usage from one request.
- Ensure all API calls parse the shared envelope and show
error.messageon failure. - Install
supabase-jsand initialise it with your project URL and publishable key (sb_publishable_…). Never put the secret key in the frontend. - Replace the stored API key with a Supabase session. Build sign-in with
whichever providers you enable —
signInWithPassword,signInWithOAuth({provider:'google'}),signInWithOtpfor magic links. - Attach
Authorization: Bearer ${session.access_token}to every authenticated request, reading the session fromsupabase.auth.getSession()so you always get the refreshed token. - Subscribe to
supabase.auth.onAuthStateChangeto react to sign-in, sign-out and token refresh rather than caching the token yourself. - Add auth bootstrap on app load using
GET /api/v1/auth/me. It returnsauthenticated:falsewith HTTP 200 when signed out, so it is safe to call unconditionally. - Update verse, context, cross-reference, and analysis screens to handle range payloads.
- Keep existing single-verse rendering path, but branch to range rendering when
versesexists. - Update note editor to allow range references in
verse_ids. - Note
created_at/updated_atare RFC 3339 (2026-07-28T06:11:36+02:00) rather than SQLite's2026-07-28 06:11:36.
- Detect range payload by checking
data.versesanddata.startplusdata.end. - If absent, fall back to legacy single-verse fields.
- For analysis/context range responses, prefer
analysis_by_versefor grouped rendering. - Use legacy flattened
analysisonly for backward-compatible components.