Deconstructing the new Ghost analytics endpoints
Deep in the weeds of how Ghost is populating the new analytics feature

I was helping another developer building an external dashboard for Ghost get access to the new analytics baked into Ghost. Here's what I learned.
Endpoints:
"Top" posts: /ghost/api/admin/stats/top-posts/
Optional parameters and sample values:
- date_from=2022-12-28
- date_to=2025-09-22
- order=free_members+desc
- post_type=post (or page, or blank to get both)
Sample result:
{
"stats": [
{
"post_id": null,
"attribution_url": "/",
"attribution_type": "url",
"attribution_id": null,
"title": "Homepage",
"published_at": null,
"free_members": 7,
"paid_members": 6,
"mrr": 19248,
"post_type": null,
"url_exists": false
},
{
"post_id": "68a33ca316030a0001dcff43",
"attribution_url": "/discord-sample-page/",
"attribution_type": "page",
"attribution_id": "68a33ca316030a0001dcff43",
"title": "Discord Sample Page",
"published_at": "2025-08-18T14:49:05.000Z",
"free_members": 0,
"paid_members": 3,
"mrr": 5832,
"post_type": "page",
"url_exists": true
}
]
}
Top growth sources /ghost/api/admin/stats/top-sources-growth/
Parameters:
- date_from and date_to (as above)
- limit=20
- member_status=undefined,free,paid
- order=signups+desc
Sample response:
{
"stats": [
{
"source": "Created manually",
"signups": 1,
"paid_conversions": 0,
"mrr": 0
},
{
"source": "Direct",
"signups": 1,
"paid_conversions": 9,
"mrr": 36621
}
],
"meta": {}
}
Subscriptions /ghost/api/admin/stats/subscriptions/
This endpoint also takes date_from and date_to parameters.
{
"stats": [
{
"date": "2025-08-17",
"tier": "68a24f9016030a0001dcfe86",
"cadence": "month",
"positive_delta": 3,
"negative_delta": 0,
"signups": 3,
"cancellations": 0,
"count": 3
},
{
"date": "2025-08-17",
"tier": "68a24f9016030a0001dcfe86",
"cadence": "year",
"positive_delta": 3,
"negative_delta": 0,
"signups": 3,
"cancellations": 0,
"count": 3
},
{
"date": "2025-08-18",
"tier": "66a5236fe87f2c0001e025b6",
"cadence": "year",
"positive_delta": 5,
"negative_delta": 5,
"signups": 1,
"cancellations": 1,
"count": 0
},
{
"date": "2025-08-18",
"tier": "68a24f9016030a0001dcfe86",
"cadence": "year",
"positive_delta": 6,
"negative_delta": 4,
"signups": 2,
"cancellations": 0,
"count": 5
}
],
"meta": {
"cadences": [
"year",
"month"
],
"tiers": [
"68a24f9016030a0001dcfe86",
"66a5236fe87f2c0001e025b6"
],
"totals": [
{
"count": 3,
"tier": "68a24f9016030a0001dcfe86",
"cadence": "month"
},
{
"count": 5,
"tier": "68a24f9016030a0001dcfe86",
"cadence": "year"
}
]
}
}
MRR (revenue) over time /ghost/api/admin/stats/mrr/
I don't think this endpoint takes date arguments, although I'm being hampered by having very limited analytics data in my faked up demo site.
{
"stats": [
{
"date": "2025-08-16",
"mrr": 0,
"currency": "usd"
},
{
"date": "2025-08-17",
"mrr": 19248,
"currency": "usd"
},
{
"date": "2025-08-18",
"mrr": 25080,
"currency": "usd"
}
],
"meta": {
"totals": [
{
"currency": "usd",
"mrr": 25080
}
]
}
}
Member count /ghost/api/admin/stats/member_count/
This endpoint takes date_from and date_to.
Sample output:
{
"stats": [
{
"date": "2025-09-16",
"paid": 9,
"free": 2,
"comped": 0,
"paid_subscribed": 0,
"paid_canceled": 0
},
{
"date": "2025-09-17",
"paid": 9,
"free": 2,
"comped": 0,
"paid_subscribed": 0,
"paid_canceled": 0
}
... many lines removed ...
],
"meta": {
"totals": {
"paid": 9,
"free": 2,
"comped": 0
}
}
}
Post-specific endpoints
You can also get per-post stats!
Endpoints:
- /ghost/api/admin/stats/posts/{post_id}/top-referrers/
- /ghost/api/admin/stats/posts/{post_id}/growth/
- There does not appear to be a per-post MRR endpoint. Instead, the request goes to /ghost/api/admin/stats/mrr/ .
Tinybird endpoints
In addition to these endpoints that use the Ghost Admin API, there are also requests going to Tinybird. These take a token, which you can get (with your staff token) from: /ghost/api/admin/tinybird/token/
Then the request for data looks like this:
https://api.tinybird.co/v0/pipes/api_kpis.json?site_uuid=(removed)&date_from=2022-12-28&date_to=2025-09-22&timezone=America%2FNew_York&post_uuid=&from=chart&token=(removed)
Sample result:
{
"meta": [
...
],
"data": [
{
"date": "2022-12-28",
"visits": 0,
"pageviews": 0,
"bounce_rate": 0,
"avg_session_sec": 0
},
... lots more dates ...
There's also a top sources endpoint:
https://api.tinybird.co/v0/pipes/api_top_sources.json?site_uuid=(removed)&date_from=2025-08-28&date_to=2025-09-22&timezone=America%2FNew_York&post_uuid=(removed)&from=chart&token=(removed)
Sample result:
{
"meta": [
...
],
"data": [
{
"source": "LinkedIn",
"visits": 25
},
{
"source": "",
"visits": 16
},
...
Email stats
/ghost/api/admin/links/
Parameters:
- Filter (i.e. post_id:'{post_id}' url encoded)
{
"links": [
{
"post_id": "68b05fb1e8d44f1231db5634",
"link": {
"link_id": "68b07d61e8d44f0001db5ad1",
"from": "<url>",
"to": "<url>",
"edited": false
},
"count": {
"clicks": 12
}
}
/ghost/api/admin/stats/newsletter-basic-stats/
parameter: newsletter_id
{
"stats": [
{
"post_id": "68b05fb1e8d456f0001db634",
"post_title": "Some post",
"send_date": "2025-03-21T16:01:36.000Z",
"sent_to": 1040,
"total_opens": 629,
"open_rate": 0.6048
}
.... many lines skipped ...
]
}
/ghost/api/admin/stats/newsletter-click-stats/
Parameters:
- newsletter_id
- post_ids=(comma separated list, encoded)
{
"stats": [
{
"post_id": "664d1f2fdee1d20001ae1367",
"total_clicks": 28,
"email_count": 505,
"click_rate": 0.0554
},
{
"post_id": "667aabc1c4c30a0001eb5c96",
"total_clicks": 42,
"email_count": 573,
"click_rate": 0.0733
},
π‘Quick tip: API keys need to be converted to tokens. Here's how:
Here's a small utility for making requests from Ghost admin API endpoints, without using the SDK.
// ghost-client.js //
import fetch from 'node-fetch';
import crypto from 'crypto';
class GhostAdminClient {
constructor(url, adminApiKey) {
this.url = url.replace(/\/$/, ''); // Remove trailing slash
this.adminApiKey = adminApiKey;
this.apiVersion = 'v5.0'; // Current Ghost Admin API version
this.baseUrl = `${this.url}/ghost/api/admin`;
}
/**
* Generate JWT token for Admin API authentication
* @returns {string} JWT token
*/
generateToken() {
const [id, secret] = this.adminApiKey.split(':');
// Create the token header
const header = {
alg: 'HS256',
typ: 'JWT',
kid: id
};
// Create the token payload
const payload = {
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (5 * 60), // 5 minutes from now
aud: '/admin/'
};
// Encode header and payload
const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
// Create signature
const unsignedToken = `${encodedHeader}.${encodedPayload}`;
const signature = crypto
.createHmac('sha256', Buffer.from(secret, 'hex'))
.update(unsignedToken)
.digest('base64url');
return `${unsignedToken}.${signature}`;
}
/**
* Make authenticated API request
* @param {string} endpoint - API endpoint (without /ghost/api/admin prefix)
* @param {Object} options - Fetch options
* @returns {Promise<Object>} API response
*/
async makeRequest(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const token = this.generateToken();
const defaultOptions = {
headers: {
'Authorization': `Ghost ${token}`,
'Content-Type': 'application/json',
'Accept-Version': this.apiVersion,
...options.headers
}
};
const requestOptions = {
...defaultOptions,
...options,
headers: {
...defaultOptions.headers,
...options.headers
}
};
try {
const response = await fetch(url, requestOptions);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Ghost API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
}
return await response.json();
} catch (error) {
console.error('Ghost API request failed:', error);
throw error;
}
}
import GhostAdminClient from './ghost-client.js';
const GHOST_URL = 'https://your-admin-domain';
const ADMIN_API_KEY = 'your-api-key-or-staff-token'
const ghost = new GhostAdminClient(GHOST_URL, ADMIN_API_KEY);
const analyticsData = await ghost.makeRequest('/stats/top-posts/', {
method: 'GET'
});
console.log(JSON.stringify(analyticsData, null, 2));
Hey, before you go... If your finances allow you to keep this tea-drinking ghost and the freelancer behind her supplied with our hot beverage of choice, we'd both appreciate it!
