Custom sources13 MIN READ

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.

JSON
{
  "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"
      }
    }
  }
}

Download the JSON template

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.

JSON
{
  "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

  1. Open Sources → Add Custom API Source.
  2. 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.
  3. Tap Add Provider. Review the source details, then choose Add source to save it.
  4. 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.

FieldRequired?Meaning
nameYesA readable name for the source.
baseUrlYesPublic HTTPS API base, such as https://example.com/api.
endpoints.searchYesThe search request definition.
versionNoA string identifying your config revision. Defaults to 1.0.0.
descriptionNoA short description of the source. Defaults to an empty string.
headersNoGlobal non-sensitive headers, as a string-to-string object.
authNoAuthentication 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.

FieldRequired?Meaning
pathYesNon-empty endpoint path, for example /search.
responseMappingYesA mapping object. Use explicit fields to match your response.
methodNoGET or POST, uppercase. Defaults to GET.
paramsNoQuery parameters as string-to-string pairs, such as {"q":"{query}"}. Even numeric values should be strings.
headersNoNon-sensitive headers for this endpoint.
bodyNoPOST body as a JSON string, not a nested object.
paginationNoSettings 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.

MappingMeaning / default
itemsPathResult array. Defaults to $, meaning the whole response is the array.
idPathStable book or result identifier. Defaults to $.id; if missing, the app falls back to a hash of the title. Prefer a real stable ID.
titlePathBook title. Defaults to $.title. An item with no title is skipped.
authorPathAuthor string. Defaults to $.author; missing authors become Unknown.
coverUrlPathOptional cover image URL.
descriptionPathOptional description string.
mediaTypePathOptional field identifying the media type. A value containing audio, ignoring case, means audiobook; other values mean ebook.
mediaTypeValueFixed media type when no mapped type is present. Use audiobook or ebook. With neither, the result defaults to ebook.
formatPathFile format string. Recognises EPUB, PDF, MP3, M4B, M4A, AAC, OGG, and FLAC by case-insensitive matching. Defaults to MP3 for audio, EPUB otherwise.
mediaUrlPathURL or supported source target for the book file, not its cover or details webpage.
remoteUrlPath, downloadUrlPathAlternative explicit media-URL mappings, checked after mediaUrlPath.
durationPathDuration as integer milliseconds, or a string containing that integer. Defaults to 0.
pageCountPathPage count as an integer or integer string. Defaults to 0.
fileSizePathSize 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.

PathSupported?
$Yes: the entire current value.
$.data.resultsYes: nested object fields.
$[0]Yes: the first item when the current root is an array.
$.authors.[0].nameYes: the first author’s name. The dot before [0] is required here.
$.authors[0].nameNo: this conventional JSONPath form isn’t supported by the current resolver.
$..title, $.results[*], filters, or quoted bracket keysNo: 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.

JSON
{
  "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 typeWhat the app sends
noneNo authentication header.
api-keyThe key as the value of headerName. Set the header your API requires; the default is Authorization.
bearertokenPrefix plus the key in headerName. Defaults are Authorization and Bearer (including the trailing space).
basicBasic 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.

JSON
{
  "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.

JSON
{
  "params": { "q": "{query}", "limit": "20" },
  "pagination": {
    "strategy": "page",
    "paramName": "page",
    "pageSize": 20,
    "maxPages": 10,
    "maxTotalItems": 200,
    "interRequestDelayMs": 500
  }
}
StrategyBehaviour
noneOne request. This is the default.
pageAdds paramName to the URL query with values 1, 2, 3, and so on.
offsetAdds paramName with 0, pageSize, 2 × pageSize, and so on.
cursorOmits 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.

JSON
{
  "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

SymptomWhat to check
Malformed JSON or missing required fieldsCheck quoting and the required name, baseUrl, endpoints.search.path, and responseMapping fields.
URL must use HTTPS / a public hostUse a direct public HTTPS API; local development and private addresses aren’t accepted.
Redirects are not allowedUse the final endpoint or raw config URL instead of a redirecting URL.
HTML or unexpected responseCheck for a webpage, login screen, or anti-bot challenge instead of API JSON.
Items path not found / wrong value typeMake sure itemsPath points to an array and each result is an object.
No results from TestThe test query is test. Try a real title; check that each mapped item has a title.
Results appear as ebooksSet mediaTypeValue to audiobook or map a field whose value contains audio.
A book appears but won’t playCheck mediaUrlPath, file access, format, and any provider requirements. A cover or details page isn’t a media file.
Source needs credentials after saving or restoringUse 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.