How to Build a Stripe MRR Dashboard (Without Building a Frontend)

What you'll build

A live MRR dashboard with four widgets:

  • MRR (KPI) — current monthly recurring revenue

  • MRR over time (line chart) — last 90 days

  • New vs. churned subscriptions (bar chart) — last 30 days

  • Top customers by revenue (table) — top 10

You'll write 4 small endpoints in your existing backend, point dashboardbase at them, and that's it. Total time: ~15 minutes if you already have Stripe set up.

Why not just use Stripe's built-in dashboard?

Stripe's dashboard is fine for accounting. It's not great for:

  • Combining Stripe data with your own product data (e.g. "MRR vs. weekly active users")

  • Sharing one read-only view with a co-founder, advisor, or investor

  • Viewing it on your phone with a single tap (Stripe's mobile experience is heavy)

  • Customizing what counts as "MRR" for your business

If any of those matter, you want a dashboard layer on top of Stripe. The cheap way to do that is: write a few endpoints in your backend that compute exactly what you want, and let a tool render them.

Prerequisites

  • A Stripe account with subscription data

  • A backend in any language (examples below in Node.js, but the shape is the same in Python, Go, Ruby, PHP)

  • A dashboardbase account

Step 1: The MRR endpoint

The MRR widget needs a single headline number. Here's the JSON contract:

{
  "title": "MRR",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/mrr-details"
    }
  ],
  "data": {
    "header": {
      "title": "$12,480",
      "subtitle": "vs last month",
      "badge": {
        "text": "+12.4%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}

And the Node.js endpoint:

// GET /dashboards/stripe/mrr
app.get('/dashboards/stripe/mrr', async (req, res) => {
  const subs = await stripe.subscriptions.list({
    status: 'active',
    limit: 100,
    expand: ['data.items.data.price'],
  });

  // Sum monthly-equivalent revenue across all active subs
  const mrr = subs.data.reduce((total, sub) => {
    return total + sub.items.data.reduce((sum, item) => {
      const price = item.price.unit_amount / 100;
      const interval = item.price.recurring.interval;
      const monthly = interval === 'year' ? price / 12 : price;
      return sum + (monthly * item.quantity);
    }, 0);
  }, 0);

  // Compare to MRR 30 days ago (you'd compute this from your own snapshots)
  const previous = await getMrrSnapshot(30);
  const trend = previous ? ((mrr - previous) / previous * 100).toFixed(1) : 0;
  const up = trend >= 0;

  res.json({
    title: 'MRR',
    actions: [
      { title: 'View Details', type: 'link', url: 'https://example.com/mrr-details' },
    ],
    data: {
      header: {
        title: `$${Math.round(mrr).toLocaleString('en-US')}`,
        subtitle: 'vs last month',
        badge: {
          text: `${up ? '+' : ''}${trend}%`,
          icon: up ? 'ArrowUp' : 'ArrowDown',
          color: up ? 'Success' : 'Danger',
        },
      },
    },
  });
});

Tip: If you don't already snapshot MRR daily, do this now in a cron job. You'll thank yourself in 6 months when you want to chart MRR over time and don't have the historical data.

Two shortcuts before you write the other three. You don't have to hand-write these endpoints or guess whether the JSON is right:

  • Generate them. Install the free dashboardbase Skill and Claude Code or Cursor knows the whole contract — "add a KPI endpoint for Stripe MRR" produces the right shape first try. Or skip the install: the widget editor generates a copy-pasteable prompt (per widget, or one covering the whole dashboard) for whatever AI tool you already use. It's a draft you review and run yourself; nothing executes on our side.

  • Check them. Paste the response into the endpoint validator — no account needed — and it tells you whether the KPI widget can render it, with a preview of the actual widget.

Step 2: The MRR-over-time endpoint

The line chart widget expects time-series data:

{
  "title": "MRR over time",
  "data": {
    "header": {
      "title": "$10,100",
      "subtitle": "Last 90 days",
      "badge": {
        "text": "+3%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    },
    "labels": [
      "2026-02-01",
      "2026-02-02",
      "2026-02-03"
    ],
    "datasets": [
      {
        "data": [
          {
            "value": 9800
          },
          {
            "value": 9850
          },
          {
            "value": 10100
          }
        ],
        "label": "MRR"
      }
    ]
  }
}

The labels array holds one entry per point on the x-axis, and each point in datasets[].data is an object with a value — not an x/y pair. And the endpoint:

app.get('/dashboards/stripe/mrr-history', async (req, res) => {
  const days = parseInt(req.query.days) || 90;
  const snapshots = await db.mrrSnapshots.findMany({
    where: { date: { gte: subDays(new Date(), days) } },
    orderBy: { date: 'asc' },
  });

  const first = snapshots[0]?.mrr ?? 0;
  const last = snapshots[snapshots.length - 1]?.mrr ?? 0;
  const change = first ? ((last - first) / first * 100).toFixed(0) : 0;
  const up = change >= 0;

  res.json({
    title: 'MRR over time',
    data: {
      header: {
        title: `$${last.toLocaleString('en-US')}`,
        subtitle: `Last ${days} days`,
        badge: {
          text: `${up ? '+' : ''}${change}%`,
          icon: up ? 'ArrowUp' : 'ArrowDown',
          color: up ? 'Success' : 'Danger',
        },
      },
      labels: snapshots.map(s => s.date.toISOString().slice(0, 10)),
      datasets: [{
        data: snapshots.map(s => ({ value: s.mrr })),
        label: 'MRR',
      }],
    },
  });
});

This endpoint takes a days query param so you can wire it up to dashboardbase's date range selector. When the user picks "Last 30 days" on the dashboard, that gets passed through.

Step 3: New vs. churned subscriptions

Bar chart contract:

{
  "title": "New vs churned",
  "data": {
    "header": {
      "title": "27",
      "subtitle": "New subscriptions, last 30 days"
    },
    "labels": [
      "Week 1",
      "Week 2"
    ],
    "datasets": [
      {
        "data": [
          {
            "value": 12
          },
          {
            "value": 15
          }
        ],
        "label": "New"
      },
      {
        "data": [
          {
            "value": 3
          },
          {
            "value": 5
          }
        ],
        "label": "Churned"
      }
    ]
  }
}

Two series means two entries in datasets, both indexed against the same labels array.

You compute this from Stripe events (customer.subscription.created and customer.subscription.deleted) — ideally from your own webhook log, not by polling Stripe. If you're not logging Stripe webhooks yet, set that up first; it'll save you from a dozen future headaches.

Step 4: Top customers by revenue

Table contract:

{
  "title": "Top customers",
  "actions": [
    {
      "title": "View All",
      "type": "link",
      "url": "https://example.com/customers"
    }
  ],
  "data": {
    "headers": [
      {
        "text": "Customer",
        "width": 35
      },
      {
        "text": "Plan",
        "width": 25
      },
      {
        "text": "MRR",
        "width": 20
      },
      {
        "text": "Since",
        "width": 20
      }
    ],
    "rows": [
      [
        {
          "text": "Acme Corp",
          "link": "https://example.com/customers/acme"
        },
        {
          "text": "Business",
          "badge": {
            "text": "Business",
            "color": "Dark"
          }
        },
        {
          "text": "$240/mo"
        },
        {
          "text": "2025-04-12"
        }
      ],
      [
        {
          "text": "Globex",
          "link": "https://example.com/customers/globex"
        },
        {
          "text": "Team",
          "badge": {
            "text": "Team",
            "color": "Success"
          }
        },
        {
          "text": "$80/mo"
        },
        {
          "text": "2025-08-01"
        }
      ]
    ]
  }
}

Note that every cell is an object, not a bare string. That's what lets a cell carry a link, a badge or a thumbnail (imageUrl) without changing the shape of the response.

app.get('/dashboards/stripe/top-customers', async (req, res) => {
  const subs = await stripe.subscriptions.list({
    status: 'active',
    limit: 100,
    expand: ['data.customer', 'data.items.data.price'],
  });

  const rows = subs.data
    .map(sub => {
      const item = sub.items.data[0];
      const monthly = item.price.recurring.interval === 'year'
        ? item.price.unit_amount / 12 / 100
        : item.price.unit_amount / 100;
      return {
        name: sub.customer.name || sub.customer.email,
        plan: item.price.nickname || 'Plan',
        mrr: monthly,
        since: new Date(sub.start_date * 1000).toISOString().slice(0, 10),
      };
    })
    .sort((a, b) => b.mrr - a.mrr)
    .slice(0, 10);

  res.json({
    title: 'Top customers',
    actions: [
      { title: 'View All', type: 'link', url: 'https://example.com/customers' },
    ],
    data: {
      headers: [
        { text: 'Customer', width: 35 },
        { text: 'Plan', width: 25 },
        { text: 'MRR', width: 20 },
        { text: 'Since', width: 20 },
      ],
      rows: rows.map(r => [
        { text: r.name },
        { text: r.plan, badge: { text: r.plan, color: 'Dark' } },
        { text: `$${r.mrr.toFixed(0)}/mo` },
        { text: r.since },
      ]),
    },
  });
});

Step 5: Wire it up in dashboardbase

  1. Sign in, create a new dashboard, name it "Stripe MRR."

  2. Click Add datasource and paste the URL of your first endpoint. Your workspace's endpoint secret is already there — it shows as a read-only row pinned above your own headers, so you can see authentication is in place before you configure anything. Add extra headers only if your endpoint needs them.

  3. Test the datasource — you'll see the JSON response.

  4. Drop a KPI widget on the grid, point it at the MRR datasource. Done.

  5. Repeat for the other three.

  6. Set refresh interval to 5 minutes. Publish.

Open it on your phone in the native iOS or Android app. That's the Stripe dashboard you'd otherwise have spent a weekend building.

Securing the endpoints

These endpoints return your revenue. They need auth — and you don't have to invent a scheme for it.

Start with the endpoint secret. Every workspace has one, and dashboardbase sends it as an x-dashboardbase-secret header on every request it makes to your endpoints. Your side compares one string. That's the whole mechanism.

Find it under Settings → Secrets → Endpoint Secret (or go to /workspace, which redirects to the active one). It's masked until you reveal it, and the copy button gives you the real value either way. The environment variable name is shown next to it, ready to paste into your deployment config:

const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET

// Fail at boot rather than serving revenue data unprotected.
if (!SECRET) throw new Error('DASHBOARDBASE_ENDPOINT_SECRET is not set')

app.use('/dashboard', (req, res, next) => {
  if (req.get('x-dashboardbase-secret') !== SECRET) {
    return res.status(401).json({ error: 'unauthorized' })
  }
  next()
})

Note which direction this authenticates: it proves we are calling you. It is not an API key for calling dashboardbase.

When you need to rotate it, the old secret keeps working for 24 hours, so your endpoints don't break the moment you press the button.

Custom headers and Basic Auth still work if you'd rather use something you already have — an Authorization: Bearer … header, or Basic Auth credentials. Both sit in the same Authentication panel, on top of the secret rather than instead of it.

Validation tells you the truth, not what you configured. When you test a datasource, dashboardbase makes one extra request with every credential stripped and reports what actually came back. An endpoint that rejects it gets a green "Protected — returns 401 without the secret". One that hands over data to an unauthenticated caller gets a yellow warning naming the header to check. It never blocks you from saving — it just refuses to let you believe an open endpoint is closed.

Beyond that, the usual rules: no tokens in the URL, HTTPS only.

What this approach gives you that Stripe's dashboard doesn't

  • Combined data. Add a widget that joins Stripe MRR with your product's WAU. Stripe can't do that.

  • Custom MRR definition. Many SaaS founders exclude one-time charges, certain coupons, or specific plans from MRR. Define it your way in your endpoint.

  • Mobile. MRR in your pocket, with push notifications when a number moves.

  • Shareable. One link, optional password, no Stripe access for the viewer.

Common gotchas

  • Annual subscriptions. Don't forget to divide by 12 to get monthly-equivalent revenue.

  • Trials. Decide whether trialing customers count as MRR. Stripe doesn't decide for you.

  • Refunds and credits. Net vs. gross MRR is a judgment call — code yours explicitly.

  • Caching. If the MRR endpoint is slow, cache it for 60 seconds. dashboardbase polls at your refresh interval; you don't need real-time per request.

Next steps

  • Add a churn rate widget (gauge chart).

  • Add a "new signups this week" KPI from your own user table.

  • Add a "subscription length distribution" pie chart.

Each one is another 10–20 lines of code in your backend, and another widget on the dashboard.

How to Build a Stripe MRR Dashboard (Without Building a Frontend)

What you'll build

A live MRR dashboard with four widgets:

  • MRR (KPI) — current monthly recurring revenue

  • MRR over time (line chart) — last 90 days

  • New vs. churned subscriptions (bar chart) — last 30 days

  • Top customers by revenue (table) — top 10

You'll write 4 small endpoints in your existing backend, point dashboardbase at them, and that's it. Total time: ~15 minutes if you already have Stripe set up.

Why not just use Stripe's built-in dashboard?

Stripe's dashboard is fine for accounting. It's not great for:

  • Combining Stripe data with your own product data (e.g. "MRR vs. weekly active users")

  • Sharing one read-only view with a co-founder, advisor, or investor

  • Viewing it on your phone with a single tap (Stripe's mobile experience is heavy)

  • Customizing what counts as "MRR" for your business

If any of those matter, you want a dashboard layer on top of Stripe. The cheap way to do that is: write a few endpoints in your backend that compute exactly what you want, and let a tool render them.

Prerequisites

  • A Stripe account with subscription data

  • A backend in any language (examples below in Node.js, but the shape is the same in Python, Go, Ruby, PHP)

  • A dashboardbase account

Step 1: The MRR endpoint

The MRR widget needs a single headline number. Here's the JSON contract:

{
  "title": "MRR",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/mrr-details"
    }
  ],
  "data": {
    "header": {
      "title": "$12,480",
      "subtitle": "vs last month",
      "badge": {
        "text": "+12.4%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}

And the Node.js endpoint:

// GET /dashboards/stripe/mrr
app.get('/dashboards/stripe/mrr', async (req, res) => {
  const subs = await stripe.subscriptions.list({
    status: 'active',
    limit: 100,
    expand: ['data.items.data.price'],
  });

  // Sum monthly-equivalent revenue across all active subs
  const mrr = subs.data.reduce((total, sub) => {
    return total + sub.items.data.reduce((sum, item) => {
      const price = item.price.unit_amount / 100;
      const interval = item.price.recurring.interval;
      const monthly = interval === 'year' ? price / 12 : price;
      return sum + (monthly * item.quantity);
    }, 0);
  }, 0);

  // Compare to MRR 30 days ago (you'd compute this from your own snapshots)
  const previous = await getMrrSnapshot(30);
  const trend = previous ? ((mrr - previous) / previous * 100).toFixed(1) : 0;
  const up = trend >= 0;

  res.json({
    title: 'MRR',
    actions: [
      { title: 'View Details', type: 'link', url: 'https://example.com/mrr-details' },
    ],
    data: {
      header: {
        title: `$${Math.round(mrr).toLocaleString('en-US')}`,
        subtitle: 'vs last month',
        badge: {
          text: `${up ? '+' : ''}${trend}%`,
          icon: up ? 'ArrowUp' : 'ArrowDown',
          color: up ? 'Success' : 'Danger',
        },
      },
    },
  });
});

Tip: If you don't already snapshot MRR daily, do this now in a cron job. You'll thank yourself in 6 months when you want to chart MRR over time and don't have the historical data.

Two shortcuts before you write the other three. You don't have to hand-write these endpoints or guess whether the JSON is right:

  • Generate them. Install the free dashboardbase Skill and Claude Code or Cursor knows the whole contract — "add a KPI endpoint for Stripe MRR" produces the right shape first try. Or skip the install: the widget editor generates a copy-pasteable prompt (per widget, or one covering the whole dashboard) for whatever AI tool you already use. It's a draft you review and run yourself; nothing executes on our side.

  • Check them. Paste the response into the endpoint validator — no account needed — and it tells you whether the KPI widget can render it, with a preview of the actual widget.

Step 2: The MRR-over-time endpoint

The line chart widget expects time-series data:

{
  "title": "MRR over time",
  "data": {
    "header": {
      "title": "$10,100",
      "subtitle": "Last 90 days",
      "badge": {
        "text": "+3%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    },
    "labels": [
      "2026-02-01",
      "2026-02-02",
      "2026-02-03"
    ],
    "datasets": [
      {
        "data": [
          {
            "value": 9800
          },
          {
            "value": 9850
          },
          {
            "value": 10100
          }
        ],
        "label": "MRR"
      }
    ]
  }
}

The labels array holds one entry per point on the x-axis, and each point in datasets[].data is an object with a value — not an x/y pair. And the endpoint:

app.get('/dashboards/stripe/mrr-history', async (req, res) => {
  const days = parseInt(req.query.days) || 90;
  const snapshots = await db.mrrSnapshots.findMany({
    where: { date: { gte: subDays(new Date(), days) } },
    orderBy: { date: 'asc' },
  });

  const first = snapshots[0]?.mrr ?? 0;
  const last = snapshots[snapshots.length - 1]?.mrr ?? 0;
  const change = first ? ((last - first) / first * 100).toFixed(0) : 0;
  const up = change >= 0;

  res.json({
    title: 'MRR over time',
    data: {
      header: {
        title: `$${last.toLocaleString('en-US')}`,
        subtitle: `Last ${days} days`,
        badge: {
          text: `${up ? '+' : ''}${change}%`,
          icon: up ? 'ArrowUp' : 'ArrowDown',
          color: up ? 'Success' : 'Danger',
        },
      },
      labels: snapshots.map(s => s.date.toISOString().slice(0, 10)),
      datasets: [{
        data: snapshots.map(s => ({ value: s.mrr })),
        label: 'MRR',
      }],
    },
  });
});

This endpoint takes a days query param so you can wire it up to dashboardbase's date range selector. When the user picks "Last 30 days" on the dashboard, that gets passed through.

Step 3: New vs. churned subscriptions

Bar chart contract:

{
  "title": "New vs churned",
  "data": {
    "header": {
      "title": "27",
      "subtitle": "New subscriptions, last 30 days"
    },
    "labels": [
      "Week 1",
      "Week 2"
    ],
    "datasets": [
      {
        "data": [
          {
            "value": 12
          },
          {
            "value": 15
          }
        ],
        "label": "New"
      },
      {
        "data": [
          {
            "value": 3
          },
          {
            "value": 5
          }
        ],
        "label": "Churned"
      }
    ]
  }
}

Two series means two entries in datasets, both indexed against the same labels array.

You compute this from Stripe events (customer.subscription.created and customer.subscription.deleted) — ideally from your own webhook log, not by polling Stripe. If you're not logging Stripe webhooks yet, set that up first; it'll save you from a dozen future headaches.

Step 4: Top customers by revenue

Table contract:

{
  "title": "Top customers",
  "actions": [
    {
      "title": "View All",
      "type": "link",
      "url": "https://example.com/customers"
    }
  ],
  "data": {
    "headers": [
      {
        "text": "Customer",
        "width": 35
      },
      {
        "text": "Plan",
        "width": 25
      },
      {
        "text": "MRR",
        "width": 20
      },
      {
        "text": "Since",
        "width": 20
      }
    ],
    "rows": [
      [
        {
          "text": "Acme Corp",
          "link": "https://example.com/customers/acme"
        },
        {
          "text": "Business",
          "badge": {
            "text": "Business",
            "color": "Dark"
          }
        },
        {
          "text": "$240/mo"
        },
        {
          "text": "2025-04-12"
        }
      ],
      [
        {
          "text": "Globex",
          "link": "https://example.com/customers/globex"
        },
        {
          "text": "Team",
          "badge": {
            "text": "Team",
            "color": "Success"
          }
        },
        {
          "text": "$80/mo"
        },
        {
          "text": "2025-08-01"
        }
      ]
    ]
  }
}

Note that every cell is an object, not a bare string. That's what lets a cell carry a link, a badge or a thumbnail (imageUrl) without changing the shape of the response.

app.get('/dashboards/stripe/top-customers', async (req, res) => {
  const subs = await stripe.subscriptions.list({
    status: 'active',
    limit: 100,
    expand: ['data.customer', 'data.items.data.price'],
  });

  const rows = subs.data
    .map(sub => {
      const item = sub.items.data[0];
      const monthly = item.price.recurring.interval === 'year'
        ? item.price.unit_amount / 12 / 100
        : item.price.unit_amount / 100;
      return {
        name: sub.customer.name || sub.customer.email,
        plan: item.price.nickname || 'Plan',
        mrr: monthly,
        since: new Date(sub.start_date * 1000).toISOString().slice(0, 10),
      };
    })
    .sort((a, b) => b.mrr - a.mrr)
    .slice(0, 10);

  res.json({
    title: 'Top customers',
    actions: [
      { title: 'View All', type: 'link', url: 'https://example.com/customers' },
    ],
    data: {
      headers: [
        { text: 'Customer', width: 35 },
        { text: 'Plan', width: 25 },
        { text: 'MRR', width: 20 },
        { text: 'Since', width: 20 },
      ],
      rows: rows.map(r => [
        { text: r.name },
        { text: r.plan, badge: { text: r.plan, color: 'Dark' } },
        { text: `$${r.mrr.toFixed(0)}/mo` },
        { text: r.since },
      ]),
    },
  });
});

Step 5: Wire it up in dashboardbase

  1. Sign in, create a new dashboard, name it "Stripe MRR."

  2. Click Add datasource and paste the URL of your first endpoint. Your workspace's endpoint secret is already there — it shows as a read-only row pinned above your own headers, so you can see authentication is in place before you configure anything. Add extra headers only if your endpoint needs them.

  3. Test the datasource — you'll see the JSON response.

  4. Drop a KPI widget on the grid, point it at the MRR datasource. Done.

  5. Repeat for the other three.

  6. Set refresh interval to 5 minutes. Publish.

Open it on your phone in the native iOS or Android app. That's the Stripe dashboard you'd otherwise have spent a weekend building.

Securing the endpoints

These endpoints return your revenue. They need auth — and you don't have to invent a scheme for it.

Start with the endpoint secret. Every workspace has one, and dashboardbase sends it as an x-dashboardbase-secret header on every request it makes to your endpoints. Your side compares one string. That's the whole mechanism.

Find it under Settings → Secrets → Endpoint Secret (or go to /workspace, which redirects to the active one). It's masked until you reveal it, and the copy button gives you the real value either way. The environment variable name is shown next to it, ready to paste into your deployment config:

const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET

// Fail at boot rather than serving revenue data unprotected.
if (!SECRET) throw new Error('DASHBOARDBASE_ENDPOINT_SECRET is not set')

app.use('/dashboard', (req, res, next) => {
  if (req.get('x-dashboardbase-secret') !== SECRET) {
    return res.status(401).json({ error: 'unauthorized' })
  }
  next()
})

Note which direction this authenticates: it proves we are calling you. It is not an API key for calling dashboardbase.

When you need to rotate it, the old secret keeps working for 24 hours, so your endpoints don't break the moment you press the button.

Custom headers and Basic Auth still work if you'd rather use something you already have — an Authorization: Bearer … header, or Basic Auth credentials. Both sit in the same Authentication panel, on top of the secret rather than instead of it.

Validation tells you the truth, not what you configured. When you test a datasource, dashboardbase makes one extra request with every credential stripped and reports what actually came back. An endpoint that rejects it gets a green "Protected — returns 401 without the secret". One that hands over data to an unauthenticated caller gets a yellow warning naming the header to check. It never blocks you from saving — it just refuses to let you believe an open endpoint is closed.

Beyond that, the usual rules: no tokens in the URL, HTTPS only.

What this approach gives you that Stripe's dashboard doesn't

  • Combined data. Add a widget that joins Stripe MRR with your product's WAU. Stripe can't do that.

  • Custom MRR definition. Many SaaS founders exclude one-time charges, certain coupons, or specific plans from MRR. Define it your way in your endpoint.

  • Mobile. MRR in your pocket, with push notifications when a number moves.

  • Shareable. One link, optional password, no Stripe access for the viewer.

Common gotchas

  • Annual subscriptions. Don't forget to divide by 12 to get monthly-equivalent revenue.

  • Trials. Decide whether trialing customers count as MRR. Stripe doesn't decide for you.

  • Refunds and credits. Net vs. gross MRR is a judgment call — code yours explicitly.

  • Caching. If the MRR endpoint is slow, cache it for 60 seconds. dashboardbase polls at your refresh interval; you don't need real-time per request.

Next steps

  • Add a churn rate widget (gauge chart).

  • Add a "new signups this week" KPI from your own user table.

  • Add a "subscription length distribution" pie chart.

Each one is another 10–20 lines of code in your backend, and another widget on the dashboard.

How to Build a Stripe MRR Dashboard (Without Building a Frontend)

What you'll build

A live MRR dashboard with four widgets:

  • MRR (KPI) — current monthly recurring revenue

  • MRR over time (line chart) — last 90 days

  • New vs. churned subscriptions (bar chart) — last 30 days

  • Top customers by revenue (table) — top 10

You'll write 4 small endpoints in your existing backend, point dashboardbase at them, and that's it. Total time: ~15 minutes if you already have Stripe set up.

Why not just use Stripe's built-in dashboard?

Stripe's dashboard is fine for accounting. It's not great for:

  • Combining Stripe data with your own product data (e.g. "MRR vs. weekly active users")

  • Sharing one read-only view with a co-founder, advisor, or investor

  • Viewing it on your phone with a single tap (Stripe's mobile experience is heavy)

  • Customizing what counts as "MRR" for your business

If any of those matter, you want a dashboard layer on top of Stripe. The cheap way to do that is: write a few endpoints in your backend that compute exactly what you want, and let a tool render them.

Prerequisites

  • A Stripe account with subscription data

  • A backend in any language (examples below in Node.js, but the shape is the same in Python, Go, Ruby, PHP)

  • A dashboardbase account

Step 1: The MRR endpoint

The MRR widget needs a single headline number. Here's the JSON contract:

{
  "title": "MRR",
  "actions": [
    {
      "title": "View Details",
      "type": "link",
      "url": "https://example.com/mrr-details"
    }
  ],
  "data": {
    "header": {
      "title": "$12,480",
      "subtitle": "vs last month",
      "badge": {
        "text": "+12.4%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    }
  }
}

And the Node.js endpoint:

// GET /dashboards/stripe/mrr
app.get('/dashboards/stripe/mrr', async (req, res) => {
  const subs = await stripe.subscriptions.list({
    status: 'active',
    limit: 100,
    expand: ['data.items.data.price'],
  });

  // Sum monthly-equivalent revenue across all active subs
  const mrr = subs.data.reduce((total, sub) => {
    return total + sub.items.data.reduce((sum, item) => {
      const price = item.price.unit_amount / 100;
      const interval = item.price.recurring.interval;
      const monthly = interval === 'year' ? price / 12 : price;
      return sum + (monthly * item.quantity);
    }, 0);
  }, 0);

  // Compare to MRR 30 days ago (you'd compute this from your own snapshots)
  const previous = await getMrrSnapshot(30);
  const trend = previous ? ((mrr - previous) / previous * 100).toFixed(1) : 0;
  const up = trend >= 0;

  res.json({
    title: 'MRR',
    actions: [
      { title: 'View Details', type: 'link', url: 'https://example.com/mrr-details' },
    ],
    data: {
      header: {
        title: `$${Math.round(mrr).toLocaleString('en-US')}`,
        subtitle: 'vs last month',
        badge: {
          text: `${up ? '+' : ''}${trend}%`,
          icon: up ? 'ArrowUp' : 'ArrowDown',
          color: up ? 'Success' : 'Danger',
        },
      },
    },
  });
});

Tip: If you don't already snapshot MRR daily, do this now in a cron job. You'll thank yourself in 6 months when you want to chart MRR over time and don't have the historical data.

Two shortcuts before you write the other three. You don't have to hand-write these endpoints or guess whether the JSON is right:

  • Generate them. Install the free dashboardbase Skill and Claude Code or Cursor knows the whole contract — "add a KPI endpoint for Stripe MRR" produces the right shape first try. Or skip the install: the widget editor generates a copy-pasteable prompt (per widget, or one covering the whole dashboard) for whatever AI tool you already use. It's a draft you review and run yourself; nothing executes on our side.

  • Check them. Paste the response into the endpoint validator — no account needed — and it tells you whether the KPI widget can render it, with a preview of the actual widget.

Step 2: The MRR-over-time endpoint

The line chart widget expects time-series data:

{
  "title": "MRR over time",
  "data": {
    "header": {
      "title": "$10,100",
      "subtitle": "Last 90 days",
      "badge": {
        "text": "+3%",
        "icon": "ArrowUp",
        "color": "Success"
      }
    },
    "labels": [
      "2026-02-01",
      "2026-02-02",
      "2026-02-03"
    ],
    "datasets": [
      {
        "data": [
          {
            "value": 9800
          },
          {
            "value": 9850
          },
          {
            "value": 10100
          }
        ],
        "label": "MRR"
      }
    ]
  }
}

The labels array holds one entry per point on the x-axis, and each point in datasets[].data is an object with a value — not an x/y pair. And the endpoint:

app.get('/dashboards/stripe/mrr-history', async (req, res) => {
  const days = parseInt(req.query.days) || 90;
  const snapshots = await db.mrrSnapshots.findMany({
    where: { date: { gte: subDays(new Date(), days) } },
    orderBy: { date: 'asc' },
  });

  const first = snapshots[0]?.mrr ?? 0;
  const last = snapshots[snapshots.length - 1]?.mrr ?? 0;
  const change = first ? ((last - first) / first * 100).toFixed(0) : 0;
  const up = change >= 0;

  res.json({
    title: 'MRR over time',
    data: {
      header: {
        title: `$${last.toLocaleString('en-US')}`,
        subtitle: `Last ${days} days`,
        badge: {
          text: `${up ? '+' : ''}${change}%`,
          icon: up ? 'ArrowUp' : 'ArrowDown',
          color: up ? 'Success' : 'Danger',
        },
      },
      labels: snapshots.map(s => s.date.toISOString().slice(0, 10)),
      datasets: [{
        data: snapshots.map(s => ({ value: s.mrr })),
        label: 'MRR',
      }],
    },
  });
});

This endpoint takes a days query param so you can wire it up to dashboardbase's date range selector. When the user picks "Last 30 days" on the dashboard, that gets passed through.

Step 3: New vs. churned subscriptions

Bar chart contract:

{
  "title": "New vs churned",
  "data": {
    "header": {
      "title": "27",
      "subtitle": "New subscriptions, last 30 days"
    },
    "labels": [
      "Week 1",
      "Week 2"
    ],
    "datasets": [
      {
        "data": [
          {
            "value": 12
          },
          {
            "value": 15
          }
        ],
        "label": "New"
      },
      {
        "data": [
          {
            "value": 3
          },
          {
            "value": 5
          }
        ],
        "label": "Churned"
      }
    ]
  }
}

Two series means two entries in datasets, both indexed against the same labels array.

You compute this from Stripe events (customer.subscription.created and customer.subscription.deleted) — ideally from your own webhook log, not by polling Stripe. If you're not logging Stripe webhooks yet, set that up first; it'll save you from a dozen future headaches.

Step 4: Top customers by revenue

Table contract:

{
  "title": "Top customers",
  "actions": [
    {
      "title": "View All",
      "type": "link",
      "url": "https://example.com/customers"
    }
  ],
  "data": {
    "headers": [
      {
        "text": "Customer",
        "width": 35
      },
      {
        "text": "Plan",
        "width": 25
      },
      {
        "text": "MRR",
        "width": 20
      },
      {
        "text": "Since",
        "width": 20
      }
    ],
    "rows": [
      [
        {
          "text": "Acme Corp",
          "link": "https://example.com/customers/acme"
        },
        {
          "text": "Business",
          "badge": {
            "text": "Business",
            "color": "Dark"
          }
        },
        {
          "text": "$240/mo"
        },
        {
          "text": "2025-04-12"
        }
      ],
      [
        {
          "text": "Globex",
          "link": "https://example.com/customers/globex"
        },
        {
          "text": "Team",
          "badge": {
            "text": "Team",
            "color": "Success"
          }
        },
        {
          "text": "$80/mo"
        },
        {
          "text": "2025-08-01"
        }
      ]
    ]
  }
}

Note that every cell is an object, not a bare string. That's what lets a cell carry a link, a badge or a thumbnail (imageUrl) without changing the shape of the response.

app.get('/dashboards/stripe/top-customers', async (req, res) => {
  const subs = await stripe.subscriptions.list({
    status: 'active',
    limit: 100,
    expand: ['data.customer', 'data.items.data.price'],
  });

  const rows = subs.data
    .map(sub => {
      const item = sub.items.data[0];
      const monthly = item.price.recurring.interval === 'year'
        ? item.price.unit_amount / 12 / 100
        : item.price.unit_amount / 100;
      return {
        name: sub.customer.name || sub.customer.email,
        plan: item.price.nickname || 'Plan',
        mrr: monthly,
        since: new Date(sub.start_date * 1000).toISOString().slice(0, 10),
      };
    })
    .sort((a, b) => b.mrr - a.mrr)
    .slice(0, 10);

  res.json({
    title: 'Top customers',
    actions: [
      { title: 'View All', type: 'link', url: 'https://example.com/customers' },
    ],
    data: {
      headers: [
        { text: 'Customer', width: 35 },
        { text: 'Plan', width: 25 },
        { text: 'MRR', width: 20 },
        { text: 'Since', width: 20 },
      ],
      rows: rows.map(r => [
        { text: r.name },
        { text: r.plan, badge: { text: r.plan, color: 'Dark' } },
        { text: `$${r.mrr.toFixed(0)}/mo` },
        { text: r.since },
      ]),
    },
  });
});

Step 5: Wire it up in dashboardbase

  1. Sign in, create a new dashboard, name it "Stripe MRR."

  2. Click Add datasource and paste the URL of your first endpoint. Your workspace's endpoint secret is already there — it shows as a read-only row pinned above your own headers, so you can see authentication is in place before you configure anything. Add extra headers only if your endpoint needs them.

  3. Test the datasource — you'll see the JSON response.

  4. Drop a KPI widget on the grid, point it at the MRR datasource. Done.

  5. Repeat for the other three.

  6. Set refresh interval to 5 minutes. Publish.

Open it on your phone in the native iOS or Android app. That's the Stripe dashboard you'd otherwise have spent a weekend building.

Securing the endpoints

These endpoints return your revenue. They need auth — and you don't have to invent a scheme for it.

Start with the endpoint secret. Every workspace has one, and dashboardbase sends it as an x-dashboardbase-secret header on every request it makes to your endpoints. Your side compares one string. That's the whole mechanism.

Find it under Settings → Secrets → Endpoint Secret (or go to /workspace, which redirects to the active one). It's masked until you reveal it, and the copy button gives you the real value either way. The environment variable name is shown next to it, ready to paste into your deployment config:

const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET

// Fail at boot rather than serving revenue data unprotected.
if (!SECRET) throw new Error('DASHBOARDBASE_ENDPOINT_SECRET is not set')

app.use('/dashboard', (req, res, next) => {
  if (req.get('x-dashboardbase-secret') !== SECRET) {
    return res.status(401).json({ error: 'unauthorized' })
  }
  next()
})

Note which direction this authenticates: it proves we are calling you. It is not an API key for calling dashboardbase.

When you need to rotate it, the old secret keeps working for 24 hours, so your endpoints don't break the moment you press the button.

Custom headers and Basic Auth still work if you'd rather use something you already have — an Authorization: Bearer … header, or Basic Auth credentials. Both sit in the same Authentication panel, on top of the secret rather than instead of it.

Validation tells you the truth, not what you configured. When you test a datasource, dashboardbase makes one extra request with every credential stripped and reports what actually came back. An endpoint that rejects it gets a green "Protected — returns 401 without the secret". One that hands over data to an unauthenticated caller gets a yellow warning naming the header to check. It never blocks you from saving — it just refuses to let you believe an open endpoint is closed.

Beyond that, the usual rules: no tokens in the URL, HTTPS only.

What this approach gives you that Stripe's dashboard doesn't

  • Combined data. Add a widget that joins Stripe MRR with your product's WAU. Stripe can't do that.

  • Custom MRR definition. Many SaaS founders exclude one-time charges, certain coupons, or specific plans from MRR. Define it your way in your endpoint.

  • Mobile. MRR in your pocket, with push notifications when a number moves.

  • Shareable. One link, optional password, no Stripe access for the viewer.

Common gotchas

  • Annual subscriptions. Don't forget to divide by 12 to get monthly-equivalent revenue.

  • Trials. Decide whether trialing customers count as MRR. Stripe doesn't decide for you.

  • Refunds and credits. Net vs. gross MRR is a judgment call — code yours explicitly.

  • Caching. If the MRR endpoint is slow, cache it for 60 seconds. dashboardbase polls at your refresh interval; you don't need real-time per request.

Next steps

  • Add a churn rate widget (gauge chart).

  • Add a "new signups this week" KPI from your own user table.

  • Add a "subscription length distribution" pie chart.

Each one is another 10–20 lines of code in your backend, and another widget on the dashboard.