Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ Keep the session token local and out of source control. All MCP tools are read-o
| `deleteComment(id)` | Permanently deletes an authenticated user's comment. |
| `setNoteRestack(id, restacked, options)` | Restacks or removes a Note restack. |
| `getActivity(filter)` | Activity feed. Filters: `all`, `replies-and-mentions`, `restacks`. |
| `getActivityPage({ filter, after })` | One complete historical activity page with validated `more` and `nextAfter`. |
| `getUnreadActivity()` | Activity feed annotated using Substack's unread count. |
| `getFollowing()` | Accounts followed by the authenticated account. |
| `testConnectivity()` | Whether the session can perform a lightweight API request. |
Expand Down Expand Up @@ -206,6 +207,49 @@ const latestFive = (activity.activityItems ?? []).slice(0, 5)

This is an activity feed, so it can include both replies and mentions. To fetch the comments for one particular post, use `getPostComments(postId)`.

For historical activity, use the separately exported `ActivityPageOptions` and
`ActivityPage` types with `getActivityPage()`:

```ts
const firstPage = await client.getActivityPage({ filter: 'all' })
// The consuming app decides when to request another page.
if (firstPage.nextAfter !== null) {
const olderPage = await client.getActivityPage({
filter: 'all',
after: firstPage.nextAfter
})
}
```

Each call makes exactly one authenticated GET to the global
`/api/v1/activity-feed-web?filter=...` endpoint, adding URL-encoded `after` when
provided. `filter` defaults to `all` and supports the same filters as
`getActivity()`. Input cursors and every item's `updated_at` must be valid ISO
8601 timestamps with a timezone (`Z` or a numeric offset) and at most three
fractional-second digits.

The result preserves all upstream fields, lookup tables, and every
`activityItems` record, adding explicit pagination metadata. `more` must be an
upstream boolean. When `more=true`, `nextAfter` is the **last item's `updated_at`
minus one millisecond**, formatted as a UTC ISO timestamp. For example,
`updated_at: 2026-09-05T21:53:10.058Z` produces
`nextAfter: 2026-09-05T21:53:10.057Z`, even if that item's `created_at` is
`2026-09-05T21:53:10.062Z`. When `more=false`, `nextAfter` is `null`, including
on an empty final page. These rules follow observed native Substack pagination.

Items must be ordered by descending `updated_at` (ties allowed). With an `after`
cursor, no item may be newer than the cursor, and a nonempty page's last item
must be strictly older than it. Invalid input throws `SubstackConfigurationError`
before requesting activity. Malformed responses, missing or invalid timestamps,
unordered items, non-advancing pages, and empty pages with `more=true` throw
`SubstackApiError`; they are never interpreted as exhaustion.

Grouped activity can be created in April and updated in September. This method
preserves such records and never filters or paginates by `created_at`.
`getActivity()` retains its existing raw-response behavior. Date cutoffs,
automatic pagination, retries, scheduling, persistence, and resumable backfills
remain the consuming app's responsibility.

## Publishing Notes

