@americanbible/api-bible-sdk
v1.1.0
Published
TypeScript SDK for api.bible
Readme
api-bible-sdk
A TypeScript SDK for the API.Bible REST API — type-safe access to Bibles, books, chapters, verses, passages, audio content, and search.
Table of Contents
- Installation
- Authentication
- Quickstart
- Key Features
- Configuration
- API Reference
- Content Parameters
- Error Handling
- Advanced Usage
- Development
- Contributing
Installation
npm install @americanbible/api-bible-sdkRequires Node.js ≥20. The SDK relies on the native
fetchAPI andHeadersglobal. Node 18 is end-of-life as of April 2025; consumers on EOL runtimes should upgrade before adopting this SDK.
ESM and CommonJS. This package ships both builds with full TypeScript types for each.
importresolves to the ESM build,require()to the CommonJS build — so it works in Node 20+ (with or without"type": "module"), bundlers (Vite, Webpack 5+, esbuild, Rollup), and test runners including Jest's default CJS transformer and Vitest.
Authentication
- Sign up for a free API key at api.bible.
- Pass the key as
apiKeywhen creating the client.
The key is sent as an api-key header on every request. Never hard-code keys in source files — use an environment variable instead:
export BIBLE_API_KEY="your-api-key-here"Quickstart
import { createBibleClient } from "@americanbible/api-bible-sdk";
const client = createBibleClient({
apiKey: process.env.BIBLE_API_KEY!,
});
// List all available Bibles
const { data: bibles } = await client.bibles.list();
console.log(bibles.map((b) => `${b.id} — ${b.name}`));
// Get a specific chapter (KJV Genesis 1)
const { data: chapter } = await client.chapters.get(
"de4e12af7f28f599-02", // Bible ID
"GEN.1", // Chapter ID
{ contentType: "text", includeVerseNumbers: true },
);
console.log(chapter.content);Key Features
- Full TypeScript support — all responses are runtime-validated with Zod and exposed as precise TypeScript types.
- Complete API coverage —
bibles,books,chapters,verses,passages,sections,audioBibles, andsearchresources. - Automatic retry — exponential backoff with full jitter on
429and5xxresponses, plus transport failures and timeouts; respects theRetry-Afterheader. - Request cancellation — every method accepts an optional
AbortSignal. - Configurable timeout — global timeout (default 10 s) enforced via
AbortController. - Flexible content types — retrieve content as
html,json, or plaintext. - Custom fetch injection — swap in your own
fetchimplementation for testing or edge runtimes. - ESM-only, minimal dependencies — zero runtime dependencies beyond Zod.
Configuration
createBibleClient(config)
| Option | Type | Default | Description |
| ------------------ | ------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------ |
| apiKey | string | — | Required. Your API.Bible key. |
| baseUrl | string | https://rest.api.bible/v1 | Override the API base URL. Must be https:// except for loopback hosts (localhost / 127.0.0.1 / [::1]). |
| timeout | number | 10000 | Per-attempt request timeout in ms — not an end-to-end deadline. See Timeouts are per-attempt. |
| retry | RetryConfig | see below | Retry strategy configuration. |
| maxResponseBytes | number | 10485760 (10 MiB) | Hard ceiling on a single response body. See Response size and validation. |
| headers | Record<string, string> | — | Extra headers sent on every request (e.g. tracing IDs). The api-key header is always applied last and cannot be overridden. |
| fetch | typeof globalThis.fetch | globalThis.fetch | Custom fetch implementation. |
| onResponse | ResponseObserver | — | Observability hook fired per HTTP response received (each attempt, including error statuses). Carries status, headers, attempt, url, and durationMs (per-attempt latency). See Observability. |
| onRetry | RetryObserver | — | Observability hook fired on each retryable failure — including timeouts and network errors, which carry no HTTP response and so are invisible to onResponse. Carries durationMs for the failed attempt. |
RetryConfig
| Option | Default | Description |
| -------------- | ------------- | -------------------------------------------------- |
| maxAttempts | 3 | Total number of attempts (initial + retries). |
| baseDelayMs | 500 | Initial backoff delay in milliseconds. |
| maxDelayMs | 30000 | Maximum backoff delay in milliseconds. |
| maxElapsedMs | 60000 | Total wall-clock budget (ms) across all attempts. Before each backoff sleep, if waiting would exceed it the SDK gives up instead. Bounds retry scheduling, not a single in-flight request. Pass null to disable. |
| jitter | Math.random | Function returning a value in [0, 1) for jitter. |
Retry uses the formula floor(jitter() * min(baseDelayMs * 2^attempt, maxDelayMs)), with the server's Retry-After header value used as a floor when present. If the server's Retry-After exceeds maxDelayMs, the SDK does not retry — it throws RateLimitError / ServerError immediately so you can decide how to handle a wait longer than you've allowed, rather than silently consuming the retry budget.
Timeouts are per-attempt
timeout bounds a single HTTP attempt, not the whole operation. With the default retry.maxAttempts: 3, a request that keeps timing out can take up to roughly 3 × timeout plus the backoff sleeps between attempts before it finally throws.
retry.maxElapsedMs (default 60s) caps the total wall-clock spent across attempts: before each backoff sleep the SDK checks the budget and gives up rather than sleep past it — so a long Retry-After during a rate-limit storm surfaces the error promptly instead of parking the caller for minutes. Note it bounds retry scheduling, not an in-flight request, so it is not a hard deadline. If you need a hard end-to-end deadline that also aborts the in-flight request, pass an AbortSignal and abort it on your own timer (see Request cancellation) — aborting takes priority over the internal timeout and stops retrying immediately.
Response size and validation
Every response is buffered fully into memory and validated against a Zod schema before it is returned. Two things are worth knowing for high-throughput or large-payload workloads:
- Size cap. Bodies larger than
maxResponseBytes(default 10 MiB) are rejected with anApiErrorbefore the whole payload is buffered. Raise it if you legitimately fetch larger responses — e.g. a whole Bible with chapter content inline. - Validation is synchronous. Parsing and validating a multi-megabyte response holds several copies of it in memory and runs on the event loop for the duration. This is negligible for typical payloads, but if you fetch very large bodies under high concurrency, budget for the CPU and memory cost.
Observability
The SDK emits no logs of its own. Instead it exposes two side-channel hooks so you can wire requests into whatever metrics/logging backend you use. onResponse fires once per HTTP response (every attempt, including error statuses); onRetry fires on every retryable failure — network errors, timeouts, 429, 5xx — including the final give-up. Both payloads carry durationMs, the monotonic time the attempt took, so you can build latency percentiles (P50/P95/P99) without wrapping every call site:
import { createBibleClient, type ResponseMeta } from '@americanbible/api-bible-sdk';
const latencies: number[] = [];
const client = createBibleClient({
apiKey: process.env.BIBLE_API_KEY!,
onResponse: (meta: ResponseMeta) => {
latencies.push(meta.durationMs);
// or record straight into your metrics backend, tagged by status:
// histogram.record(meta.durationMs, { status: meta.status });
},
onRetry: (meta) => {
const took = Math.round(meta.durationMs);
console.warn(
`attempt ${meta.attempt} failed in ${took}ms` +
(meta.willRetry ? `, retrying in ${meta.delayMs}ms` : ' — giving up'),
);
},
});
function percentile(values: number[], p: number): number {
if (values.length === 0) return NaN;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
}
// ...later: percentile(latencies, 99) → P99 latency in msResponseMeta also exposes status, headers, attempt, and url; RetryMeta exposes attempt, delayMs, error, and willRetry. The hooks run on the request's hot path and any error they throw is swallowed, so keep them cheap and non-throwing. The api-key is never passed to these callbacks.
Rate limiting
The SDK handles rate limits reactively: a 429 is retried with full-jitter backoff that honors the server's Retry-After, bounded by retry.maxAttempts and retry.maxElapsedMs (see RetryConfig). If retries are exhausted it throws RateLimitError, whose .statusCode is 429 and whose response body is on .body.
It does not throttle proactively — it won't slow down before you hit the limit. If you want to avoid 429s in the first place (e.g. a batch job), read the rate-limit headers off onResponse and pace your own requests. api.bible returns X-RateLimit-Remaining; treat any reset hint as advisory and confirm the exact header semantics against a live response.
import { createBibleClient } from '@americanbible/api-bible-sdk';
let remaining = Infinity;
let resetAtMs = 0; // when the window resets, if the API reports it
const client = createBibleClient({
apiKey: process.env.BIBLE_API_KEY!,
onResponse: (meta) => {
const rem = meta.headers.get('x-ratelimit-remaining');
if (rem !== null) remaining = Number(rem);
// Adjust to your API's actual reset header (seconds-until-reset shown here):
const reset = meta.headers.get('x-ratelimit-reset');
if (reset !== null) resetAtMs = Date.now() + Number(reset) * 1000;
},
});
// Call before a request when you'd rather wait than spend your last token.
async function throttle(): Promise<void> {
if (remaining > 0) return;
const waitMs = Math.max(0, resetAtMs - Date.now());
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
}
await throttle();
const bibles = await client.bibles.list();For multiple clients or processes sharing one API key, enforce the limit in a shared place (a queue or a distributed limiter) rather than per-instance — the backoff already adds jitter so concurrent clients don't retry in lockstep, but only a shared limiter actually caps aggregate request rate.
API Reference
All resource methods return Promise<ApiResponse<T>>, where ApiResponse<T> is:
type ApiResponse<T> = {
data: T;
meta?: Meta; // FUMS analytics metadata from API.Bible
};Every method accepts an optional AbortSignal as its final argument.
client.bibles
// List all Bibles, optionally filtered
bibles.list(params?: BibleListParams, signal?: AbortSignal): Promise<ApiResponse<Bible[]>>
// Get a single Bible by ID
bibles.get(bibleId: string, signal?: AbortSignal): Promise<ApiResponse<Bible>>BibleListParams: language?, abbreviation?, name?, ids?: string[], includeFullDetails?: boolean
client.books
books.list(bibleId: string, params?: BookListParams, signal?: AbortSignal): Promise<ApiResponse<Book[]>>
books.get(bibleId: string, bookId: string, params?: BookGetParams, signal?: AbortSignal): Promise<ApiResponse<Book>>BookListParams: includeChapters?: boolean, includeChaptersAndSections?: booleanBookGetParams: includeChapters?: boolean
client.chapters
chapters.list(bibleId: string, bookId: string, signal?: AbortSignal): Promise<ApiResponse<ChapterSummary[]>>
chapters.get(bibleId: string, chapterId: string, params?: ChapterGetParams, signal?: AbortSignal): Promise<ApiResponse<Chapter>>client.verses
verses.list(bibleId: string, chapterId: string, signal?: AbortSignal): Promise<ApiResponse<VerseSummary[]>>
verses.get(bibleId: string, verseId: string, params?: VerseGetParams, signal?: AbortSignal): Promise<ApiResponse<Verse>>client.passages
// passageId is a range like "GEN.1.1-GEN.1.10"
passages.get(bibleId: string, passageId: string, params?: PassageGetParams, signal?: AbortSignal): Promise<ApiResponse<Passage>>client.sections
sections.listForBook(bibleId: string, bookId: string, signal?: AbortSignal): Promise<ApiResponse<SectionSummary[]>>
sections.listForChapter(bibleId: string, chapterId: string, signal?: AbortSignal): Promise<ApiResponse<SectionSummary[]>>
sections.get(bibleId: string, sectionId: string, params?: SectionGetParams, signal?: AbortSignal): Promise<ApiResponse<Section>>client.audioBibles
audioBibles.list(params?: AudioBibleListParams, signal?: AbortSignal): Promise<ApiResponse<AudioBibleSummary[]>>
audioBibles.get(audioBibleId: string, signal?: AbortSignal): Promise<ApiResponse<AudioBible>>
audioBibles.listBooks(audioBibleId: string, params?: AudioBookListParams, signal?: AbortSignal): Promise<ApiResponse<AudioBookSummary[]>>
audioBibles.getBook(audioBibleId: string, bookId: string, params?: AudioBookGetParams, signal?: AbortSignal): Promise<ApiResponse<AudioBookSummary>>
audioBibles.listChapters(audioBibleId: string, bookId: string, signal?: AbortSignal): Promise<ApiResponse<AudioChapterSummary[]>>
// Returns resourceUrl (signed audio stream URL); timecodes are included only when api.bible has them
audioBibles.getChapter(audioBibleId: string, chapterId: string, signal?: AbortSignal): Promise<ApiResponse<AudioChapter>>AudioChapter includes a signed resourceUrl for the audio stream. The timecodes array (mapping timestamps to verse IDs) is present only when api.bible provides timecode data for that chapter — it is not a request option, so it will be absent for audio Bibles that have no timecodes.
client.search
search.search(bibleId: string, params?: SearchParams, signal?: AbortSignal): Promise<ApiResponse<SearchResult>>SearchParams:
| Param | Type | Description |
| ----------- | --------------------------------------------------- | ---------------------------------------------------------------- |
| query | string | Search query string. |
| limit | number | Max results to return. |
| offset | number | Pagination offset. |
| sort | 'relevance' \| 'canonical' \| 'reverse-canonical' | Sort order. |
| range | string | Limit search to a passage range (e.g. "GEN", "MAT.1-MAT.5"). |
| fuzziness | 'AUTO' \| '0' \| '1' \| '2' | Fuzzy match level. |
Content Parameters
The get methods on chapters, verses, passages, and sections share a common set of content parameters:
| Param | Type | Description |
| ----------------------- | ---------------------------- | ---------------------------------------------------- |
| contentType | 'html' \| 'json' \| 'text' | Format for the content field. |
| includeNotes | boolean | Include footnotes. |
| includeTitles | boolean | Include section titles. |
| includeChapterNumbers | boolean | Include chapter number markers. |
| includeVerseNumbers | boolean | Include verse number markers. |
| includeVerseSpans | boolean | Include verse span markers. |
| parallels | string[] | Additional Bible IDs to include as parallel content. |
Error Handling
Every error the SDK throws extends a single root, BibleError, so one
instanceof BibleError catches anything the SDK can throw. Narrow to a subtype
for specific handling:
BibleError ← catch-all for every error the SDK throws
├─ ApiError ← a request was attempted; exposes statusCode + body
│ ├─ AuthError 401 / 403
│ ├─ NotFoundError 404
│ ├─ BadRequestError 400
│ ├─ RateLimitError 429 (auto-retried)
│ ├─ ServerError 5xx (auto-retried)
│ └─ NetworkError transport failure / timeout (auto-retried)
├─ InvalidInputError ← you called the SDK wrong; no request was made
└─ ValidationError ← API response didn't match the schema; exposes issuesImport the error classes you need and use instanceof checks. Order subtypes
before their supertypes (e.g. NetworkError before the ApiError catch-all):
import {
createBibleClient,
InvalidInputError,
AuthError,
NotFoundError,
BadRequestError,
RateLimitError,
ServerError,
NetworkError,
ValidationError,
ApiError,
BibleError,
} from "@americanbible/api-bible-sdk";
const client = createBibleClient({ apiKey: process.env.BIBLE_API_KEY! });
try {
const { data } = await client.verses.get("invalid-bible-id", "GEN.1.1");
} catch (err) {
if (err instanceof InvalidInputError) {
// You called the SDK wrong (missing apiKey, malformed params) — no request was sent
console.error("Invalid input:", err.message);
} else if (err instanceof AuthError) {
// 401 or 403 — check your API key
console.error("Authentication failed:", err.message);
} else if (err instanceof NotFoundError) {
// 404 — Bible or verse ID does not exist
console.error("Not found:", err.message);
} else if (err instanceof BadRequestError) {
// 400 — malformed request
console.error("Bad request:", err.message);
} else if (err instanceof RateLimitError) {
// 429 — automatically retried; only thrown when retries are exhausted
console.error("Rate limit exceeded");
} else if (err instanceof ServerError) {
// 5xx — automatically retried; only thrown when retries are exhausted
console.error("Server error:", err.statusCode);
} else if (err instanceof NetworkError) {
// DNS/TCP/TLS failure or timeout before any response — auto-retried.
// statusCode is 0 and body is "" (no response was received).
console.error("Network error:", err.message);
} else if (err instanceof ValidationError) {
// SDK received a response that didn't match its schema
console.error("Schema validation failed:", err.format());
} else if (err instanceof ApiError) {
// Catch-all for any other error where a request was attempted
console.error(`HTTP ${err.statusCode}:`, err.body);
} else if (err instanceof BibleError) {
// Catch-all for any SDK error
console.error("SDK error:", err.message);
}
}| Error | Extends | Thrown when | Auto-retried | statusCode / body |
| --- | --- | --- | --- | --- |
| AuthError | ApiError | HTTP 401 / 403 | no | real / response body |
| NotFoundError | ApiError | HTTP 404 | no | real / response body |
| BadRequestError | ApiError | HTTP 400 | no | real / response body |
| RateLimitError | ApiError | HTTP 429 | yes | real / response body |
| ServerError | ApiError | HTTP 5xx | yes | real / response body |
| NetworkError | ApiError | transport failure / timeout, before any response | yes | 0 / "" |
| InvalidInputError | BibleError | bad caller input — thrown synchronously, no request sent | no | — |
| ValidationError | BibleError | API response failed Zod validation (contract drift / bad JSON) | no | issues + format() |
Notes:
BibleErroris the single root — use it when you just want "did the SDK throw?"ApiErrornarrows that to "a request was attempted," and includesNetworkErroreven though no HTTP response came back (statusCode0,body"").InvalidInputErroris deliberately not anApiError: it's raised before any request (missingapiKey, a plaintextbaseUrl, out-of-range retry bounds, an empty search query, a comma inside anids[]/parallelselement). Catch it to distinguish "I called the SDK wrong" from a real API/transport failure.ValidationErroris also not anApiError: it signals an SDK/contract problem, not an API failure. Inspectissues(Zod's path-based list) or callformat()for a log-ready multi-line summary.RateLimitError,ServerError, andNetworkErrorare retried automatically; they only reach your code once retries are exhausted — or, for 429/5xx, immediately when the server'sRetry-Afterexceedsretry.maxDelayMs.
Advanced Usage
Request cancellation with AbortSignal
const controller = new AbortController();
// Cancel after 5 seconds
setTimeout(() => controller.abort(), 5000);
try {
const { data } = await client.chapters.get(
"de4e12af7f28f599-02",
"GEN.1",
{ contentType: "text" },
controller.signal,
);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
console.log("Request cancelled");
}
}Custom retry configuration
const client = createBibleClient({
apiKey: process.env.BIBLE_API_KEY!,
retry: {
maxAttempts: 5,
baseDelayMs: 1000,
maxDelayMs: 60_000,
},
});Custom fetch implementation
Useful for testing, edge runtimes, or adding middleware:
import { createBibleClient } from "@americanbible/api-bible-sdk";
const client = createBibleClient({
apiKey: process.env.BIBLE_API_KEY!,
fetch: async (url, init) => {
console.log("→", url);
return globalThis.fetch(url, init);
},
});Paginating search results
const PAGE_SIZE = 20;
let offset = 0;
let total = Infinity;
while (offset < total) {
const { data } = await client.search.search("de4e12af7f28f599-02", {
query: "love",
limit: PAGE_SIZE,
offset,
});
total = data.total;
console.log(data.verses);
offset += PAGE_SIZE;
}Versioning and support
This package follows Semantic Versioning. Notable changes are recorded in CHANGELOG.md.
- Patch (
1.0.x) — bug fixes and internal changes with no API impact. - Minor (
1.x.0) — backwards-compatible additions: new methods, new optional config, new fields on response/observer types. Because every response schema uses.passthrough(), new fields returned by api.bible do not require a release and will not break validation. - Major (
x.0.0) — breaking changes to the public API: removed or renamed exports, changed method signatures, changed error types, or raising the minimum Node version.
The public API is everything exported from the package entry point — createBibleClient, the resource interfaces, the config/response types, and the error classes. Anything reached through dist/ internals is not a stable surface.
Runtime support: Node 20+. Dropping an end-of-life Node version is a breaking change and ships only in a major release.
Security support: fixes land on the latest published minor release; see SECURITY.md.
Development
# Run the test suite (Vitest)
npm test
# Type-check without emitting
npm run typecheck
# Run the bundled example
npm run exampleTests use Vitest with mocked fetch responses — no live API calls are made during testing.
Contract fixtures
The offline contract tests in tests/contract/ replay recorded api.bible responses (tests/contract/fixtures/recordings.json) through the real client and schemas, so schema drift is caught without touching the network. The nightly Contract workflow runs the same cases against the live API to detect drift; the monthly Refresh Contract Fixtures workflow re-records the fixtures and opens a PR with any changes (or run it on demand via workflow_dispatch). To refresh locally:
BIBLE_API_KEY=... npm run record-fixturesContributing
Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request. Make sure npm test and npm run typecheck both pass before opening a PR.
Maintainers: see RELEASING.md for the release process and the rollback/incident runbook.
