SDK guides

Engagement data

The Social service exposes a GET /counts endpoint that returns aggregate engagement counts — likes, shares, and subscribes — for any publication or article. Use it to display live metrics alongside your content.

Note

This endpoint is separate from the @scribe-atp/social React components. The components handle the OAuth interaction; GET /counts is for reading aggregate totals, with no authentication required.

Endpoint

GET https://social.scribe-atp.app/counts

The endpoint is publicly accessible from allowed origins (see CORS below). No API key or token is required.

Parameters

ParamRequiredDescription
action_typeyesrecommend, subscribe, or share
publication_urinoFilter to a specific site — AT URI of the site.standard.publication record
document_urinoFilter to a specific article — AT URI of the site.standard.document record
originnoFilter to events from a specific site origin, e.g. https://norobots.blog
fromnoStart of window — ISO 8601 or relative (-7d, -14d, -30d)
tonoEnd of window — ISO 8601 or relative; defaults to now
group_bynodocument_uri, did, or day
order_bynocount (default) or date; only applies with group_by
limitno1–100, default 10; only applies with group_by

Careful

AT Protocol uses recommend — not like — as the action_type for likes. This matches the underlying site.standard.graph.recommend collection name. Passing like will return a 400 error.

Basic usage

Fetch the total number of likes for an article:

const res = await fetch(
  `https://social.scribe-atp.app/counts?action_type=recommend&document_uri=${encodeURIComponent(documentUri)}`
);
const { count } = await res.json();
// count: 42

Fetch the current subscriber count for a site:

const res = await fetch(
  `https://social.scribe-atp.app/counts?action_type=subscribe&publication_uri=${encodeURIComponent(publicationUri)}`
);
const { count } = await res.json();
// count: 17

Time windows

Pass from to scope the count to a recent period. Relative shorthand is accepted:

ValueMeaning
-7dPast 7 days
-14dPast 14 days
-30dPast 30 days
ISO 8601e.g. 2026-06-01T00:00:00Z

Combine from and to to query a specific window — useful for week-on-week comparisons:

// This week
const thisWeek = await fetch(
  `https://social.scribe-atp.app/counts?action_type=subscribe&publication_uri=${encodeURIComponent(publicationUri)}&from=-7d`
).then((r) => r.json());

// The week before
const lastWeek = await fetch(
  `https://social.scribe-atp.app/counts?action_type=subscribe&publication_uri=${encodeURIComponent(publicationUri)}&from=-14d&to=-7d`
).then((r) => r.json());

const delta = thisWeek.count - lastWeek.count;

Note

Date ranges are capped at 90 days. Requests spanning a longer period will return a 400 error.

Grouped results

Add group_by to break the count down by article, reader, or day. The response shape changes to include a groups array:

{
  "groups": [
    { "key": "at://did:plc:.../site.standard.document/3mp...", "count": 17 },
    { "key": "at://did:plc:.../site.standard.document/3mq...", "count":  9 }
  ],
  "total": 42
}

Most shared articles in the past 30 days:

const res = await fetch(
  `https://social.scribe-atp.app/counts?action_type=share&from=-30d&group_by=document_uri&order_by=count&limit=10`
);
const { groups, total } = await res.json();

Daily like counts over the past week (for a sparkline):

const res = await fetch(
  `https://social.scribe-atp.app/counts?action_type=recommend&publication_uri=${encodeURIComponent(publicationUri)}&from=-7d&group_by=day&order_by=date`
);
const { groups } = await res.json();
// groups: [{ key: "2026-06-26", count: 3 }, { key: "2026-06-27", count: 1 }, ...]

Fetching counts in your loader

For SSR frameworks, fetch counts in your route loader alongside the article to avoid client-side waterfalls:

// app/routes/blog.$slug.tsx — React Router v7/v8
export async function loader({ request, params }: LoaderFunctionArgs) {
  const { article, uri: documentUri } = await fetchArticleBySlug(
    'alice.bsky.social',
    'https://alice.bsky.social',
    params.slug,
    request.signal,
  );
  const likeCount = await fetch(
    `https://social.scribe-atp.app/counts?action_type=recommend&document_uri=${encodeURIComponent(documentUri)}`
  ).then((r) => r.json()).then((d) => d.count as number).catch(() => null);
  return { article, likeCount };
}

The .catch(() => null) ensures a social service outage does not break your article route.

CORS and allowed origins

GET /counts includes CORS headers that allow browser requests from these origins:

  • https://norobots.blog
  • https://anthonycregan.co.uk
  • https://www.anthonycregan.co.uk
  • https://perpetualsummer.ltd
  • https://www.perpetualsummer.ltd
  • https://skyscribe.app

Requests from other origins will be blocked by the browser's CORS policy. Server-side fetch calls (in a loader or API route) are not subject to CORS and will work from any origin.

Tip

If you are building a site on the SkyScribe platform and need your origin added to the allowlist, contact the SkyScribe team.

Rate limits

The endpoint is rate limited to 60 requests per minute per IP address. Requests exceeding this limit receive a 429 Too Many Requests response. For most sites, fetching counts in a server-side loader on each page view is well within this limit.

Getting the AT URIs

The AT URIs you need to pass as publication_uri or document_uri come from your site and article data:

import { fetchSite, fetchArticleBySlug } from '@scribe-atp/core';

const site = await fetchSite('alice.bsky.social', 'https://alice.bsky.social');
// site.uri → publication_uri

const { article, uri: documentUri } = await fetchArticleBySlug('alice.bsky.social', 'https://alice.bsky.social', slug);
// documentUri → document_uri

See Core Concepts for more on AT URIs.

Author endpoint: GET /events

GET https://social.scribe-atp.app/events

This endpoint is protected — it requires an Authorization: Bearer <NOTIFY_SECRET> header (the same shared secret used for the /notify route). It is intended for the author's own tooling and analytics, not for reader-facing sites.

Use it to query raw action event records for your own analytics dashboards or scripts.

Parameters

ParamRequiredDescription
action_typeyesrecommend, subscribe, or share
publication_urinoFilter by publication AT URI
document_urinoFilter by document AT URI
didnoFilter by reader DID
fromnoISO 8601 or relative (-7d, -14d, -30d)
tonoISO 8601 or relative; defaults to now
limitno1–100, default 50
offsetnoDefault 0

Response

{
  "events": [
    {
      "action_type": "recommend",
      "did": "did:plc:...",
      "document_uri": "at://did:plc:.../site.standard.document/3mp...",
      "publication_uri": "at://did:plc:.../site.standard.publication/3mp...",
      "origin": "https://norobots.blog",
      "created_at": 1782995765
    }
  ],
  "total": 42
}

Example

// Who has liked a specific article?
const res = await fetch(
  `https://social.scribe-atp.app/events?action_type=recommend&document_uri=${encodeURIComponent(documentUri)}`,
  { headers: { Authorization: `Bearer ${process.env.NOTIFY_SECRET}` } }
);
const { events, total } = await res.json();

Careful

events contain reader DIDs — personal identifiers tied to individual Bluesky accounts. Handle with care and do not expose this endpoint or its data publicly.

Settings

Appearance