API

A small read-only JSON API for third-party integrators: reaction aggregators, dashboard widgets, IndieWeb tools. CORS is open, no key is required, and every endpoint is rate-limited per IP.

Voter identities are never exposed. Vote counts are aggregate only. If you need the list of people who liked, boosted, or replied to the Fediverse post that announces an entry, see the proxied Fediverse endpoints further down — they expose Mastodon-compatible JSON without requiring any access token on your side.

Typical flow

  1. Call /api/resolve?url= once per post URL, cache the ID locally.
  2. Call /api/entry/{id} whenever you need fresh metadata.

GET /api/resolve

Maps a third-party blog post URL to a Bubbles entry ID.

Query: url (required) — the canonical URL of the post.

Response 200:

{
  "id": 114467,
  "url": "https://bubbles.town/entry/114467"
}

404 — URL is not known to Bubbles (either not imported, or the blog isn't listed).

Rate limit: 60 requests per IP per minute. Results are cacheable for 60 s.

Example:

curl 'https://bubbles.town/api/resolve?url=https://example.com/my-post'

GET /api/entry/{id}

Aggregate metadata for a single entry. The ID is stable over the entry's lifetime.

Path param: the numeric ID returned by /api/resolve (or visible in the URL of any /entry/… page).

Response 200:

{
  "id": 114467,
  "url": "https://bubbles.town/entry/114467",
  "source_url": "https://example.com/my-post",
  "title": "Post Title",
  "published_at": "2026-04-19T14:23:11Z",
  "imported_at": "2026-04-19T14:25:02Z",
  "language": "en",
  "category": "tech",
  "votes": 4,
  "comments": 2,
  "last_voted_at": "2026-04-20T09:12:44Z",
  "fediverse_url": "https://social.bubbles.town/@bubbles/statuses/01ABC...",
  "blog": {
    "id": 42,
    "name": "Example Blog",
    "url": "https://example.com"
  }
}

fediverse_url is null if no Fediverse post was created for this entry (rare: inactive blogs, import errors). last_voted_at is null if the entry has no votes yet.

404 — no entry with that ID.

Rate limit: 120 requests per IP per minute. Results are cacheable for 60 s.

Example:

curl 'https://bubbles.town/api/entry/114467'

GET /api/blog/{id}

Public metadata for a blog listed on Bubbles.

Response 200:

{
  "id": 42,
  "name": "Example Blog",
  "url": "https://example.com",
  "feed_url": "https://example.com/feed.xml",
  "category": "tech",
  "categories": [
    { "category": "tech",    "count": 98, "percent": 72 },
    { "category": "science", "count": 24, "percent": 17 },
    { "category": "life",    "count": 15, "percent": 11 }
  ],
  "language": "en",
  "active": true,
  "entries": 137,
  "votes": 412,
  "followers": 9
}

category is the blog's primary label (the most frequent one in categories). categories lists the full distribution of entry-level categories, sorted by frequency descending. percent is rounded to integer and may not sum to 100 due to rounding.

404 — no blog with that ID, or the blog has been removed from the public listing.

Rate limit: 60 requests per IP per minute. Results are cacheable for 5 minutes.

GET /api/stats

Global aggregate counts for Bubbles as a whole. Useful for About-Bubbles widgets and dashboards.

Response 200:

{
  "blogs": 4960,
  "entries": 21543,
  "votes": 1408,
  "users": 148
}

Rate limit: 60 requests per IP per minute. Results are cacheable for 5 minutes.

GET /api/vote-count

Lightweight endpoint used by the embeddable vote-count widget. Returns only id and count.

Query: url (required).

Response 200:

{ "id": 114467, "count": 4 }

Prefer /api/resolve + /api/entry/{id} for anything beyond a simple counter. This endpoint exists for the embed widget and its backward-compatible consumers.

Fediverse reactions (proxied)

Bubbles announces every entry as a Fediverse post on social.bubbles.town (a GoToSocial instance). To let third-party plugins read replies, likes, and boosts of those posts without holding access tokens, three Mastodon-compatible read endpoints are exposed through Bubbles itself. CORS is open, no key is required, the wire format matches Mastodon's exactly.

Each endpoint takes the Fediverse status ID as its path segment. You'll find that ID inside fediverse_url from /api/entry/{id}: the trailing path segment after the last slash.

GET /api/fediverse/statuses/{id}/context

Replies (descendants) and parent posts (ancestors) of the status. Used to render comment threads.

Response 200:

{
  "ancestors": [],
  "descendants": [
    {
      "id": "01KPS4E170M4C5B9QP735A9VE2",
      "in_reply_to_id": "01KPRTS777089KEK0JTTS4N75J",
      "created_at": "2026-04-21T23:01:32.000Z",
      "url": "https://scalie.zone/@aks/116445183503640190",
      "content": "<p>@bubbles Big thanks for this site…</p>",
      "language": "en",
      "visibility": "public",
      "account": {
        "id": "01KPGK7PZAJPPE187M5NN0V5C8",
        "acct": "aks@scalie.zone",
        "display_name": "Akseli",
        "url": "https://scalie.zone/@aks",
        "avatar": "…"
      }
    }
  ]
}

GET /api/fediverse/statuses/{id}/favourited_by

Accounts that liked the status.

Response 200:

[
  {
    "id": "01KDX3T6KGHCSB6TM2BVN0VT99",
    "acct": "alice@mastodon.social",
    "display_name": "Alice",
    "url": "https://mastodon.social/@alice",
    "avatar": "…"
  }
]

GET /api/fediverse/statuses/{id}/reblogged_by

Accounts that boosted (reblogged) the status. Same shape as favourited_by.

Behaviour notes

  • Status ID format: case-insensitive alphanumeric, 8–32 characters. Anything else returns 404.
  • Endpoint allowlist: only the three paths above are forwarded. Other Mastodon endpoints under the same prefix are 404.
  • Caching: responses are cached server-side for 5 minutes per (id, endpoint); the response carries Cache-Control: public, max-age=300.
  • Rate limit: 30 requests per IP per minute. Plus a global ceiling of 60 outbound calls per minute as defence-in-depth.
  • Bad gateway: 502 if the upstream GoToSocial instance is unreachable.

Example:

curl 'https://bubbles.town/api/fediverse/statuses/01KPRTS777089KEK0JTTS4N75J/context'

Errors

All endpoints return plain JSON on 4xx with an error field:

{ "error": "missing url" }

429 means you hit the rate limit; back off and try again in a minute.

Questions

Mail hello@bubbles.town. If you build something on top of this, we'd love to see it.