Quick start
The base URL is https://hadith.adhamzahran.com/api. Every example below has a Try button that runs it.
A narrator (Ibn Abbas), with a link to him on the website:
GET /api/narrators/4883TryThe first five sahih narrations of Sahih al-Bukhari (book 146), only two columns:
GET /api/hadith?book_id=146&hokm=0&fields=bhid,cluster_id&limit=5TrySearch, the same search as the website:
GET /api/search?q=إنما الأعمال بالنيات&book=146Trycurl -G "https://hadith.adhamzahran.com/api/search" \
--data-urlencode 'q=إنما الأعمال بالنيات' --data-urlencode book=146
Conventions
- Read-only, except for reports.
GET,HEADandOPTIONSeverywhere, plusPOST /api/reportsto report a mistake; anything else is a 405. - JSON in UTF-8. Arabic comes back as Arabic, not
\u0627escapes. - Open CORS (
Access-Control-Allow-Origin: *), so a web page can call it directly. - Cacheable. The data never changes, so responses carry
Cache-Control: public, max-age=3600. Reports do change, so/api/reportsis never cached. - No key and no rate limit for now. Please be gentle, and use the full download if you want everything.
- Ids are not coerced. Some id columns are text and some are numbers (for example
books.book_idandclusters.cluster_idcome back as strings,narrators.idas a number). Filtering works either way; compare by value. bhididentifies one narration:<book_id>_<number>, e.g.146_1is the first narration of Sahih al-Bukhari.
Routes
| Request | Returns |
|---|---|
GET /api/ | This API as JSON: every table with its columns (and which are indexed), keys and foreign keys, plus the meaning of coded values. |
GET /api/{table} | Rows of a table, with filters, paging and column selection. Works for every table, see Tables. |
GET /api/{table}/{key} | One row by primary key. A composite key is joined with commas, in the table's key order: /api/commentaries/146_1,1. |
GET /api/search?q= | The website's hadith search. See Search. |
GET /api/hadith_fts?q= | Raw full-text search over every narration (FTS5 syntax). |
POST /api/reports | Report a mistake in the data or the website. See Reports. |
GET /api/reports, GET /api/reports/{id} | The reports so far, newest first. |
GET /api/docs | This page. |
Filtering, columns and order
Any column of a table can be a filter: column=value means equals, and column=op.value uses an operator. Several filters must all match.
| Operator | Meaning | Example |
|---|---|---|
eq (default) | equals | book_id=146 |
neq | not equals | hokm=neq.0 |
gt gte lt lte | comparisons | death_year=gte.100 |
like | pattern, * matches anything. Case-sensitive; every other character is literal. | name=like.عبد الله* |
in | one of a comma-separated list (up to 1000) | book_id=in.146,158 |
is | is.null or is.notnull | badeel=is.notnull |
- Ranges: repeat the column:
hadith_num=gte.10&hadith_num=lt.13. - Comparing ids:
gt/lton a text id (book_id,bhid,cluster_id) compares as text, so"9" > "10". Number columns likehadith_numcompare as numbers. - A literal value that looks like an operator: prefix it with
eq., e.g.name=eq.like.x. fields=a,breturns only those columns.order=colsorts ascending,order=col.desc,othersorts by several. Without it rows come in stable table order.- Filtering on any short column is fast: they are all indexed (the reference marks which). A
like.*word*inside a long text column has no index and reads the table, which takes a few seconds oncommentariesanddictionary.
Paging and totals
A list returns:
{
"data": [ { "book_id": "146", "name": "صحيح البخاري" }, ... ],
"limit": 2,
"offset": 0,
"next": "/api/books?fields=book_id%2Cname&limit=2&count=1&after=1402",
"total": 1400
}
limitdefaults to 20 and is capped at 100.- Follow
nextuntil it isnull. It is a ready-made URL, so you never build paging yourself. In the default order it pages by key (fast however deep you go); with a customorderit pages byoffset, which gets slower the deeper you go. count=1addstotal. It is opt-in because counting a big table is slow.
A single row (/api/narrators/4883?fields=id,name,rotba,wasf_rotba) is returned bare, not wrapped in data:
{
"id": 4883,
"name": "عبد الله بن عباس بن عبد المطلب بن هاشم بن عبد مناف",
"rotba": 1,
"wasf_rotba": "صحابي",
"ui_url": "https://hadith.adhamzahran.com/#view=narrator&id=4883"
}
Search
There are three ways to search, from the most convenient to the most flexible.
1. /api/search: the website's search
| Parameter | Meaning |
|---|---|
q | required. The words to find. Diacritics (tashkeel) are ignored. |
book | only this book, by book_id (146 is Sahih al-Bukhari). |
hokm | only this grading, 0 sahih to 5 mawdu', see Codes. |
page, limit | page number (default 1) and page size (default 20, max 100). |
- One word is expanded to every form of its root: searching الأعمال also finds عماله, أعمالنا and استعملنا. The response says so in
root_used,morphologicalandform_count. - Several words are searched as an exact phrase first; if nothing matches, as all the words anywhere in the narration.
- Each result has a
snippetwith the matches wrapped in<mark>, its grading, and aui_urlto the narration on the website.
{
"total": 26527, "page": 1, "pages": 26527,
"results": [ {
"bhid": "2028_6", "book_id": "2028", "hadith_num": 6,
"book_name": "...", "author": "...",
"snippet": "...إلى <mark>عماله</mark> : إياكم أن <mark>تستعملوا</mark> على...",
"cluster_id": null, "hokm": null, "hokm_label": "غير محدد",
"ui_url": "https://hadith.adhamzahran.com/#view=hadith&bhid=2028_6"
} ],
"root_used": "عمل", "morphological": true, "form_count": 400,
"ui_url": "https://hadith.adhamzahran.com/#view=search&q=..."
}
It runs the same code as the website's search box, so the results match what you see there. The top-level ui_url opens the same search on the website.
2. /api/hadith_fts?q=: raw full-text syntax
For when you want control: "exact phrase", a OR b, word*, a NOT b. Rows come ranked by relevance with a snippet, and can be combined with the usual filters and fields. A malformed query is a 400. There is no root expansion here.
GET /api/hadith_fts?q="إنما الأعمال"&book_id=146&fields=bhid&limit=3Try3. Filters on any table
Everything else (narrator names, critics' wording, commentary text, dictionary entries) is searched with like: /api/critic_opinions?opinion=like.*اختلط*. A prefix match (name=like.عبد*) is instant on an indexed column; a match in the middle of long text reads the table.
Limits worth knowing
hadith_textcannot be scanned. It holds the text of all 825,000 narrations (2.4 GB), so a filter on it must start from an exactbhid,book_idorhadith_num. After that, text can be filtered inside those rows:/api/hadith_text?book_id=146&clean_text=like.*الأعمال*. To search all narrations, use/api/searchor/api/hadith_fts. Otherwise the answer is a 400 saying so.- Very common words are slow in
/api/search(a few seconds, sometimes much more) because the exacttotalhas to count every match. Addingbookorhokmnarrows it. - Spelling must match. Diacritics are ignored but forms of alef and hamza are not folded: الاعمال does not find الأعمال. Note that
hadith_text.clean_textkeeps its diacritics whilehadith_ftsand/api/searchignore them.
Links to the website
Every hadith and every narrator comes with a link to its page on the website, so a result is one click from the full view (the isnad tree, the takhreej, the biography). The links are added to the rows by the API, whichever table they come from, and even when fields= left the key column out.
| The row… | Gets | Example |
|---|---|---|
is a narration (hadith, hadith_text, hadith_fts) or a narrator (narrators) | ui_url | https://hadith.adhamzahran.com/#view=hadith&bhid=146_1 |
points at one through a column (bhid, rawy_id) | bhid_ui_url, rawy_id_ui_url | …/#view=narrator&id=4883 |
holds a comma-separated list of narrators (sahabi_id, rawy_ids) | an array: sahabi_id_ui_urls, rawy_ids_ui_urls | in the same order as the ids |
is a search (/api/search) | ui_url on every result, and one for the whole search | …/#view=search&q=… |
Recipes
Common questions and the requests that answer them.
Find a narrator, then everything about him
GET /api/narrators?name=like.*عباس*&fields=id,nameTryGET /api/critic_opinions?rawy_id=4883TryThe second one is what every critic said about him; alem_id is the critic, see /api/critics/{alem_id}.
Every isnad a narrator appears in
The isnads store their narrators as a comma-separated list (sanad.rawy_ids), which cannot be searched efficiently. So the same data is also split into a table with one row per narrator in a chain:
GET /api/sanad_rawy?rawy_id=4883&limit=5TryEach row says which isnad (sanad_id) and where in it he stands (pos, counted from 0: 0 is the compiler's side, the last position is the companion). Fetch the isnad itself with /api/sanad/{sanad_id}, which carries its bhid, grading and every narrator's link. The same pattern covers companions: /api/hadith_sahabi?rawy_id=4883 lists the narrations he is the companion of, and book_cluster_sahabi does the same for book_clusters.
The isnads of one narration, and its text
GET /api/sanad?bhid=146_1TryGET /api/hadith_text/146_1TryThe same hadith in every book
Narrations of the same hadith share a cluster_id:
GET /api/hadith?cluster_id=29&fields=bhid&limit=10TryGET /api/clusters/29TryCommentary on a narration
GET /api/commentaries?bhid=146_1Trycommentary_book says which: 1 Fath al-Bari, 2 Sharh al-Nawawi, 3 Awn al-Ma'bud, 4 Tuhfat al-Ahwadhi.
A word's root
GET /api/word_forms?form=الأعمالTryReports
If you find a mistake in the data, or a bug in the website or this API, report it here. This is the only route that takes a POST. Reports are public (anyone can read them below), so don't put personal information in one. They are kept in a file of their own, so they are not part of the database download.
Send a report
curl -X POST https://hadith.adhamzahran.com/api/reports \
-H 'Content-Type: application/json' \
-d '{"kind": "data",
"problem": "missing",
"table": "sanad",
"bhid": "146_4412",
"narrator_id": 3954,
"message": "No sanad row. The text names 1548, 896, 1302, 3954, 4049.",
"suggested": "1548,896,1302,3954,4049",
"source": "islamic-debate.adhamzahran.com"}'
Only kind and message are required.
| Field | Meaning |
|---|---|
kind | required. data for a mistake in the database, website for a mistake or bug in the website or the API. |
problem | optional. What kind of mistake: wrong_value (what is stored is wrong), missing (something that should be there is not), unexpected (something is there that should not be) or other. |
table | optional, only for kind=data. The table the mistake is in, as named under Tables. |
bhid | optional. The narration it concerns, <book_id>_<number> like 146_4412, whichever table the mistake is in (a sanad row, a commentary, a grading, …). Not looked up, so a narration that is missing can be reported. |
narrator_id | optional. The narrator it concerns: a narrators.id, a whole number. Not looked up either. |
key | optional, only for kind=data, needs table. The primary key of the row that exists and is wrong, written as in /api/{table}/{key} (a composite key is comma-joined; a number is fine). Not checked against the table. Up to 200 characters. Leave it out when the row is missing: say which narration with bhid. |
column | optional, only for kind=data, needs table. The column that is wrong; it must be a column of that table. |
message | required. What is wrong. Up to 4,000 characters. |
suggested | optional. What the value, or the row, should be. Up to 4,000 characters; a number is fine. |
source | optional. Who is reporting, such as the name of a site or tool. It is whatever you write: nothing checks it. Up to 100 characters. |
- The body is a JSON object, or a form (
application/x-www-form-urlencoded, which is whatcurl -dsends). Any other field is a 400, includingstatus: only the maintainer sets that. - For a problem with the website, say where in
message: the page, what you did, and what you saw. - The answer is
201 Created, with aLocationheader and the stored report:
{
"id": 7,
"created_at": "2026-10-25T14:11:59Z",
"kind": "data",
"status": "open",
"table": "sanad",
"key": null,
"column": null,
"message": "No sanad row. The text names 1548, 896, 1302, 3954, 4049.",
"problem": "missing",
"bhid": "146_4412",
"narrator_id": 3954,
"suggested": "1548,896,1302,3954,4049",
"source": "islamic-debate.adhamzahran.com",
"bhid_ui_url": "https://hadith.adhamzahran.com/#view=hadith&bhid=146_4412",
"narrator_id_ui_url": "https://hadith.adhamzahran.com/#view=narrator&id=3954"
}
Limits. A body can be 64 KB at most. Each address can send 10 reports an hour, and everyone together 1,000 a day; past that the answer is 429 with a Retry-After header (seconds).
Read the reports
GET /api/reportsTryGET /api/reports?status=open&kind=dataTryGET /api/reports?bhid=146_4412TryGET /api/reports?problem=missing&table=sanadTryReports behave like any table: the same filters, fields, order, paging and count, and /api/reports/{id} for one. They come newest first unless you set order. The columns are the ones in the response above, so any of them filters: everything reported about a narration (bhid=), a narrator (narrator_id=) or a table, everything of one problem, everything from one source. status starts as open and becomes fixed or rejected (the Codes list the values of kind, problem and status). Links to the website come as for any table: bhid_ui_url and narrator_id_ui_url, and ui_url when table and key name a narration or a narrator.
The same list is a page on the website: /#view=reports.
Errors
Errors are JSON with the reason, never HTML: {"error": "unknown column: bogus"}.
| Status | When |
|---|---|
400 | an unknown column or operator, a malformed value, an invalid full-text query, a missing q, a filter on hadith_text that would scan it, or a report with a missing or invalid field. |
404 | an unknown table, or no row with that key. |
405 | anything but GET, HEAD or OPTIONS, except POST /api/reports. |
413 | a report whose body is over 64 KB. |
415 | a report that is neither JSON nor application/x-www-form-urlencoded. |
429 | too many reports from this address, or from everyone, lately. See Retry-After. |
500 | a server fault. The message is always just internal error; the details are only in the server log. |
Tables
Every table in the database, read live from /api/. indexed columns filter and sort fast. Click a table for its columns.
Loading…
Codes
Columns whose values stand for something, read live from /api/.
Loading…
Bulk download
If you want all of it, don't page through the API: download the whole database as one SQLite file from Hugging Face (under 600 MB compressed). The API is read-only on that same file, so every column and table listed here is in it, and the table descriptions are on that page. Reports are the exception: they are not part of the database.