Create a custom JSON source
A source config tells Forge & Fable how to search a JSON API and turn the results into books. You provide the request details and map the API’s fields to the app’s fields. You don’t need to write an Android plugin.
This reference follows the current app source, reviewed on 23 September 2026. A closed-test build may lag behind the latest source. Examples use a placeholder domain; they explain the format and won’t return books until you adapt them to a real API.
In this guide
Before you start
You need a JSON API that you’re allowed to use, its search endpoint, and a sample response. The API must use HTTPS and a public host. Localhost and private-network destinations are blocked, and redirects aren’t followed. Config downloads and individual search responses have a 5 MiB limit.
An ordinary webpage, an HTML search page, or a browser sign-in page isn’t a JSON API. The source engine doesn’t execute JavaScript, scrape HTML selectors, or perform an interactive login.
Start with this template
This example searches https://example.com/api/search?q=your-search. Replace the domain, endpoint, and mappings with your API’s details. The file is a source definition, not a list of books.
{
"name": "My Book Source",
"version": "1.0.0",
"description": "A source for my book API",
"baseUrl": "https://example.com/api",
"auth": { "type": "none" },
"endpoints": {
"search": {
"method": "GET",
"path": "/search",
"params": { "q": "{query}" },
"headers": { "Accept": "application/json" },
"responseMapping": {
"itemsPath": "$.results",
"idPath": "$.id",
"titlePath": "$.title",
"authorPath": "$.author",
"coverUrlPath": "$.cover",
"descriptionPath": "$.description",
"mediaTypeValue": "ebook",
"formatPath": "$.format",
"mediaUrlPath": "$.downloadUrl"
}
}
}
}The response it expects
Here’s a sample API response that matches the template. This is returned by the API; don’t paste it into the app as the source config.
{
"results": [
{
"id": "secret-garden",
"title": "The Secret Garden",
"author": "Frances Hodgson Burnett",
"cover": "https://example.com/covers/secret-garden.jpg",
"description": "A classic story of friendship and a hidden garden.",
"format": "epub",
"downloadUrl": "https://example.com/books/secret-garden.epub"
}
]
}Import and test your source
- Open Sources → Add Custom API Source.
- Paste your complete JSON config, or a direct HTTPS URL that returns the JSON file. A repository’s HTML file-view page won’t work; use its raw JSON URL, with no redirect.
- Tap Add Provider. Review the source details, then choose Add source to save it.
- Use Test on the provider, then try a search for a title you know the API can return.
The provider test searches for the literal term test. An empty result is allowed and doesn’t necessarily mean your source is broken. Passing config validation checks structure and selected request rules; it doesn’t guarantee that the API or every mapping works.
For a config hosted at a URL, the current import flow fetches the file without a custom authentication header. Keep that shared definition free of credentials.
The config fields
Field names are case-sensitive. Use a single JSON object with quoted property names, string values where shown, and no comments or trailing commas. Prefer the native format below for new sources.
| Field | Required? | Meaning |
|---|---|---|
name | Yes | A readable name for the source. |
baseUrl | Yes | Public HTTPS API base, such as https://example.com/api. |
endpoints.search | Yes | The search request definition. |
version | No | A string identifying your config revision. Defaults to 1.0.0. |
description | No | A short description of the source. Defaults to an empty string. |
headers | No | Global non-sensitive headers, as a string-to-string object. |
auth | No | Authentication settings. Omit for an unauthenticated API. |
The schema also accepts endpoints.metadata, endpoints.trending, and endpoints.newReleases. The current custom-source engine executes search; declaring the other endpoints doesn’t make them active features.
Inside endpoints.search
| Field | Required? | Meaning |
|---|---|---|
path | Yes | Non-empty endpoint path, for example /search. |
responseMapping | Yes | A mapping object. Use explicit fields to match your response. |
method | No | GET or POST, uppercase. Defaults to GET. |
params | No | Query parameters as string-to-string pairs, such as {"q":"{query}"}. Even numeric values should be strings. |
headers | No | Non-sensitive headers for this endpoint. |
body | No | POST body as a JSON string, not a nested object. |
pagination | No | Settings for fetching multiple pages. |
The request builder preserves the base path: https://example.com/api plus /search becomes https://example.com/api/search. Put {query} in a parameter value or path to insert the search term. Query values are URL-encoded by the request builder; don’t pre-encode them.
{QUERY}, {title}, and {TITLE} are aliases for the same search term. {author} and {AUTHOR} are replaced with an empty string; they aren’t a separate author search. There is no general template language or active {id} substitution for search.
Map an API response
itemsPath starts at the complete response and must resolve to an array. Every other item mapping starts at one object in that array. For the template, $.results selects the array and $.title selects each book’s title.
| Mapping | Meaning / default |
|---|---|
itemsPath | Result array. Defaults to $, meaning the whole response is the array. |
idPath | Stable book or result identifier. Defaults to $.id; if missing, the app falls back to a hash of the title. Prefer a real stable ID. |
titlePath | Book title. Defaults to $.title. An item with no title is skipped. |
authorPath | Author string. Defaults to $.author; missing authors become Unknown. |
coverUrlPath | Optional cover image URL. |
descriptionPath | Optional description string. |
mediaTypePath | Optional field identifying the media type. A value containing audio, ignoring case, means audiobook; other values mean ebook. |
mediaTypeValue | Fixed media type when no mapped type is present. Use audiobook or ebook. With neither, the result defaults to ebook. |
formatPath | File format string. Recognises EPUB, PDF, MP3, M4B, M4A, AAC, OGG, and FLAC by case-insensitive matching. Defaults to MP3 for audio, EPUB otherwise. |
mediaUrlPath | URL or supported source target for the book file, not its cover or details webpage. |
remoteUrlPath, downloadUrlPath | Alternative explicit media-URL mappings, checked after mediaUrlPath. |
durationPath | Duration as integer milliseconds, or a string containing that integer. Defaults to 0. |
pageCountPath | Page count as an integer or integer string. Defaults to 0. |
fileSizePath | Size in bytes as an integer or integer string. Defaults to 0. |
The app’s MOBI reader and the source format mapper are different features: the current source mapper doesn’t have a MOBI format branch. Don’t assume that setting format to mobi selects it.
Use primitive values for title, author, cover, description, and similar fields. Mapping an object or an array into those fields can fail the search. For example, if the author is {"name":"Jules Verne"}, map $.author.name.
The supported path syntax
This is a small path resolver, not full JSONPath. It supports the root $, dot-separated object properties, and numeric array indices when the index is its own segment.
| Path | Supported? |
|---|---|
$ | Yes: the entire current value. |
$.data.results | Yes: nested object fields. |
$[0] | Yes: the first item when the current root is an array. |
$.authors.[0].name | Yes: the first author’s name. The dot before [0] is required here. |
$.authors[0].name | No: this conventional JSONPath form isn’t supported by the current resolver. |
$..title, $.results[*], filters, or quoted bracket keys | No: recursive lookup, wildcards, filters, and quoted keys aren’t implemented. |
Media URLs and extra result details
Map a media URL explicitly. If explicit mappings are empty, the engine tries common fields such as downloadUrl, download_url, mediaUrl, remoteUrl, infoHash, magnetUrl, magnet, url, and link (including several snake-case variants). If no media target is found, the current item mapper can fall back to the cover URL, which doesn’t make the book playable.
Relative cover and media URLs are resolved against baseUrl using normal URL resolution. Use absolute HTTPS URLs to avoid ambiguity. Unlike endpoint joining, a leading slash in a returned media URL starts at the host root.
Magnet targets remain magnets; a bare 40-character hexadecimal info hash is converted to a magnet target. These need a compatible provider flow. Mapping a result doesn’t guarantee that it can stream or download, and remote audio playback requires HTTPS.
Optional result-detail mappings include postedDatePath, sourceLabelPath, serviceLabelPath, supportedServicesPath, resolverLabelPath, compatibilityLabelPath, qualityTierPath, qualityScorePath, debridCachePath, requiresTorBoxProPath, usenetOnlyPath, seedersPath, and tagsPath. These add source information or quality/cache hints; they don’t grant access to a provider.
Authentication without sharing secrets
Supported auth.type values are none, api-key, bearer, and basic. For an authenticated API, use the dedicated top-level auth object. Don’t put credentials in header maps, query parameters, paths, or POST bodies.
An API-key definition can look like this. The empty key is deliberate: a private import needs your own credential before it can make authenticated requests.
{
"auth": {
"type": "api-key",
"headerName": "X-Api-Key",
"key": ""
}
}This is a fragment to merge into your complete source config, not a standalone import. For a private import, enter the credential in auth.key in your private copy before pasting it into the app. On save, the app moves that value to Android Keystore-backed encrypted storage and removes it from the saved JSON definition. Don’t publish or share the private copy.
| Auth type | What the app sends |
|---|---|
none | No authentication header. |
api-key | The key as the value of headerName. Set the header your API requires; the default is Authorization. |
bearer | tokenPrefix plus the key in headerName. Defaults are Authorization and Bearer (including the trailing space). |
basic | Basic plus the key in headerName. Supply the Base64-encoded username:password value as the key; the app doesn’t encode it for you. |
secretRef is an internal, device-specific reference. Omit it from authored and shared configs. A blank key in a newly imported authenticated source leaves it requiring credentials. Backups deliberately exclude credentials too.
Authorization, Cookie, Proxy-Authorization, X-Api-Key, and X-Auth-Token are blocked in arbitrary headers maps. The dedicated auth mechanism is separate. Sensitive values hidden elsewhere can also make a source unusable when it is saved. Cookie-based sign-in and OAuth login flows aren’t supported by this config format.
POST requests
In the native format, body is a string containing the request body. For a JSON API, set Content-Type on the endpoint’s headers. This fragment replaces the corresponding search request fields; retain your responseMapping.
{
"method": "POST",
"path": "/search",
"headers": { "Content-Type": "application/json" },
"body": "{\"query\":\"{query}\"}"
}POST template replacement is literal: the app doesn’t JSON-escape or form-encode the inserted search term. Quotes, backslashes, or other special characters may break a request body. Prefer GET query parameters when the API offers them, and test special-character searches if using POST.
Fetch more than one page
Add pagination inside endpoints.search. For a page-number API, the following fragment sends page=1, then page=2, and so on. Set the API’s actual page-size parameter separately in params; pageSize alone doesn’t send a limit parameter.
{
"params": { "q": "{query}", "limit": "20" },
"pagination": {
"strategy": "page",
"paramName": "page",
"pageSize": 20,
"maxPages": 10,
"maxTotalItems": 200,
"interRequestDelayMs": 500
}
}| Strategy | Behaviour |
|---|---|
none | One request. This is the default. |
page | Adds paramName to the URL query with values 1, 2, 3, and so on. |
offset | Adds paramName with 0, pageSize, 2 × pageSize, and so on. |
cursor | Omits the cursor parameter on the first request, then reads cursorPath from the full response and sends that value next time. Stops when the cursor is empty or null. |
For cursor pagination, set strategy to cursor, paramName to the API’s cursor parameter, and cursorPath to a supported path such as $.nextCursor. The path must start with $. Active pagination requires a non-empty parameter name using only letters, digits, underscores, or hyphens.
Defaults are pageSize: 20, maxPages: 50, maxTotalItems: 5000, and interRequestDelayMs: 500. The app clamps page size to 1–1,000, maximum pages to 1–200, and total items to 1–50,000; negative delays become zero. Use modest limits suitable for your API.
Pagination parameters always go in the URL query, including for POST. The engine stops at an empty mapped page or a configured limit and deduplicates by source and source ID. A failure on the first page fails the search; a later failure can return the results already collected.
Existing adapter-style configs
The importer also converts a subset of Ink & Echo-style adapter definitions: kind: "search" with a search object, adapters.source, or top-level request and response objects. This is compatibility support, not a guarantee that every external provider format will work.
{
"name": "Example adapter source",
"kind": "search",
"contentType": "ebook",
"search": {
"request": {
"method": "GET",
"url": "https://example.com/api/search?q={query}"
},
"response": {
"resultsPath": "results",
"mapping": {
"title": "title",
"author": "author",
"cover": "cover",
"format": "format",
"ebookUrl": "downloadUrl"
}
}
}
}Adapter mappings use field names such as title and ebookUrl, rather than the native titlePath and mediaUrlPath. Missing $ prefixes are added during conversion. Adapter request bodies can be JSON objects; the converter serialises them into the native string body. Use the native template for new configs, especially if you need an explicit stable idPath or dedicated authentication.
If it doesn’t work
| Symptom | What to check |
|---|---|
| Malformed JSON or missing required fields | Check quoting and the required name, baseUrl, endpoints.search.path, and responseMapping fields. |
| URL must use HTTPS / a public host | Use a direct public HTTPS API; local development and private addresses aren’t accepted. |
| Redirects are not allowed | Use the final endpoint or raw config URL instead of a redirecting URL. |
| HTML or unexpected response | Check for a webpage, login screen, or anti-bot challenge instead of API JSON. |
| Items path not found / wrong value type | Make sure itemsPath points to an array and each result is an object. |
| No results from Test | The test query is test. Try a real title; check that each mapped item has a title. |
| Results appear as ebooks | Set mediaTypeValue to audiobook or map a field whose value contains audio. |
| A book appears but won’t play | Check mediaUrlPath, file access, format, and any provider requirements. A cover or details page isn’t a media file. |
| Source needs credentials after saving or restoring | Use auth correctly and supply credentials privately. Shared templates and backups don’t carry a working secret. |
For network and provider problems, see troubleshooting. To share a source, distribute only the credential-free definition and explain which API it expects.
Keep exploring
Back to Help & Guides