GET /api/v1The service index.
Every route with a one-line description, and every collection with the URL that pages through it. The cheapest possible first request.
/api/v1 ↗Everything on this site is available as JSON. No key, no sign-up, no rate limit worth mentioning. It is the same data the pages render from, so the two can never disagree: 31,102 verses of the Berean Standard Bible, the cross-reference corpus behind them, and every person, place, theme, commandment and Hebrew lexeme the text names.
There is no registration step, no dashboard, no tier and no token. Every endpoint is a GET, every one answers to an anonymous request, and Access-Control-Allow-Origin is *, so a browser can call this directly from a page you are writing, which is the one thing a keyless API is usually still closed to.
Nothing here is metered, so nothing here needs an account to meter it. If a client asks you for a bearer token, that is its own default rather than a requirement from this server; set its auth type to None.
GET /api/v1The service index.
Every route with a one-line description, and every collection with the URL that pages through it. The cheapest possible first request.
/api/v1 ↗GET /api/v1/statsWhat is in the knowledge base.
The catalogue: every collection with its document count, folder, example path and what it holds, plus graph totals and the commonest tags. Start here.
/api/v1/stats ↗GET /api/v1/listPage through any collection.
How you find out what exists rather than looking one thing up. Epochs and events default to chronological order, so one call answers "what happened, in sequence".
typetagfoldersortlimitoffsetGET /api/v1/searchConcept search across every collection.
Names and their variants (aliases, alternate spellings, Strong’s numbers, transliterations), plus descriptions, tags, type names and paths. All terms must match, so adding a word narrows the result. Filter by type: there are ~3,000 people and ~3,000 themes, so an unfiltered name query is a coin flip.
qtypetaglimitoffsetGET /api/v1/scriptureFull-text search over the text of Scripture.
For finding a passage you half-remember. Every word must appear in the verse; double quotes require them adjacent as a phrase. Covers all 31,102 verses less the handful the Berean edition omits on manuscript grounds. Those carry no text, so nothing can match them.
qbooktestamentlimitoffsetGET /api/v1/passage/{osis}A verse, a range, or a whole chapter.
John.3.16 for a verse, John.3.16-18 for a range, John.3 for a chapter, which returns a different shape. A verse comes with its context: speaker, the people and places it names, the themes it develops, and what points at it.
/api/v1/passage/John.3.16-18 ↗GET /api/v1/interlinear/{osis}The Hebrew, Aramaic or Greek behind the English, word by word.
John.3.16 for one verse or John.3 for a chapter. Like /xrefs, it takes no ranges. A reverse interlinear: entries are in English order and each carries the range of English words it produced, so a word in the translation traces back to the word it renders. Every entry has the original, its transliteration, its morphology spelled out and its Strong’s number; lexeme appears only where the bundle holds a document for that number, which is the words behind biblical names and their roots rather than all of Strong’s, and no Greek at all. Answers 503 until npm run build:interlinear has produced the artefact.
/api/v1/interlinear/John.3.16 ↗GET /api/v1/xrefs/{osis}Cross references, ordered by crowd support.
John.3.16 for one verse or John.3 for a whole chapter. Unlike /passage, this takes no ranges. The corpus records that two passages are connected but never why, so these carry no relationship type. Nothing below 20 votes is in the bundle at all, so a lower minVotes changes nothing.
minVoteslimitGET /api/v1/connectionsThe cross-reference corpus in aggregate.
The shape of the whole corpus rather than one passage: hub chapters, book-to-book flows, testament totals. Answers 503 until npm run build:connections has produced the artefact.
bookosislimitGET /api/v1/nearbyPlaces within a radius, nearest first.
Anchored on a place already here, or on a bare coordinate pair for a modern location that has no document. Distances are great-circle, in kilometres.
placelatlonradiuslimitGET /api/v1/changelogThe bundle log, and when the data was generated.
bundleGenerated is when the knowledge base was last ingested, and is the timestamp to cite or cache on. The generated field on /stats is this server’s index-build clock and changes on every restart.
/api/v1/changelog ↗GET /api/v1/entity/{path}Any document, with typed relations both ways.
Every collection, the relation vocabulary included. Relations are stored one-directional, so the inbound half is where a person’s parents live. Add body=true for the prose. A folder root such as entity/themes or entity/sources resolves too, and answers with the collection’s size and a sample instead of a document.
bodybacklinksGET /api/v1/family/{path}A person’s parents, children, siblings and spouses.
The corpus has no child-of edge anywhere, because parentage is recorded only on the parent’s document, so a person’s own parent-of relations are their children. This inverts it for you.
generationsGET /api/v1/graph/{path}A node neighbourhood, with its edges.
Everything within N hops, and the edges between them. Typed edges carry a predicate; plain document links carry only kind, so about a third of the edge list has none. Any depth on a well-connected node runs past limit quickly (themes/faith truncates at depth 2 even at the 600 maximum), and the response sets truncated when it did.
depthtypepredicatelimitGET /api/v1/vocabularyThe relation predicates and the canon table.
What develops, narrated-in, involves and stated-in mean, their inverses, and how many edges use each, plus all 66 books with their OSIS ids and chapter counts.
/api/v1/vocabulary ↗GET /api/v1/tagsThe tag vocabulary, with counts.
Tags cut across collections. Any of them can be passed to /list or /search.
limitoffsetGET /api/v1/statusIs the service up, and are the optional datasets built.
Live checks of every component, measured uptime over 24 hours, 30 and 90 days, and the availability target those are measured against. Check the datasets block before you call /interlinear or /connections: both answer 503 when their artefact is missing, and this reports that state in advance.
/api/v1/status ↗GET /api/v1/webhooksBe told when the data changes, instead of polling.
The one write on this API. Registers an https endpoint for a signed POST when the knowledge base is re-ingested. GET describes the events, the signature scheme and the limits; POST registers, and ?sandbox=true validates a registration without storing or delivering anything.
/api/v1/webhooks ↗GET /api/v1/openapi.jsonOpenAPI 3.1 description of everything above.
For tools that build themselves from a schema. Also served at /openapi.json, and every operation is named to match its MCP tool.
/api/v1/openapi.json ↗Three ways to make the same request. Nothing to install, no key to pass; the only header worth setting is a User-Agent naming who you are.
curl 'https://vineverse.bible/api/v1/passage/John.3.16'
# Failures carry a code. Branch on that, never on the message.
curl -i 'https://vineverse.bible/api/v1/passage/NotABook.9'
# HTTP/1.1 400 Bad Request
# x-request-id: …
# {"error":"Not a valid OSIS reference: NotABook.9","code":"invalid_reference",…}const res = await fetch('https://vineverse.bible/api/v1/search?q=faith&type=Theme')
if (!res.ok) {
const { code, error, requestId } = await res.json()
// code is the contract; error is the sentence, and its wording may change.
throw new Error(`${code}: ${error} (request ${requestId})`)
}
const { total, results } = await res.json()import json, urllib.request, urllib.error
try:
with urllib.request.urlopen("https://vineverse.bible/api/v1/family/people/ruth?generations=3") as r:
family = json.loads(r.read())
except urllib.error.HTTPError as err:
failure = json.loads(err.read())
raise SystemExit(f"{failure['code']}: {failure['error']}")Every operation in the OpenAPI document carries the same three as x-codeSamples, so a tool that renders the schema shows them per endpoint.
Every success carries a licence object alongside its data. Every failure has the same shape:
{
"error": "lat must be a number between -90 and 90, got: abc",
"code": "invalid_parameter",
"requestId": "syd1::iad1::mzzxj-1787480195318-fe03aaf10ec4",
"param": "lat",
"docs": "https://vineverse.bible/api#errors"
}error is written for a person and its wording may change. code is the contract, so branch on that. param names the offending parameter where one can be named, and available lists the legal values when the failure was an unrecognised enum, so an unknown type or sort tells you what it would have accepted.
missing_parameter · 400invalid_parameter · 400unknown_value · 400invalid_reference · 400not_found · 404dataset_unavailable · 503rate_limited · 429internal_error · 500The two retryable codes always carry Retry-After, in seconds. Honour it instead of inventing a backoff: the server knows how long the window has left and you do not.
Two codes are easy to confuse. invalid_reference means a Scripture reference did not parse; not_found means it parsed and nothing is filed under it. /passage/NotABook.9.9 and /passage/John.999.1 still read alike to a person, because the reference parser genuinely cannot tell them apart. A number that will not parse, such as a limit of abc, is treated as absent rather than as an error, so you get the default rather than an empty result.
X-Request-Id is on every response, successful or not, and requestId is inside every error body. Quote it if you report a problem: it is the id in our own logs, so it resolves to the actual request.
Every endpoint but one is a GET over a build artefact that changes only when the data is re-ingested, so replaying a call is safe by construction. You do not have to take that on trust: every response carries an ETag, and sending it back as If-None-Match answers 304 with no body when nothing has moved. If you stamp requests with an Idempotency-Key, it comes back to you.
curl -sD- -o/dev/null 'https://vineverse.bible/api/v1/stats' | grep -i etag
# etag: W/"19d3e81c83315a42"
curl -s -o/dev/null -w '%{http_code}\n' \
-H 'If-None-Match: W/"19d3e81c83315a42"' 'https://vineverse.bible/api/v1/stats'
# 304There is one ceiling: 600 requests a minute from a single address. It exists to stop abuse, not to sell you a tier, and there is no key that raises it. Walking the entire 31,102-verse corpus one verse at a time finishes inside an hour without touching it. It is published on every response as X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and in the RFC 9331 RateLimit spelling, so a client can learn the budget rather than guess at it. Crossing it answers 429 with Retry-After.
Every /api/v1 route answers with Access-Control-Allow-Origin: *, so this can be called straight from client-side JavaScript without a proxy in between.
The data changes when the knowledge base is re-ingested, which is rarely and unpredictably. Rather than poll /api/v1/changelog forever, register an endpoint and be told:
curl -X POST 'https://vineverse.bible/api/v1/webhooks' \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/hook","events":["data.updated"]}'
# {"subscription":{"id":"…","status":"active",…},"secret":"whsec_…"}The secret is shown once. It signs every delivery and authorises reading and deleting the subscription, as Authorization: Bearer. Each delivery carries X-VineVerse-Signature, which is sha256=<hex>, an HMAC-SHA256 of the raw request body. Hash the bytes you received, not a re-serialisation of the parsed JSON: key order and whitespace may not survive the round trip, and the digest will not match if they do not.
POST /api/v1/webhooks/{id}/test fires a real delivery, signed the same way, with test: true in the payload, so you can prove your receiver works without waiting for an ingest. ?sandbox=true on registration runs the same validation and returns the same shape, storing and delivering nothing.
The URL must be https and must resolve to a public address; redirects are not followed. Deliveries are retried three times, and ten consecutive failures disable a subscription. The outbound payload is described under webhooks in the OpenAPI document.
Two of them, one file each, no dependencies. Copy the one you want into your project. There is deliberately nothing to npm install: a package for sixteen keyless GET endpoints would be mostly release process and supply-chain surface wrapped around fetch.
Both handle the parts you would otherwise write twice: a failure becomes a typed error carrying code and requestId, 429 and 503 are retried on Retry-After, and repeated calls revalidate with ETag.
/**
* A VineVerse API client. Copy this file into your project; there is nothing to install.
*
* Deliberately not published to npm. The API is sixteen GET endpoints over a public corpus
* with no key and no auth, so a package would be mostly release process and supply-chain
* surface wrapped around `fetch`. This file has no dependencies, runs anywhere `fetch` does
* (Node 18+, Deno, Bun, browsers, Cloudflare Workers), and reads in a couple of minutes.
*
* What it adds over calling fetch yourself:
*
* - Failures throw a typed error carrying `code` and `requestId`, so you cannot forget to
* check a status you never looked at.
* - 429 and 503 are retried on the server's own `Retry-After`.
* - Repeated calls revalidate with `ETag` and come back as a 304 with no body.
*
* Licence: the code is yours to use however you like. The DATA is not. It is CC BY 4.0, and
* anything under `/sa/` is CC BY-SA 4.0, which is viral. Every response carries a `licence`
* object; if you show this data to anyone, the attribution travels with it.
*/
export const DEFAULT_BASE_URL = 'https://vineverse.bible/api/v1'
/** Every failure the API produces. See {base}/../api#errors for the full table. */
export type ErrorCode =
| 'missing_parameter'
| 'invalid_parameter'
| 'unknown_value'
| 'invalid_reference'
| 'not_found'
| 'dataset_unavailable'
| 'rate_limited'
| 'internal_error'
export type ErrorBody = {
error: string
code: ErrorCode
requestId?: string
param?: string
available?: string[]
docs?: string
}
/**
* Thrown for any non-2xx response.
*
* Branch on `code`. The `message` is written for a person and its wording may change.
*/
export class VineVerseError extends Error {
readonly code: ErrorCode
readonly status: number
readonly requestId?: string
readonly param?: string
readonly available?: string[]
constructor(status: number, body: ErrorBody) {
super(body.error)
this.name = 'VineVerseError'
this.status = status
this.code = body.code
this.requestId = body.requestId
this.param = body.param
this.available = body.available
}
/** True when waiting and trying again is a reasonable response. */
get retryable(): boolean {
return this.code === 'rate_limited' || this.code === 'dataset_unavailable'
}
}
export type ClientOptions = {
baseUrl?: string
/** Sent as User-Agent. Say who you are; it costs nothing and helps when something breaks. */
userAgent?: string
/** Attempts per request, including the first. Only retryable failures are retried. */
maxRetries?: number
/** Cache ETags and reuse them. Off for a long-lived process only if memory is precious. */
cache?: boolean
fetch?: typeof fetch
}
type CacheEntry = { etag: string; body: unknown }
export class VineVerse {
private readonly baseUrl: string
private readonly userAgent: string
private readonly maxRetries: number
private readonly cache: Map<string, CacheEntry> | null
private readonly fetchImpl: typeof fetch
constructor(options: ClientOptions = {}) {
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
this.userAgent = options.userAgent ?? 'vineverse-client/1'
this.maxRetries = options.maxRetries ?? 3
this.cache = options.cache === false ? null : new Map()
this.fetchImpl = options.fetch ?? globalThis.fetch
}
/**
* One request, with revalidation and retries.
*
* Everything else on this class is a thin wrapper around this, so behaviour is defined in
* exactly one place.
*/
async request<T = unknown>(path: string, query: Record<string, unknown> = {}): Promise<T> {
const url = new URL(this.baseUrl + path)
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value))
}
const key = url.toString()
const cached = this.cache?.get(key)
for (let attempt = 1; ; attempt++) {
const res = await this.fetchImpl(key, {
headers: {
accept: 'application/json',
'user-agent': this.userAgent,
...(cached ? { 'if-none-match': cached.etag } : {}),
},
})
// Nothing changed, and no body was sent. This is the whole point of keeping the tag.
if (res.status === 304 && cached) return cached.body as T
if (res.ok) {
const body = (await res.json()) as T
const etag = res.headers.get('etag')
if (etag && this.cache) this.cache.set(key, { etag, body })
return body
}
const body = (await res.json().catch(() => ({
error: `HTTP ${res.status}`,
code: 'internal_error' as const,
}))) as ErrorBody
const error = new VineVerseError(res.status, body)
if (!error.retryable || attempt >= this.maxRetries) throw error
// The server knows how long the window has left and we do not, so its Retry-After
// beats anything we could compute here.
const retryAfter = Number(res.headers.get('retry-after'))
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt
await new Promise((resolve) => setTimeout(resolve, waitMs))
}
}
// ---- Discovery -----------------------------------------------------------
/** The catalogue: every collection, its size, and what it holds. Call this first. */
stats() {
return this.request('/stats')
}
/** Is the service up, is it meeting its target, are the optional datasets present. */
status() {
return this.request('/status')
}
/** The relation predicate set and the 66-book canon table. */
vocabulary() {
return this.request('/vocabulary')
}
/** When the knowledge base was last ingested, and what changed. */
changelog() {
return this.request('/changelog')
}
/** The tag vocabulary with document counts. */
tags(options: { limit?: number; offset?: number } = {}) {
return this.request('/tags', options)
}
// ---- Finding things ------------------------------------------------------
/** Page through a collection. This is how you find out what exists. */
list(options: {
type?: string
tag?: string
folder?: string
sort?: 'title' | 'degree' | 'canonical' | 'chronological'
limit?: number
offset?: number
} = {}) {
return this.request('/list', options)
}
/** Concept search over names, descriptions, tags and paths. Filter by type: see /list. */
search(q: string, options: { type?: string; tag?: string; limit?: number; offset?: number } = {}) {
return this.request('/search', { q, ...options })
}
/** Full-text search across all 31,102 verses. Quote a phrase to require adjacency. */
scripture(
q: string,
options: { book?: string; testament?: 'OT' | 'NT'; limit?: number; offset?: number } = {},
) {
return this.request('/scripture', { q, ...options })
}
// ---- Scripture -----------------------------------------------------------
/** A verse, range or chapter: John.3.16, John.3.16-18, John.3. */
passage(osis: string) {
return this.request(`/passage/${encodeURIComponent(osis)}`)
}
/**
* The Hebrew, Aramaic or Greek behind a verse or chapter, word by word. No ranges.
*
* Throws `dataset_unavailable` on a deployment that has not built the artefact. Check
* `status().datasets.interlinear` first if you would sooner ask than catch.
*/
interlinear(osis: string) {
return this.request(`/interlinear/${encodeURIComponent(osis)}`)
}
/** Cross references for a verse or chapter, ordered by crowd support. No ranges. */
crossReferences(osis: string, options: { minVotes?: number; limit?: number } = {}) {
return this.request(`/xrefs/${encodeURIComponent(osis)}`, options)
}
/** The cross-reference corpus in aggregate: hub chapters, book-to-book flows. */
connections(options: { book?: string; osis?: string; limit?: number } = {}) {
return this.request('/connections', options)
}
// ---- The graph -----------------------------------------------------------
/** Any document, with typed relations in both directions. `path` is like "people/moses". */
entity(path: string, options: { body?: boolean; backlinks?: number } = {}) {
return this.request(`/entity/${path.replace(/^\/+/, '')}`, options)
}
/** A person's parents, children, siblings and spouses. */
family(path: string, options: { generations?: number } = {}) {
return this.request(`/family/${path.replace(/^\/+/, '')}`, options)
}
/** Everything within N hops of a document, with the edges between them. */
graph(
path: string,
options: { depth?: number; type?: string; predicate?: string; limit?: number } = {},
) {
return this.request(`/graph/${path.replace(/^\/+/, '')}`, options)
}
/** Biblical places within a radius, nearest first. Anchor on a place or a coordinate. */
nearby(options: { place?: string; lat?: number; lon?: number; radius?: number; limit?: number }) {
return this.request('/nearby', options)
}
// ---- Change notification -------------------------------------------------
/**
* Register an https endpoint to be told when the corpus changes.
*
* The only write on the whole API. Pass `sandbox: true` to validate the request without
* storing anything; the response has the same shape and no consequences.
*
* Keep the returned secret: it signs every delivery, and it is not shown again.
*/
async registerWebhook(input: {
url: string
events?: ('data.updated' | 'status.degraded')[]
sandbox?: boolean
}): Promise<{ subscription: { id: string; url: string }; secret: string }> {
const target = new URL(`${this.baseUrl}/webhooks`)
if (input.sandbox) target.searchParams.set('sandbox', 'true')
const res = await this.fetchImpl(target.toString(), {
method: 'POST',
headers: { 'content-type': 'application/json', 'user-agent': this.userAgent },
body: JSON.stringify({ url: input.url, events: input.events }),
})
const body = await res.json()
if (!res.ok) throw new VineVerseError(res.status, body as ErrorBody)
return body as { subscription: { id: string; url: string }; secret: string }
}
}
/**
* Verify a webhook delivery.
*
* Hash the RAW body you received, not a re-serialisation of the parsed JSON: key order and
* whitespace may not survive the round trip, and then the digest will not match.
*
* Needs Node's crypto. In a browser or Worker, use the Web Crypto equivalent.
*/
export async function verifySignature(rawBody: string, signature: string, secret: string): Promise<boolean> {
const { createHmac, timingSafeEqual } = await import('node:crypto')
const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
if (signature.length !== expected.length) return false
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
}
Standard library only. Run it directly to check a deployment end to end: python vineverse.py --selftest.
"""A VineVerse API client. Copy this file into your project; there is nothing to install.
Deliberately not published to PyPI. The API is sixteen GET endpoints over a public corpus
with no key and no auth, so a package would be mostly release process and supply-chain
surface wrapped around an HTTP call. This module uses only the standard library (no requests,
no httpx) and reads in a couple of minutes.
What it adds over calling urllib yourself:
- Failures raise a typed exception carrying ``code`` and ``request_id``, so you are not
unpacking an HTTPError by hand.
- 429 and 503 are retried on the server's own ``Retry-After``.
- Repeated calls revalidate with ``ETag`` and come back as a 304 with no body.
Licence: the code is yours to use however you like. The DATA is not. It is CC BY 4.0, and
anything under ``/sa/`` is CC BY-SA 4.0, which is viral. Every response carries a ``licence``
object; if you show this data to anyone, the attribution travels with it.
Self-check against a running server:
python clients/vineverse.py --selftest
python clients/vineverse.py --selftest --base-url http://localhost:3000/api/v1
"""
from __future__ import annotations
import hashlib
import hmac
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Iterable
DEFAULT_BASE_URL = "https://vineverse.bible/api/v1"
#: Every failure the API produces. See /api#errors for the full table.
ERROR_CODES = (
"missing_parameter",
"invalid_parameter",
"unknown_value",
"invalid_reference",
"not_found",
"dataset_unavailable",
"rate_limited",
"internal_error",
)
#: Waiting and trying again is a reasonable response to these, and to nothing else.
RETRYABLE = {"rate_limited", "dataset_unavailable"}
class VineVerseError(Exception):
"""Raised for any non-2xx response.
Branch on ``code``. The message is written for a person and its wording may change.
"""
def __init__(self, status: int, body: dict[str, Any]):
super().__init__(body.get("error", f"HTTP {status}"))
self.status = status
self.code = body.get("code", "internal_error")
self.request_id = body.get("requestId")
self.param = body.get("param")
self.available = body.get("available")
@property
def retryable(self) -> bool:
return self.code in RETRYABLE
class VineVerse:
def __init__(
self,
base_url: str = DEFAULT_BASE_URL,
user_agent: str = "vineverse-client/1",
max_retries: int = 3,
cache: bool = True,
timeout: float = 30.0,
):
self.base_url = base_url.rstrip("/")
self.user_agent = user_agent
self.max_retries = max_retries
self.timeout = timeout
# url -> (etag, parsed body)
self._cache: dict[str, tuple[str, Any]] | None = {} if cache else None
def request(self, path: str, **query: Any) -> Any:
"""One request, with revalidation and retries.
Everything else on this class is a thin wrapper around this, so the behaviour is
defined in exactly one place.
"""
params = {k: v for k, v in query.items() if v is not None}
url = self.base_url + path
if params:
url += "?" + urllib.parse.urlencode(
# Python's bools stringify as "True"; the API wants "true".
{k: str(v).lower() if isinstance(v, bool) else v for k, v in params.items()}
)
cached = self._cache.get(url) if self._cache is not None else None
attempt = 0
while True:
attempt += 1
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
if cached:
headers["If-None-Match"] = cached[0]
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = json.loads(response.read().decode("utf-8"))
etag = response.headers.get("ETag")
if etag and self._cache is not None:
self._cache[url] = (etag, body)
return body
except urllib.error.HTTPError as err:
# Nothing changed, and no body was sent. urllib surfaces 304 as an error;
# it is the successful outcome of a conditional request.
if err.code == 304 and cached:
return cached[1]
try:
payload = json.loads(err.read().decode("utf-8"))
except Exception:
payload = {"error": f"HTTP {err.code}", "code": "internal_error"}
error = VineVerseError(err.code, payload)
if not error.retryable or attempt >= self.max_retries:
raise error from None
# The server knows how long the window has left and we do not, so its
# Retry-After beats anything we could compute here.
retry_after = err.headers.get("Retry-After")
try:
wait = float(retry_after) if retry_after else 0.5 * 2**attempt
except ValueError:
wait = 0.5 * 2**attempt
time.sleep(wait)
# ---- Discovery ---------------------------------------------------------
def stats(self) -> Any:
"""The catalogue: every collection, its size, and what it holds. Call this first."""
return self.request("/stats")
def status(self) -> Any:
"""Is the service up, is it meeting its target, are the optional datasets present."""
return self.request("/status")
def vocabulary(self) -> Any:
"""The relation predicate set and the 66-book canon table."""
return self.request("/vocabulary")
def changelog(self) -> Any:
"""When the knowledge base was last ingested, and what changed."""
return self.request("/changelog")
def tags(self, limit: int | None = None, offset: int | None = None) -> Any:
"""The tag vocabulary with document counts."""
return self.request("/tags", limit=limit, offset=offset)
# ---- Finding things ----------------------------------------------------
def list(
self,
type: str | None = None,
tag: str | None = None,
folder: str | None = None,
sort: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> Any:
"""Page through a collection. This is how you find out what exists."""
return self.request("/list", type=type, tag=tag, folder=folder, sort=sort, limit=limit, offset=offset)
def search(
self,
q: str,
type: str | None = None,
tag: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> Any:
"""Concept search over names, descriptions, tags and paths."""
return self.request("/search", q=q, type=type, tag=tag, limit=limit, offset=offset)
def scripture(
self,
q: str,
book: str | None = None,
testament: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> Any:
"""Full-text search across all 31,102 verses. Quote a phrase to require adjacency."""
return self.request("/scripture", q=q, book=book, testament=testament, limit=limit, offset=offset)
def paginate(self, method: str = "list", page_size: int = 200, **kwargs: Any) -> Iterable[Any]:
"""Walk every page of ``list``, ``search`` or ``scripture``.
Those three return ``total`` alongside the page, which is what makes this possible.
``xrefs`` and ``graph`` do not page; there ``limit`` is a cap, not a cursor.
"""
offset = 0
while True:
page = getattr(self, method)(limit=page_size, offset=offset, **kwargs)
# Three endpoints, three names for the page: `list` and `search` return
# `results`, `scripture` returns `hits`, `tags` returns `tags`. Normalised here
# rather than making the caller remember which is which.
results = page.get("results") or page.get("hits") or page.get("tags") or []
if not results:
return
yield from results
offset += len(results)
if offset >= page.get("total", 0):
return
# ---- Scripture ---------------------------------------------------------
def passage(self, osis: str) -> Any:
"""A verse, range or chapter: John.3.16, John.3.16-18, John.3."""
return self.request(f"/passage/{urllib.parse.quote(osis)}")
def interlinear(self, osis: str) -> Any:
"""The Hebrew, Aramaic or Greek behind a verse or chapter, word by word. No ranges.
Raises ``dataset_unavailable`` on a deployment that has not built the artefact.
Check ``status()["datasets"]["interlinear"]`` first if you would sooner ask than
catch.
"""
return self.request(f"/interlinear/{urllib.parse.quote(osis)}")
def cross_references(self, osis: str, min_votes: int | None = None, limit: int | None = None) -> Any:
"""Cross references for a verse or chapter, ordered by crowd support. No ranges."""
return self.request(f"/xrefs/{urllib.parse.quote(osis)}", minVotes=min_votes, limit=limit)
def connections(self, book: str | None = None, osis: str | None = None, limit: int | None = None) -> Any:
"""The cross-reference corpus in aggregate: hub chapters, book-to-book flows."""
return self.request("/connections", book=book, osis=osis, limit=limit)
# ---- The graph ---------------------------------------------------------
def entity(self, path: str, body: bool | None = None, backlinks: int | None = None) -> Any:
"""Any document, with typed relations in both directions. ``path`` is "people/moses"."""
return self.request(f"/entity/{path.lstrip('/')}", body=body, backlinks=backlinks)
def family(self, path: str, generations: int | None = None) -> Any:
"""A person's parents, children, siblings and spouses."""
return self.request(f"/family/{path.lstrip('/')}", generations=generations)
def graph(
self,
path: str,
depth: int | None = None,
type: str | None = None,
predicate: str | None = None,
limit: int | None = None,
) -> Any:
"""Everything within N hops of a document, with the edges between them."""
return self.request(f"/graph/{path.lstrip('/')}", depth=depth, type=type, predicate=predicate, limit=limit)
def nearby(
self,
place: str | None = None,
lat: float | None = None,
lon: float | None = None,
radius: int | None = None,
limit: int | None = None,
) -> Any:
"""Biblical places within a radius, nearest first."""
return self.request("/nearby", place=place, lat=lat, lon=lon, radius=radius, limit=limit)
# ---- Change notification -----------------------------------------------
def register_webhook(
self,
url: str,
events: list[str] | None = None,
sandbox: bool = False,
) -> Any:
"""Register an https endpoint to be told when the corpus changes.
The only write on the whole API. Pass ``sandbox=True`` to validate the request
without storing anything; the response has the same shape and no consequences.
Keep the returned secret: it signs every delivery, and it is not shown again.
"""
target = f"{self.base_url}/webhooks" + ("?sandbox=true" if sandbox else "")
payload = json.dumps({"url": url, "events": events}).encode("utf-8")
request = urllib.request.Request(
target,
data=payload,
headers={"Content-Type": "application/json", "User-Agent": self.user_agent},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as err:
try:
body = json.loads(err.read().decode("utf-8"))
except Exception:
body = {"error": f"HTTP {err.code}", "code": "internal_error"}
raise VineVerseError(err.code, body) from None
def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
"""Verify a webhook delivery.
Hash the RAW body you received, not a re-serialisation of the parsed JSON: key order and
whitespace may not survive the round trip, and then the digest will not match.
"""
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
def _selftest(base_url: str) -> int:
"""Exercise every method against a running server, including the failure paths."""
client = VineVerse(base_url=base_url)
failures: list[str] = []
def check(label: str, fn: Any) -> None:
try:
fn()
print(f" ok {label}")
except Exception as err: # noqa: BLE001 - a self-test reports, it does not handle
failures.append(label)
print(f" FAIL {label}: {err}")
print(f"Against {base_url}")
check("stats", lambda: client.stats()["collections"])
check("status", lambda: client.status()["status"])
check("vocabulary", lambda: client.vocabulary())
check("changelog", lambda: client.changelog()["bundleGenerated"])
check("tags", lambda: client.tags(limit=5)["tags"])
check("list", lambda: client.list(type="Epoch", limit=5)["results"])
check("search", lambda: client.search("faith", type="Theme", limit=3)["results"])
check("scripture", lambda: client.scripture('"still small voice"', limit=3)["hits"])
check("passage verse", lambda: client.passage("John.3.16"))
check("passage range", lambda: client.passage("John.3.16-18"))
check("passage chapter", lambda: client.passage("John.3"))
check("cross_references", lambda: client.cross_references("John.3.16", limit=5))
check("connections", lambda: client.connections(limit=5))
check("entity", lambda: client.entity("people/moses"))
check("family", lambda: client.family("people/moses"))
check("graph", lambda: client.graph("people/moses", depth=1, limit=20))
check("nearby", lambda: client.nearby(place="places/jerusalem", radius=50, limit=5))
check("paginate", lambda: len(list(client.paginate("list", page_size=50, type="Epoch"))))
# The interlinear may legitimately be unbuilt. Ask before calling, which is exactly the
# workflow the datasets block on /status exists to support.
if client.status()["datasets"]["interlinear"] == "available":
check("interlinear", lambda: client.interlinear("John.3.16"))
else:
print(" skip interlinear (not built on this deployment)")
print("Failure paths:")
def expect(code: str, fn: Any) -> None:
try:
fn()
failures.append(f"expected {code}")
print(f" FAIL expected {code}, got success")
except VineVerseError as err:
if err.code == code:
print(f" ok {code} (requestId {err.request_id})")
else:
failures.append(f"expected {code}, got {err.code}")
print(f" FAIL expected {code}, got {err.code}")
expect("not_found", lambda: client.entity("people/definitely-nobody"))
expect("invalid_reference", lambda: client.passage("NotABook.9"))
expect("unknown_value", lambda: client.list(sort="bogus"))
expect("invalid_parameter", lambda: client.nearby(lat="abc", lon=1))
# The sandbox proves the write path without writing anything.
check("register_webhook sandbox", lambda: client.register_webhook("https://example.com/hook", sandbox=True)["secret"])
print(f"\n{'FAILED: ' + ', '.join(failures) if failures else 'All checks passed.'}")
return 1 if failures else 0
if __name__ == "__main__":
import argparse
import sys
parser = argparse.ArgumentParser(description="VineVerse API client")
parser.add_argument("--selftest", action="store_true", help="exercise every method")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
args = parser.parse_args()
if args.selftest:
sys.exit(_selftest(args.base_url))
parser.print_help()
Four places the API gives you less than the whole truth. Most of them do say so, in backlinksTruncated, truncated, hasMore and threshold, but you have to know to look for the field, and the last one says nothing at all.
backlinks stops at 100 by defaultbacklinkCount is the true total and backlinksTruncated says whether you are seeing all of it. On /entity you can raise the cap to 500 with ?backlinks=; the chapter shape is fixed at 100. Moses has 225, so the default shows fewer than half. They are ordered to put people, themes and events above the chapters that merely mention him, so the ones that survive the cut are the informative ones. links, the outbound side, is capped at 100 too, and that cap is fixed: no parameter raises it. linkCount is the true total, so compare the two rather than counting: Moses has 76 and returns all of them, while a hub like themes/god returns 100 of far more.votes is reconstructed, and saturateslimit is a cursor on some endpoints and a cap on the rest/list, /search, /scripture and /tags page properly: they take an offset and return total alongside the page, so you can walk a whole collection. /xrefs and /graph do not. There limit is still only a cap, raising it to the maximum is the only way to see more, and past that maximum the value is clamped without comment.The data is CC BY 4.0 and the scripture text is public domain, so you are free to use it, including commercially. What travels with it is the attribution: every response carries a licence field pointing at the full source list, because the obligation is inherited rather than discharged by us.
Documents under /sa/ are CC BY-SA 4.0 and are kept separate for exactly that reason. There are 451 of them: the 450 narrative events, plus the Theographic source record, which inherits the licence of the dataset it describes while living under /sources/. Filtering on the id prefix /sa/ therefore catches all but that one.
Two fields test it exactly, and which one you get depends on the shape of the response. A response about a single document (/passage, /entity, /xrefs, /family) carries licence.document, naming the licence that actually applies. A listing or a search covers many documents at once and cannot have one licence, so instead every share-alike entry in it is flagged shareAlike: true. Between them nothing viral reaches you unlabelled; the blanket data line is not a substitute for either.
There is also an MCP server if you want an AI assistant to read this directly, and an OpenAPI description if your tooling would rather build itself from a schema.