In about fifteen minutes you can have live MRR, your top customers and a payment-failure alert on one screen — on the wall, in a browser, or on your phone.
One thing to be clear about before you start, because it changes how you plan the work: dashboardbase does not integrate with Stripe. There is no OAuth flow and no "connect your Stripe account" button. You write a small endpoint that calls the Stripe API and returns the JSON shape below, and dashboardbase renders it.
That sounds like more work than clicking a connector, and it is — by about fifteen lines. What you get for it is that your Stripe secret key never leaves your backend. We hold no credential that can read your account, so there is nothing on our side for anyone to take. If you have ever had to explain to a customer why a third party holds a key to your revenue data, that trade is worth making.
What you'll build
MRR (KPI) — current monthly recurring revenue, with progress toward a goal
Top customers (table) — who is actually paying you, ranked
Payment failures (status) — a status widget that raises an alert when cards start declining
Three endpoints, three widgets. If you want the longer build — MRR over time, new versus churned subscriptions, the whole four-widget board — the step-by-step version is in how to build a Stripe MRR dashboard. This page is the short path to the first widget.
Prerequisites
A Stripe account with subscription data, and a restricted API key with read access
A backend you can deploy an endpoint to (examples are Node.js; the JSON is the same in any language)
A dashboardbase account
Step 1: The MRR endpoint
The KPI widget wants one headline number, an optional comparison, and an optional goal bar. Here is the contract:
{
"title": "MRR",
"actions": [
{
"title": "View in Stripe",
"type": "link",
"url": "https://dashboard.stripe.com/subscriptions"
}
],
"data": {
"header": {
"title": "$8,200",
"subtitle": "toward $10k goal",
"badge": {
"text": "+$820",
"icon": "ArrowUp",
"color": "Success"
}
},
"progress": {
"value": 8200,
"max": 10000,
"label": "Goal $10,000"
}
}
}{
"title": "MRR",
"actions": [
{
"title": "View in Stripe",
"type": "link",
"url": "https://dashboard.stripe.com/subscriptions"
}
],
"data": {
"header": {
"title": "$8,200",
"subtitle": "toward $10k goal",
"badge": {
"text": "+$820",
"icon": "ArrowUp",
"color": "Success"
}
},
"progress": {
"value": 8200,
"max": 10000,
"label": "Goal $10,000"
}
}
}{
"title": "MRR",
"actions": [
{
"title": "View in Stripe",
"type": "link",
"url": "https://dashboard.stripe.com/subscriptions"
}
],
"data": {
"header": {
"title": "$8,200",
"subtitle": "toward $10k goal",
"badge": {
"text": "+$820",
"icon": "ArrowUp",
"color": "Success"
}
},
"progress": {
"value": 8200,
"max": 10000,
"label": "Goal $10,000"
}
}
}You can check that shape right now without an account — paste it into the free endpoint validator and it tells you whether the widget can render it. Do that before you write the handler, not after.
And you don't have to write the handler by hand either. The open-source dashboardbase Skill drops into Claude Code or Cursor and teaches the agent this contract, so it scaffolds an endpoint that fits instead of guessing at the JSON. If you'd rather not set up an agent, the widget editor generates a copy-pasteable prompt for whatever AI tool you already use. Either way you get a draft you review and run yourself — nothing executes on our side.
Now the endpoint. The only real work is normalising every billing interval to a monthly figure:
import express from 'express'
import Stripe from 'stripe'
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const GOAL_CENTS = 1000000
const MONTHS = { day: 1 / 30, week: 7 / 30, month: 1, year: 12 }
function monthlyCents(item) {
const { unit_amount, recurring } = item.price
const months = MONTHS[recurring.interval] * (recurring.interval_count ?? 1)
return ((unit_amount ?? 0) * (item.quantity ?? 1)) / months
}
const usd = (cents) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(cents / 100)
app.get('/dashboard/mrr', async (req, res) => {
let cents = 0
for await (const sub of stripe.subscriptions.list({
status: 'active',
expand: ['data.items.data.price'],
limit: 100,
})) {
for (const item of sub.items.data) cents += monthlyCents(item)
}
const previous = await lastMonthMrrCents()
const delta = cents - previous
res.json({
title: 'MRR',
actions: [
{
title: 'View in Stripe',
type: 'link',
url: 'https://dashboard.stripe.com/subscriptions',
},
],
data: {
header: {
title: usd(cents),
subtitle: `toward ${usd(GOAL_CENTS)} goal`,
badge: {
text: `${delta >= 0 ? '+' : '-'}${usd(Math.abs(delta))}`,
icon: delta >= 0 ? 'ArrowUp' : 'ArrowDown',
color: delta >= 0 ? 'Success' : 'Danger',
},
},
progress: {
value: Math.round(cents / 100),
max: Math.round(GOAL_CENTS / 100),
label: `Goal ${usd(GOAL_CENTS)}`,
},
},
})
})import express from 'express'
import Stripe from 'stripe'
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const GOAL_CENTS = 1000000
const MONTHS = { day: 1 / 30, week: 7 / 30, month: 1, year: 12 }
function monthlyCents(item) {
const { unit_amount, recurring } = item.price
const months = MONTHS[recurring.interval] * (recurring.interval_count ?? 1)
return ((unit_amount ?? 0) * (item.quantity ?? 1)) / months
}
const usd = (cents) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(cents / 100)
app.get('/dashboard/mrr', async (req, res) => {
let cents = 0
for await (const sub of stripe.subscriptions.list({
status: 'active',
expand: ['data.items.data.price'],
limit: 100,
})) {
for (const item of sub.items.data) cents += monthlyCents(item)
}
const previous = await lastMonthMrrCents()
const delta = cents - previous
res.json({
title: 'MRR',
actions: [
{
title: 'View in Stripe',
type: 'link',
url: 'https://dashboard.stripe.com/subscriptions',
},
],
data: {
header: {
title: usd(cents),
subtitle: `toward ${usd(GOAL_CENTS)} goal`,
badge: {
text: `${delta >= 0 ? '+' : '-'}${usd(Math.abs(delta))}`,
icon: delta >= 0 ? 'ArrowUp' : 'ArrowDown',
color: delta >= 0 ? 'Success' : 'Danger',
},
},
progress: {
value: Math.round(cents / 100),
max: Math.round(GOAL_CENTS / 100),
label: `Goal ${usd(GOAL_CENTS)}`,
},
},
})
})import express from 'express'
import Stripe from 'stripe'
const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const GOAL_CENTS = 1000000
const MONTHS = { day: 1 / 30, week: 7 / 30, month: 1, year: 12 }
function monthlyCents(item) {
const { unit_amount, recurring } = item.price
const months = MONTHS[recurring.interval] * (recurring.interval_count ?? 1)
return ((unit_amount ?? 0) * (item.quantity ?? 1)) / months
}
const usd = (cents) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(cents / 100)
app.get('/dashboard/mrr', async (req, res) => {
let cents = 0
for await (const sub of stripe.subscriptions.list({
status: 'active',
expand: ['data.items.data.price'],
limit: 100,
})) {
for (const item of sub.items.data) cents += monthlyCents(item)
}
const previous = await lastMonthMrrCents()
const delta = cents - previous
res.json({
title: 'MRR',
actions: [
{
title: 'View in Stripe',
type: 'link',
url: 'https://dashboard.stripe.com/subscriptions',
},
],
data: {
header: {
title: usd(cents),
subtitle: `toward ${usd(GOAL_CENTS)} goal`,
badge: {
text: `${delta >= 0 ? '+' : '-'}${usd(Math.abs(delta))}`,
icon: delta >= 0 ? 'ArrowUp' : 'ArrowDown',
color: delta >= 0 ? 'Success' : 'Danger',
},
},
progress: {
value: Math.round(cents / 100),
max: Math.round(GOAL_CENTS / 100),
label: `Goal ${usd(GOAL_CENTS)}`,
},
},
})
})Paste the URL into a KPI widget and you have your first number.
Step 2: Top customers
The table widget takes headers with widths and rows of cells. Every cell is an object, never a bare string — that's what lets a cell carry a link or a badge:
{
"title": "Top customers",
"actions": [
{
"title": "View All",
"type": "link",
"url": "https://dashboard.stripe.com/customers"
}
],
"data": {
"headers": [
{ "text": "Customer", "width": 45 },
{ "text": "Plan", "width": 30 },
{ "text": "MRR", "width": 25 }
],
"rows": [
[
{ "text": "Acme Corp", "link": "https://dashboard.stripe.com/customers/cus_1" },
{ "text": "Enterprise", "badge": { "text": "Enterprise", "color": "Dark" } },
{ "text": "$1,200" }
],
[
{ "text": "Globex", "link": "https://dashboard.stripe.com/customers/cus_2" },
{ "text": "Pro", "badge": { "text": "Pro", "color": "Success" } },
{ "text": "$480" }
]
]
}
}{
"title": "Top customers",
"actions": [
{
"title": "View All",
"type": "link",
"url": "https://dashboard.stripe.com/customers"
}
],
"data": {
"headers": [
{ "text": "Customer", "width": 45 },
{ "text": "Plan", "width": 30 },
{ "text": "MRR", "width": 25 }
],
"rows": [
[
{ "text": "Acme Corp", "link": "https://dashboard.stripe.com/customers/cus_1" },
{ "text": "Enterprise", "badge": { "text": "Enterprise", "color": "Dark" } },
{ "text": "$1,200" }
],
[
{ "text": "Globex", "link": "https://dashboard.stripe.com/customers/cus_2" },
{ "text": "Pro", "badge": { "text": "Pro", "color": "Success" } },
{ "text": "$480" }
]
]
}
}{
"title": "Top customers",
"actions": [
{
"title": "View All",
"type": "link",
"url": "https://dashboard.stripe.com/customers"
}
],
"data": {
"headers": [
{ "text": "Customer", "width": 45 },
{ "text": "Plan", "width": 30 },
{ "text": "MRR", "width": 25 }
],
"rows": [
[
{ "text": "Acme Corp", "link": "https://dashboard.stripe.com/customers/cus_1" },
{ "text": "Enterprise", "badge": { "text": "Enterprise", "color": "Dark" } },
{ "text": "$1,200" }
],
[
{ "text": "Globex", "link": "https://dashboard.stripe.com/customers/cus_2" },
{ "text": "Pro", "badge": { "text": "Pro", "color": "Success" } },
{ "text": "$480" }
]
]
}
}The handler walks the same subscription list, groups by customer, and takes the top ten:
app.get('/dashboard/top-customers', async (req, res) => {
const byCustomer = new Map()
for await (const sub of stripe.subscriptions.list({
status: 'active',
expand: ['data.customer', 'data.items.data.price'],
limit: 100,
})) {
const cents = sub.items.data.reduce((sum, item) => sum + monthlyCents(item), 0)
const existing = byCustomer.get(sub.customer.id)
if (existing) {
existing.cents += cents
} else {
byCustomer.set(sub.customer.id, {
id: sub.customer.id,
name: sub.customer.name || sub.customer.email || sub.customer.id,
plan: sub.items.data[0]?.price.nickname ?? 'Custom',
cents,
})
}
}
const top = [...byCustomer.values()].sort((a, b) => b.cents - a.cents).slice(0, 10)
res.json({
title: 'Top customers',
actions: [
{
title: 'View All',
type: 'link',
url: 'https://dashboard.stripe.com/customers',
},
],
data: {
headers: [
{ text: 'Customer', width: 45 },
{ text: 'Plan', width: 30 },
{ text: 'MRR', width: 25 },
],
rows: top.map((c) => [
{ text: c.name, link: `https://dashboard.stripe.com/customers/${c.id}` },
{ text: c.plan, badge: { text: c.plan, color: 'Success' } },
{ text: usd(c.cents) },
]),
},
})
})app.get('/dashboard/top-customers', async (req, res) => {
const byCustomer = new Map()
for await (const sub of stripe.subscriptions.list({
status: 'active',
expand: ['data.customer', 'data.items.data.price'],
limit: 100,
})) {
const cents = sub.items.data.reduce((sum, item) => sum + monthlyCents(item), 0)
const existing = byCustomer.get(sub.customer.id)
if (existing) {
existing.cents += cents
} else {
byCustomer.set(sub.customer.id, {
id: sub.customer.id,
name: sub.customer.name || sub.customer.email || sub.customer.id,
plan: sub.items.data[0]?.price.nickname ?? 'Custom',
cents,
})
}
}
const top = [...byCustomer.values()].sort((a, b) => b.cents - a.cents).slice(0, 10)
res.json({
title: 'Top customers',
actions: [
{
title: 'View All',
type: 'link',
url: 'https://dashboard.stripe.com/customers',
},
],
data: {
headers: [
{ text: 'Customer', width: 45 },
{ text: 'Plan', width: 30 },
{ text: 'MRR', width: 25 },
],
rows: top.map((c) => [
{ text: c.name, link: `https://dashboard.stripe.com/customers/${c.id}` },
{ text: c.plan, badge: { text: c.plan, color: 'Success' } },
{ text: usd(c.cents) },
]),
},
})
})app.get('/dashboard/top-customers', async (req, res) => {
const byCustomer = new Map()
for await (const sub of stripe.subscriptions.list({
status: 'active',
expand: ['data.customer', 'data.items.data.price'],
limit: 100,
})) {
const cents = sub.items.data.reduce((sum, item) => sum + monthlyCents(item), 0)
const existing = byCustomer.get(sub.customer.id)
if (existing) {
existing.cents += cents
} else {
byCustomer.set(sub.customer.id, {
id: sub.customer.id,
name: sub.customer.name || sub.customer.email || sub.customer.id,
plan: sub.items.data[0]?.price.nickname ?? 'Custom',
cents,
})
}
}
const top = [...byCustomer.values()].sort((a, b) => b.cents - a.cents).slice(0, 10)
res.json({
title: 'Top customers',
actions: [
{
title: 'View All',
type: 'link',
url: 'https://dashboard.stripe.com/customers',
},
],
data: {
headers: [
{ text: 'Customer', width: 45 },
{ text: 'Plan', width: 30 },
{ text: 'MRR', width: 25 },
],
rows: top.map((c) => [
{ text: c.name, link: `https://dashboard.stripe.com/customers/${c.id}` },
{ text: c.plan, badge: { text: c.plan, color: 'Success' } },
{ text: usd(c.cents) },
]),
},
})
})Step 3: Payment failures, with an alert
This is the widget that earns its place. A status widget shows green or red, and the optional alert object on the response is what turns a red square into a push notification on your phone:
{
"title": "Payments",
"actions": [
{
"title": "Open Invoices",
"type": "link",
"url": "https://dashboard.stripe.com/invoices"
}
],
"data": {
"header": {
"title": "3 failed",
"subtitle": "Last 24 hours"
},
"status": "Error"
},
"alert": {
"active": true,
"level": "warning",
"message": "3 payments failed in the last 24 hours"
}
}{
"title": "Payments",
"actions": [
{
"title": "Open Invoices",
"type": "link",
"url": "https://dashboard.stripe.com/invoices"
}
],
"data": {
"header": {
"title": "3 failed",
"subtitle": "Last 24 hours"
},
"status": "Error"
},
"alert": {
"active": true,
"level": "warning",
"message": "3 payments failed in the last 24 hours"
}
}{
"title": "Payments",
"actions": [
{
"title": "Open Invoices",
"type": "link",
"url": "https://dashboard.stripe.com/invoices"
}
],
"data": {
"header": {
"title": "3 failed",
"subtitle": "Last 24 hours"
},
"status": "Error"
},
"alert": {
"active": true,
"level": "warning",
"message": "3 payments failed in the last 24 hours"
}
}Alert level is one of info, success, warning or critical. Set active to false — or leave the whole object out — when nothing is wrong:
app.get('/dashboard/payment-failures', async (req, res) => {
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60
let failed = 0
for await (const invoice of stripe.invoices.list({
status: 'open',
created: { gte: since },
limit: 100,
})) {
if (invoice.attempt_count > 0) failed++
}
res.json({
title: 'Payments',
actions: [
{
title: 'Open Invoices',
type: 'link',
url: 'https://dashboard.stripe.com/invoices',
},
],
data: {
header: {
title: failed === 0 ? 'All collected' : `${failed} failed`,
subtitle: 'Last 24 hours',
},
status: failed === 0 ? 'Ok' : 'Error',
},
alert: {
active: failed > 0,
level: 'warning',
message: `${failed} payments failed in the last 24 hours`,
},
})
})app.get('/dashboard/payment-failures', async (req, res) => {
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60
let failed = 0
for await (const invoice of stripe.invoices.list({
status: 'open',
created: { gte: since },
limit: 100,
})) {
if (invoice.attempt_count > 0) failed++
}
res.json({
title: 'Payments',
actions: [
{
title: 'Open Invoices',
type: 'link',
url: 'https://dashboard.stripe.com/invoices',
},
],
data: {
header: {
title: failed === 0 ? 'All collected' : `${failed} failed`,
subtitle: 'Last 24 hours',
},
status: failed === 0 ? 'Ok' : 'Error',
},
alert: {
active: failed > 0,
level: 'warning',
message: `${failed} payments failed in the last 24 hours`,
},
})
})app.get('/dashboard/payment-failures', async (req, res) => {
const since = Math.floor(Date.now() / 1000) - 24 * 60 * 60
let failed = 0
for await (const invoice of stripe.invoices.list({
status: 'open',
created: { gte: since },
limit: 100,
})) {
if (invoice.attempt_count > 0) failed++
}
res.json({
title: 'Payments',
actions: [
{
title: 'Open Invoices',
type: 'link',
url: 'https://dashboard.stripe.com/invoices',
},
],
data: {
header: {
title: failed === 0 ? 'All collected' : `${failed} failed`,
subtitle: 'Last 24 hours',
},
status: failed === 0 ? 'Ok' : 'Error',
},
alert: {
active: failed > 0,
level: 'warning',
message: `${failed} payments failed in the last 24 hours`,
},
})
})Once that's wired up, a card declining reaches you as a push notification in the native iOS or Android app rather than as a support email three days later.
Securing the endpoint
These endpoints return your revenue. Do not put them on the public internet unauthenticated — and you don't have to invent a scheme to avoid it.
Start with the workspace endpoint secret. Every workspace has one, and dashboardbase sends it as an x-dashboardbase-secret header on every request it makes to your endpoints, automatically. There is nothing to wire up on our side. Find it under Settings → Secrets → Endpoint Secret (or open /workspace, which redirects to the active one).
Note which direction this authenticates: it proves the call came from us. It is not an API key for calling dashboardbase.
const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET
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()
})const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET
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()
})const SECRET = process.env.DASHBOARDBASE_ENDPOINT_SECRET
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()
})Rotating the secret keeps the previous value working for 24 hours, so nothing breaks the moment you press the button. If you'd rather use auth you already have, custom headers such as Authorization: Bearer … and Basic Auth both work on top of the secret, not instead of it.
Validation reports what actually happened, not what you configured. When you test the datasource, dashboardbase fires one extra request with every credential stripped and tells you what came back — a green "Protected — returns 401 without the secret", or a yellow warning naming the header to check. It never blocks you from saving. It just won't let you believe an open endpoint is closed.
Use a restricted Stripe key with read-only access to subscriptions, customers and invoices. The endpoint doesn't need anything else, and a leaked read key is a much smaller problem than a leaked secret key.
Common gotchas
Zero-decimal currencies. unit_amount is in cents for USD and EUR — but JPY, KRW and a handful of others have no minor unit, so dividing by 100 makes your Japanese revenue a hundred times too small.
Mixed currencies. Summing amounts across currencies gives you a meaningless number. Either convert with a rate you control, or return one widget per currency and be explicit about it.
Annual plans. A yearly subscription is not twelve months of MRR this month. Divide by 12 — that's what the MONTHS table above is for.
Trials. Stripe won't decide whether a trialing customer counts toward MRR. Decide it explicitly in your endpoint, write the rule in a comment, and stay consistent.
Rate limits. Recomputing MRR by walking every subscription on every dashboard poll will eventually get you throttled. Cache the result for 60 seconds; the dashboard refreshes on an interval anyway and does not need per-request freshness.
Test versus live keys. A dashboard quietly wired to your test key shows beautiful, fictional numbers. Check which mode the key belongs to before you trust the first screenshot.
Timezones. "Today" in Stripe is UTC unless you say otherwise. If your daily numbers look like they shift a few hours, this is why.
Next steps
Follow the full build in how to build a Stripe MRR dashboard to add MRR over time and new-versus-churned subscriptions.
Decide which numbers deserve the space before you add more widgets — the revenue dashboard page covers what belongs on the board and what to leave off.
Add product data next to the revenue. Joining Stripe MRR to your own weekly active users is one more endpoint, and it is the thing Stripe's own dashboard cannot do at all.
If you're weighing this against building the whole thing yourself, the honest build-versus-buy framework is the shorter read.