API reference
A free JSON API over the Postgres mailing lists — searchable, threaded, structured. Free means no cost; you still need an account and an API key.
Base URL
All endpoints live under
/api/v1
and return application/json.
https://horton.kehvyn.dev/api/v1
Authentication
Every request needs a key. There is no anonymous access. Mint keys at /users/api-keys (you'll need to register first). The raw key is shown exactly once; only a hash is stored. Keys carry an optional expiry and can be revoked.
Send it as a bearer token (checked first) or the
X-API-Key
header. Keys are
prefixed hml_.
curl -H "Authorization: Bearer hml_…" https://horton.kehvyn.dev/api/v1/lists
# or
curl -H "X-API-Key: hml_…" https://horton.kehvyn.dev/api/v1/lists
A missing, malformed, or expired key returns
401
with { "error": { "code": "unauthorized" } }.
Endpoints
| Method | Path | Key params |
|---|---|---|
| GET | /api/v1/lists | — |
| GET | /api/v1/messages | list, from, to, dir, limit, after, before |
| GET | /api/v1/messages/:b64id | — |
| GET | /api/v1/messages/:b64id/thread | — |
| GET | /api/v1/messages/:b64id/commits | — |
| GET | /api/v1/messages/:b64id/refs | — |
| GET | /api/v1/search | q (required), sort, from, to, sender, committed, path, major, limit, after |
| GET | /api/v1/senders | q, sort, dir, limit, after, before |
| GET | /api/v1/senders/:id | — |
| GET | /api/v1/attachments | limit, after, before |
| GET | /api/v1/attachments/:id | — |
| GET | /api/v1/attachments/:id/patch | — |
| GET | /api/v1/source/activity | path (required), major, limit, after, before |
| GET | /api/v1/commits | path, author, major, from, to, limit, after, before |
| GET | /api/v1/commits/:sha | — |
| GET | /api/v1/commits/:sha/thread | — |
| GET | /api/v1/versions | — |
| GET | /api/v1/versions/:major/gucs | changed_since |
| GET | /api/v1/imports | list |
:b64id
is the base64url-encoded
Message-ID
header; :id
is a numeric id.
Attachment endpoints return metadata only — the API never returns file bytes.
Pagination
Collections use keyset (seek) pagination, not offsets. Pass
limit
(default 25, max 100) and
follow the opaque next_cursor
/ prev_cursor
from each response
via after
/ before. Either cursor may be
null
at an edge. Setting both
after
and before
is a 422; an undecodable cursor is a 400.
Search
GET /api/v1/search
is the reason
this exists. It runs Postgres full-text search over the whole corpus with a domain-tuned
'pg'
text-search config the stock
archive can't match.
-
qis required; a blank query is a400. -
Default order is relevance (
ts_rank_cd). Relevance results are capped at 200 — narrow with filters, or passsort=sent_atfor the full, uncapped set in date order. -
Filter with
from/to(ISO-8601 bounds onsent_at) andsender(substring over email or display name). -
Filter by linkage to the source tree:
committed=true|false(the thread landed a commit via itsDiscussion:trailer),path=(the thread touched a source-path prefix — a landed commit changed a file under it, or a thread message carries a patch touching it), andmajor=(the thread landed a commit that first shipped in that major, e.g.major=17). All three are extraction-tier filters — no fuzzy matching, so pre-2016 threads (beforeDiscussion:trailers) rarely match.
What the 'pg' config does
-
Synonyms.
Product spellings collapse to one term —
postgres,postgresql, andpgsqlall match. Common abbreviations map to their canonical term:autovac→autovacuum,txn→transaction,idx→index. -
Loanword plurals.
Latin/Greek plurals fold to their singular stem —
indicesmatchesindex,matrices→matrix,schemata→schema. -
Noise filtering.
Emails, URLs, and bare numbers are dropped from the
index, so a search for
vacuumisn't drowned by quoted headers and log lines.
Response & error shapes
Every success is wrapped in a
data
envelope — an array for collections (with cursors alongside), an object for single
resources.
{ "data": [ … ], "next_cursor": "…", "prev_cursor": null }
Errors carry a stable code
slug
(decoupled from the HTTP status and never renamed), a human message, and — only when
relevant — details.
{ "error": { "code": "invalid_params", "message": "…", "details": { "param": "limit" } } }
| Code | HTTP | When |
|---|---|---|
| invalid_message_id | 400 | The message-id in the URL is not valid. |
| invalid_cursor | 400 | The pagination cursor is not valid. |
| invalid_query | 400 | A blank or missing search query (q). |
| invalid_params | 422 | A bad limit, sort, dir, from/to, or after+before together. |
| unauthorized | 401 | Missing, malformed, or expired API key. |
| not_found | 404 | No resource with that identifier. |
| internal_error | 500 | An unexpected server error. |
Examples
Full-text search, relevance order, filtered by sender and date:
curl -H "Authorization: Bearer $KEY" \
"https://horton.kehvyn.dev/api/v1/search?q=replication+lag&sender=tom&from=2024-01-01&limit=25"
Messages on one list over a date range, oldest first:
curl -H "X-API-Key: $KEY" \
"https://horton.kehvyn.dev/api/v1/messages?list=pgsql-hackers&from=2023-06-01&to=2023-06-30&dir=asc&limit=50"
Senders ranked by post count:
curl -H "Authorization: Bearer $KEY" \
"https://horton.kehvyn.dev/api/v1/senders?sort=messages&dir=desc&limit=20"