We put the Bible in our own Postgres instead of calling an API per request
For a finite, static corpus read constantly, a per-request Bible API is the wrong shape. Endeo ingested translations into Postgres (and a bundled local subset) so verse text joins user data in one query and works with no network. The cost is owning ingestion, versification quirks, and binary size forever.
The data has a shape, and the shape argues
Three properties matter here. The corpus is finite and modest, a few megabytes of text per translation. It is completely static, in the sense that a verse published in a given translation is not going to be revised while your app is running. And it is read constantly, because it is the substrate every other feature in the app sits on top of.
That combination is close to the ideal case for owning your own copy. A per-request API earns its keep when data is large, changing, or you need something you genuinely can't compute yourself. None of that applies. We would have been paying network latency, availability risk, and rate limits for the privilege of fetching the same immutable paragraph over and over.
The inverse test is useful too. If the corpus were updated weekly by someone else, or so large that shipping it was impractical, or if the value came from a service rather than the text, calling an API would be obviously right. It's worth actually asking those questions rather than defaulting either direction, because the default has a way of being whichever one you did last time.
What a per-request API costs you
The one that killed it for us was offline. Endeo's default is a fully working app with no network and no backend configured. An app where reading a chapter requires a round trip cannot honor that, and everything downstream of the text inherits the dependency: the notes are anchored to verses, the search covers verses, the study session ties to a passage.
Then there's what you can't do. Owning the text means we can join it against user data in a single query. Find every verse a person has highlighted, ordered canonically, with the surrounding context, is one statement against one database. If the text lives behind someone else's HTTP endpoint, that becomes fetch a list of references, then fan out N requests, then reassemble in application code, and now you're writing a caching layer, which is the thing you were trying to avoid.
Rate limits and terms are the quiet third cost. A per-request dependency means your app's usage pattern is subject to somebody else's pricing decisions, and a feature like full-text search over the whole corpus may simply be off the table at any price.
Ingestion is where the actual work is
The download is trivial. The work is that scripture references are messier than they look, and every simplification you make during ingestion becomes a bug in the app later.
Versification is the first surprise: different translations don't always agree on where verses divide or how they're numbered, particularly in the Psalms where superscriptions may or may not be verse one. If you assume a single canonical numbering and then add a second translation, every stored reference in the app is suddenly ambiguous. We store references against a specific translation's numbering and treat cross-translation mapping as an explicit conversion rather than an assumption.
Book naming is the second. Users type Jn, John, Jhn, 1 Cor, I Corinthians, 1Co. Book identity has to be a stable numeric id in the schema, with a separate table of accepted aliases for parsing, because the day you key anything off a display name is the day a translation with different book titles breaks your references. And licensing is real work that isn't engineering at all: translations have terms, some permit redistribution in an app and some don't, and that has to be settled before ingestion rather than after.

The schema is boring on purpose
Translations, books, chapters, verses. Verses carry a translation id, book id, chapter number, verse number, and the text, with a unique constraint across the first four. Nothing clever. The entire point of owning the data is that queries against it are ordinary SQL, and clever schemas are how you lose that.
The one addition that earns its complexity is a generated full-text search column with an index on it, so search is a database concern rather than something the app does in memory over a loaded chapter. That is the capability that would have been hardest to get from a per-request API, and it took about four lines of DDL to have permanently.
The same tables ship in two places. The server copy lives in Postgres on Supabase, and a subset is prepared at build time into the local store the app reads from. Keeping the shapes aligned between the two is the ongoing tax of this approach, and it's the thing to watch: a schema change now has to land in both, in the same release.
The bill you're agreeing to
You own it forever. Corrections, a new translation someone asks for, a licensing term that changes, an ingestion bug discovered two years later in a book nobody reads often. There is no vendor to file a ticket with. That's the honest cost and it doesn't go away.
You also own the binary size. Bundling text into the app makes it bigger, and that's a real number that shows up on the App Store listing. We ship a subset rather than every translation, which means the bundled experience is deliberately narrower than the synced one, and deciding what goes in that subset was a product argument rather than a technical one.
What we'd say to someone facing the same call: the question isn't build versus buy, it's whether the data is a service or a substrate. If other features need to join against it, sort by it, search it, and reach it with no network, it's a substrate, and substrates belong in your own database. If you only ever need to display it in response to a user asking for exactly that thing, call the API and go work on something else.
Questions
- When should you own a copy of third-party text instead of calling an API?
- When the corpus is modest, effectively immutable at runtime, and a substrate other features join, search, and read offline. Paying latency, rate limits, and availability risk to refetch the same paragraph is a poor trade.
- What is the hard part of ingesting scripture text?
- Versification and naming: translations disagree on verse boundaries, and users type many book aliases. Store stable numeric book IDs, translation-specific numbering, and alias tables — do not key durable references off display strings.
- What ongoing bill do you accept by ingesting?
- Corrections, new translations, licensing changes, and ingestion bugs years later — with no vendor ticket queue. You also own app size; shipping a subset locally versus full synced text is a product decision, not just a technical one.
Sources
- PostgreSQL — full-text search
- Supabase — database overview
- Endeo case study
- SwiftData documentation — Local store shape for the bundled subset.