Everything the record holds about one blog and one post, as JSON
The same two records the pages render, as data. Metadata and the link graph only: no body, no markdown, no extract, ever.
Endpoints
GET /api/v1/domains/{domain}
The blog: identity, counts, first and last published, citing blogs, cited pages, recent posts.
GET /api/v1/articles/{id}
The post: title, URL, domain, dates, word count, who cites it, what it links to.
GET /api/v1/articles?url=…
Look a post up by its URL. Resolution runs through articles.url_key, the same generated column the corpus matches on, so scheme, www., tracking parameters and a trailing slash are normalised away — a pasted link finds the row.
Rules
- Versioned in the path from the first request. The corpus's fields are still moving, and nullable-and-documented beats absent-then-added on a promise this public.
- Paginated, with a real ceiling underneath the pages.
citing_blogs,cited_pagesandrecenton a domain,citationsandoutboundon an article, come back as{ data, meta }, 10 to a page and never more than 25 regardless of whatper_pageasks for. But the page size is not the limit that matters: the actions behind these routes already cap what they compute — 20 cited pages, 20 citing blogs, 15 recent posts, 50 citations, all of it all-time — before pagination ever slices it. Paging further into a domain'sciting_blogscannot surface a 21st source; it isn't there to find. ETagandLast-Modifiedon every response, so a client polling the same record gets a bodyless 304 once nothing has changed rather than paying for the citation-graph query and the JSON encode again.- 60 requests a minute, by IP. Every route here is public and unauthenticated, so the caller's address is what the limiter has to key on.
- 404 for anything we do not hold, never 403. A domain or an article id this corpus has no row for reads exactly like one that was never asked about — the endpoint is not built to answer "does this exist," only to hand back a record when there is one.
- Never a text field. No body, no markdown, no extract, no summary — the same rule as decision 0002, "Private text, public record." A test,
returns no body text, markdown or extract field, ever, walks both payloads at every nesting depth so a field added later to either action or either controller fails it rather than quietly reintroducing article text.
Sample response
GET /api/v1/domains/jvns.ca
{
"website": {
"id": "01a07d58-e17d-7214-983c-b66e43e1de92",
"domain": "jvns.ca",
"name": "Julia Evans",
"category": "personal_dev",
"language": "en",
"articles": 20,
"first_published": "2024-11-04T00:00:00+00:00",
"last_published": "2026-07-21T00:00:00+00:00"
},
"sources": 250,
"links": 442,
"cited_pages": {
"data": [
{
"url": "https://jvns.ca/blog/brag-documents/",
"article_id": "0198f1e2-9c3d-7b21-9d4a-2f6a1c8e0b77",
"title": "Get your work recognized: write a brag document",
"sources": 39
}
],
"meta": { "page": 1, "per_page": 10, "total": 1, "last_page": 1 }
},
"citing_blogs": {
"data": [
{ "domain": "danluu.com", "name": "Dan Luu", "links": 9, "posts": 8 },
{ "domain": "thechels.uk", "name": null, "links": 9, "posts": 3 }
],
"meta": { "page": 1, "per_page": 10, "total": 2, "last_page": 1 }
},
"recent": {
"data": [
{
"id": "0198f1e2-9c3d-7b21-9d4a-2f6a1c8e0b77",
"title": "Get your work recognized: write a brag document",
"url": "https://jvns.ca/blog/brag-documents/",
"published_at": "2024-11-04T00:00:00+00:00"
}
],
"meta": { "page": 1, "per_page": 10, "total": 20, "last_page": 2 }
}
}
What is left out, and why
Provenance — which curated list a blog came from, its state, its model-guessed kind — stays internal: it is judgment about an identifiable person's site, not a fact about the site itself, and it never shipped anywhere near this record even before the API existed. Summaries a model writes from an author's own text are a decision still open, not a field withheld by accident, and are not in v1.
For claimed blogs
The same record, in webmention.io's JSON shape, is planned for a claimed blog: claims, and the same graph turned toward one domain's own dashboard rather than the public page. Claim your blog to be first when it ships.