`publishNote` creates public content. Its `bodyJson` is passed directly to Substack's ProseMirror-style Notes API. Create a link or image attachment first, then include its returned ID in `attachmentIds`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "unofficial-substack-sdk",
"version": "0.3.12",
"version": "0.3.13",
"description": "Unofficial, portable TypeScript SDK for Substack's web API",
"type": "module",
"license": "MIT",
Expand Down
9 changes: 8 additions & 1 deletion src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { EndpointContext } from './transport.js'
import type {
ActivityFeed,
ActivityFilter,
ActivityPage,
ActivityPageOptions,
CreateAttachmentRequest,
CursorOptions,
DraftNotesOptions,
Expand Down Expand Up @@ -40,7 +42,7 @@ import type {
UploadedImage,
UpdateScheduledNoteRequest
} from './types.js'
import { getActivity, getUnreadActivity } from '../resources/activity/index.js'
import { getActivity, getActivityPage, getUnreadActivity } from '../resources/activity/index.js'
import { getAllEmailStats, getEmailStats } from '../resources/email-stats/index.js'
import { getGrowthSources } from '../resources/growth/index.js'
import {
Expand Down Expand Up @@ -371,6 +373,11 @@ export class SubstackClient {
return getUnreadActivity(this.endpoints)
}

/** Fetches one complete activity page, validating updated_at ordering and cursor progress. */
getActivityPage(options: ActivityPageOptions = {}): Promise<ActivityPage> {
return getActivityPage(this.endpoints, options)
}

getFollowing(): Promise<unknown> {
return getFollowing(this.endpoints)
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export {
ACTIVITY_FILTERS,
type ActivityFeed,
type ActivityFilter,
type ActivityPage,
type ActivityPageOptions,
type CreateAttachmentRequest,
type CreateImageAttachmentRequest,
type CreateLinkAttachmentRequest,
Expand Down
15 changes: 15 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,21 @@ export type ActivityFeed = {
[key: string]: unknown
}

export interface ActivityPageOptions {
/** Defaults to all. */
filter?: ActivityFilter
/** ISO 8601 timestamp with a timezone and at most millisecond precision. */
after?: string
}

/** A complete upstream activity page with validated historical pagination. */
export type ActivityPage = ActivityFeed & {
activityItems: Array<{ updated_at: string; [key: string]: unknown }>
more: boolean
/** Last updated_at minus 1 ms in UTC, or null when more is false. */
nextAfter: string | null
}

export type UnreadActivityFeed = ActivityFeed & {
activityItems: unknown[]
unread: UnreadActivityMetadata
Expand Down
63 changes: 62 additions & 1 deletion src/resources/activity/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { EndpointContext } from '../../core/transport.js'
import { ACTIVITY_FILTERS, type ActivityFeed, type ActivityFilter, type UnreadActivityFeed } from '../../core/types.js'
import { SubstackApiError, SubstackConfigurationError } from '../../core/errors.js'
import { ACTIVITY_FILTERS, type ActivityFeed, type ActivityFilter, type ActivityPage, type ActivityPageOptions, type UnreadActivityFeed } from '../../core/types.js'

export function isActivityFilter(value: string): value is ActivityFilter {
return (ACTIVITY_FILTERS as readonly string[]).includes(value)
Expand All @@ -9,6 +10,66 @@ export function getActivity(context: EndpointContext, filter: ActivityFilter = '
return context.global(`/activity-feed-web?filter=${encodeURIComponent(filter)}`)
}

// Date.parse alone accepts normalized invalid dates and non-ISO date strings.
function timestamp(value: unknown): number | null {
if (typeof value !== 'string') return null
const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value)
if (!match) return null
const local = `${match[1]}.${(match[2] ?? '').padEnd(3, '0')}Z`
const localTime = Date.parse(local)
if (!Number.isFinite(localTime) || new Date(localTime).toISOString() !== local) return null
if (match[3] !== 'Z' && (Number(match[3]!.slice(1, 3)) > 23 || Number(match[3]!.slice(4)) > 59)) return null
const time = Date.parse(value)
return Number.isFinite(time) ? time : null
}

export async function getActivityPage(
context: EndpointContext,
options: ActivityPageOptions = {}
): Promise<ActivityPage> {
const filter = options.filter ?? 'all'
if (typeof filter !== 'string' || !isActivityFilter(filter)) {
throw new SubstackConfigurationError('Invalid activity filter.')
}
const afterTime = options.after === undefined ? null : timestamp(options.after)
if (options.after !== undefined && afterTime === null) {
throw new SubstackConfigurationError('after must be a valid ISO 8601 timestamp with a timezone and at most millisecond precision.')
}
const path = `/activity-feed-web?filter=${encodeURIComponent(filter)}` +
(options.after === undefined ? '' : `&after=${encodeURIComponent(options.after)}`)
const response = await context.global<unknown>(path)
const invalid = (message: string): never => {
throw new SubstackApiError(`Invalid activity pagination: ${message}`, 502, path)
}
if (!response || typeof response !== 'object' || Array.isArray(response)) {
invalid('expected an object response.')
}
const page = response as Record<string, unknown>
if (!Array.isArray(page.activityItems) || typeof page.more !== 'boolean') {
invalid('activityItems must be an array and more must be a boolean.')
}
const items = page.activityItems as unknown[]
if (page.more && items.length === 0) invalid('an empty page cannot have more=true.')
let previous = Infinity
for (const item of items) {
const time = timestamp(item && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>).updated_at : undefined)
if (time === null) return invalid('each item must have a valid updated_at timestamp.')
if (time > previous) invalid('updated_at values must be in descending order (ties allowed).')
if (afterTime !== null && time > afterTime) invalid('items must not be newer than the supplied after cursor.')
previous = time
}
const nextTime = previous - 1
if (items.length && afterTime !== null && previous >= afterTime) {
invalid('the page must advance beyond the supplied after cursor.')
}
const nextAfter = page.more ? new Date(nextTime).toISOString() : null
if (nextAfter !== null && timestamp(nextAfter) === null) {
invalid('the next cursor is outside the supported timestamp range.')
}
return { ...page, activityItems: items, more: page.more, nextAfter } as ActivityPage
}

export async function getUnreadActivity(context: EndpointContext): Promise<UnreadActivityFeed> {
const [unread, feed] = await Promise.all([
context.global<{ count?: unknown; max?: unknown; lastViewedAt?: unknown }>('/activity/unread'),
Expand Down
120 changes: 120 additions & 0 deletions test/activity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, test } from 'bun:test'
import { type ActivityPage, SubstackApiError, SubstackClient, SubstackConfigurationError } from '../src/index.js'

const recent = '2026-09-06T12:00:00.000Z'
const oldest = '2026-09-05T21:53:10.058Z'
const next = '2026-09-05T21:53:10.057Z'
const item = (updated_at = oldest) => ({ updated_at, created_at: '2026-04-01T00:00:00.000Z' })

function fixture(response: unknown) {
const requests: Request[] = []
const client = new SubstackClient({
sessionToken: 'test-token',
fetch: async (input, init) => {
requests.push(new Request(input, init))
return response === undefined ? new Response('') : Response.json(response)
}
})
return { client, requests }
}

describe('historical activity pages', () => {
test('first page preserves every item and lookup table and uses the verified updated_at cursor', async () => {
const response = {
activityItems: [item(recent), { ...item(), created_at: '2026-09-05T21:53:10.062Z' }],
more: true,
profiles: { '1': { name: 'Example' } },
posts: { '2': { title: 'Example' } },
unknownFutureField: { preserved: true }
}
const { client, requests } = fixture(response)
const page: ActivityPage = await client.getActivityPage()
expect(page).toEqual({ ...response, nextAfter: next })
expect(requests).toHaveLength(1)
expect(requests[0]!.url).toBe('https://substack.com/api/v1/activity-feed-web?filter=all')
expect(requests[0]!.method).toBe('GET')
})

test('encodes the native cursor and requested filter in a single request', async () => {
const { client, requests } = fixture({ activityItems: [item('2026-09-04T00:00:00.000Z')], more: true })
await client.getActivityPage({ filter: 'restacks', after: next })
expect(requests).toHaveLength(1)
expect(requests[0]!.url).toBe('https://substack.com/api/v1/activity-feed-web?filter=restacks&after=2026-09-05T21%3A53%3A10.057Z')
})

test('preserves grouped records regardless of creation date and allows updated_at ties', async () => {
const response = { activityItems: [item(recent), item(recent), item()], more: true }
const { client } = fixture(response)
expect(await client.getActivityPage()).toEqual({ ...response, nextAfter: next })
})

for (const activityItems of [[], [item()]]) {
test(`exhaustion with ${activityItems.length} items returns null`, async () => {
const { client, requests } = fixture({ activityItems, more: false })
expect(await client.getActivityPage({ after: recent })).toEqual({ activityItems, more: false, nextAfter: null })
expect(requests).toHaveLength(1)
})
}

const malformed = [
undefined, null, [], 'invalid', {},
{ more: false }, { activityItems: [], more: 'false' },
{ activityItems: [], more: 0 }, { activityItems: [] },
{ activityItems: {}, more: false }, { activityItems: [], more: true },
{ activityItems: [null], more: false }, { activityItems: [[]], more: true },
{ activityItems: [{ created_at: oldest }], more: true },
{ activityItems: [item('bad')], more: false },
{ activityItems: [item('2026-02-30T00:00:00.000Z')], more: true },
{ activityItems: [item('0000-01-01T00:00:00.000Z')], more: true },
{ activityItems: [item(), item(recent)], more: false }
]
for (const [index, response] of malformed.entries()) {
test(`rejects malformed response ${index} rather than reporting exhaustion`, async () => {
const { client, requests } = fixture(response)
await expect(client.getActivityPage()).rejects.toBeInstanceOf(SubstackApiError)
expect(requests).toHaveLength(1)
})
}

for (const more of [true, false]) {
for (const times of [[recent], [oldest], [recent, '2026-09-04T00:00:00.000Z']]) {
test(`rejects non-advancing or out-of-bound pages: ${times.join(',')}, more=${more}`, async () => {
const { client } = fixture({ activityItems: times.map(time => item(time)), more })
await expect(client.getActivityPage({ after: oldest })).rejects.toBeInstanceOf(SubstackApiError)
})
}
}

for (const after of ['', 'bad', '2026-02-30T00:00:00Z', '2026-09-05', '2026-09-05T21:53:10',
'2026-09-05T24:00:00Z', '2026-09-05T21:53:10.0581Z', '2026-09-05T21:53:10+24:00', null, 123]) {
test(`rejects invalid input cursor ${after} before requesting activity`, async () => {
const { client, requests } = fixture({ activityItems: [], more: false })
await expect(client.getActivityPage({ after: after as string })).rejects.toBeInstanceOf(SubstackConfigurationError)
expect(requests).toHaveLength(0)
})
}

test('validates timezone offsets and compares their instants', async () => {
const { client, requests } = fixture({ activityItems: [item('2026-09-05T23:53:10.058+02:00')], more: true })
expect((await client.getActivityPage({ after: '2026-09-06T00:00:00+02:00' })).nextAfter).toBe(next)
expect(requests[0]!.url).toContain('after=2026-09-06T00%3A00%3A00%2B02%3A00')
})

test('rejects invalid filters without a request', async () => {
const { client, requests } = fixture({ activityItems: [], more: false })
// @ts-expect-error Verify runtime validation for JavaScript callers.
await expect(client.getActivityPage({ filter: 'invalid' })).rejects.toBeInstanceOf(SubstackConfigurationError)
expect(requests).toHaveLength(0)
})

test('legacy getActivity remains raw and permissive with default and explicit filters', async () => {
const response = { activityItems: [{ id: 1 }], more: 'unvalidated', profiles: {} }
const { client, requests } = fixture(response)
expect(await client.getActivity()).toEqual(response)
expect(await client.getActivity('replies-and-mentions')).toEqual(response)
expect(requests.map(request => request.url)).toEqual([
'https://substack.com/api/v1/activity-feed-web?filter=all',
'https://substack.com/api/v1/activity-feed-web?filter=replies-and-mentions'
])
})
})