|
| 1 | +# Post rankings |
| 2 | + |
| 3 | +`rank_posts` ranks posts by one metric from Substack's dashboard email |
| 4 | +statistics. It is a single read and never changes anything. |
| 5 | + |
| 6 | +```json |
| 7 | +{ "metric": "subscribes", "direction": "desc", "limit": 10, "offset": 0 } |
| 8 | +``` |
| 9 | + |
| 10 | +| Input | Values | Default | |
| 11 | +| --- | --- | --- | |
| 12 | +| `metric` | `views`, `opened`, `sent`, `open_rate`, `click_through_rate`, `signups`, `subscribes`, `estimated_value`, `post_date` | `views` | |
| 13 | +| `direction` | `desc`, `asc` | `desc` | |
| 14 | +| `limit` | 1–20 | 10 | |
| 15 | +| `offset` | 0 or more | 0 | |
| 16 | + |
| 17 | +Only metrics whose sorting was checked against the live endpoint are accepted. |
| 18 | +The endpoint silently accepts unknown sort fields and misspelled parameters, so |
| 19 | +anything else is rejected before a request is made. No filters are supported: |
| 20 | +an extra argument such as `section_id` is rejected rather than ignored, so a |
| 21 | +result is never presented as filtered when it is not. Substack rejects page |
| 22 | +sizes above 20. |
| 23 | + |
| 24 | +## Result |
| 25 | + |
| 26 | +Each row has its `rank` (position from `offset + 1`), `post_id`, `title`, |
| 27 | +`post_date`, `type`, the ranked `value`, a `value_state`, a fixed set of |
| 28 | +`metrics`, and `absent_metrics`. The page also reports `total`, `returned`, |
| 29 | +`has_more`, `next_offset` and `unreported_in_page`. |
| 30 | + |
| 31 | +| `value_state` | Meaning | |
| 32 | +| --- | --- | |
| 33 | +| `reported` | Substack returned a number (or date) | |
| 34 | +| `null` | Substack returned `null` | |
| 35 | +| `absent` | The row omitted the field | |
| 36 | + |
| 37 | +`null` and `absent` are not zero, and this server never fills them in. In |
| 38 | +`metrics`, both appear as `null`; `absent_metrics` names the omitted fields. |
| 39 | + |
| 40 | +## Ordering and coverage |
| 41 | + |
| 42 | +Rows keep Substack's order; nothing is re-sorted. Checked on September 14, 2026: |
| 43 | + |
| 44 | +- Numeric values are correctly ordered in both directions for every accepted metric. |
| 45 | +- Rows missing a count field come last in descending order and first in ascending order. |
| 46 | +- For `open_rate` and `click_through_rate`, rows with a `null` rate are placed |
| 47 | + among the numeric rows, not at one end. Treat those positions as unranked. |
| 48 | + |
| 49 | +`total` is Substack's count for its email statistics list. It may exclude posts |
| 50 | +without email statistics, such as posts that were never emailed. Each page is a |
| 51 | +separate read, so rankings can shift between calls if statistics change. |
| 52 | + |
| 53 | +A page must agree with `total`. The page at `offset == total` is empty, which |
| 54 | +ends continuation. An empty or short page before that point, or rows past |
| 55 | +`total`, is rejected instead of being reported as the end of the list. |
| 56 | + |
| 57 | +## Metric meanings |
| 58 | + |
| 59 | +Values are passed through as Substack reports them. Substack does not document |
| 60 | +the denominators or units of `open_rate` and `click_through_rate`, so this server |
| 61 | +does not describe them as percentages of any particular count. `opened` and |
| 62 | +`sent` are Substack's own counts; they are not unique-reader counts unless |
| 63 | +Substack says so. `estimated_value` is Substack's estimate. |
| 64 | + |
| 65 | +`post_date` is Substack's timestamp string, returned unchanged. It must parse as |
| 66 | +a date; its timezone is whatever the string states. |
| 67 | + |
| 68 | +## One post by ID |
| 69 | + |
| 70 | +`get_post_analytics` reads a single post's statistics from the published feed, |
| 71 | +searching the 500 most recent published posts. When the post is not found, |
| 72 | +`search_result` says why. `archive_exhausted` is only reported when the pages agree: a short final |
| 73 | +page with no contradicting total, or full pages that exactly reach a total reported identically on |
| 74 | +every page. Every page is still a separate offset read, not an atomic snapshot. If one post is |
| 75 | +published and another deleted between reads, the total can stay the same while a post shifts past a |
| 76 | +page boundary unseen, so `archive_exhausted` means the search reached the end of the feed as paged, |
| 77 | +not proof that the post never existed. Retry when the feed may be changing. |
| 78 | + |
| 79 | +| `search_result` | Meaning | |
| 80 | +| --- | --- | |
| 81 | +| `archive_exhausted` | The search reached the end of the feed as paged, with consistent pages | |
| 82 | +| `scan_bound_reached` | The 500-post bound was reached first. An older post may exist; its statistics are unknown here, not absent | |
| 83 | +| `feed_incomplete` | The feed's pages were incomplete or inconsistent: fewer posts than the reported total, a total that changed between pages or appeared on only some pages, more posts than the total, or a post repeated across pages. The search cannot rule the post out; its statistics are unknown here | |
| 84 | + |
| 85 | +`scanned` is the number of posts examined. `feed_capped` passes through the |
| 86 | +feed's `isCapped` flag, uninterpreted, or `null` when Substack omits it. A found |
| 87 | +post has `stats_available: false` when Substack returned no statistics for it; |
| 88 | +its metric fields are then `null`, not zero. |
| 89 | + |
| 90 | +## Errors |
| 91 | + |
| 92 | +HTTP 403 or 404 from the statistics endpoint returns `code: |
| 93 | +"analytics_unavailable"` with its `status`. It means Substack did not provide |
| 94 | +statistics to this account or publication, not that the ranking is empty. This |
| 95 | +server does not check publication tier or eligibility in advance; it reports what |
| 96 | +Substack returns. |
| 97 | + |
| 98 | +Rate limiting and other upstream failures return the standard read error with |
| 99 | +`status` and a validated `retry_after` when Substack provides one; there is no |
| 100 | +automatic retry. A response that does not match the expected shape (including |
| 101 | +more rows than requested, duplicate posts or non-numeric metrics) is rejected |
| 102 | +without returning partial results. |
0 commit comments