Deconstructing the new Ghost analytics endpoints

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

Deconstructing the new Ghost analytics endpoints
πŸ’‘
If you just wanted to read a non-geeky description of what you can do with analytics without writing code, you probably want this great page in the Ghost docs. Nothing to see here, move along!

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:

πŸ‘‰
Big hint: These /ghost endpoints takes a token produced from the owner or admin user's staff token (from the user's profile page). They do not take an admin API key. Need help generating the token? See the end of this post!

"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

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!