corpus.blog/api

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_pages and recent on a domain, citations and outbound on an article, come back as { data, meta }, 10 to a page and never more than 25 regardless of what per_page asks 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's citing_blogs cannot surface a 21st source; it isn't there to find.
  • ETag and Last-Modified on 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.