How to Build an API Status Dashboard: Complete Tutorial with Code Examples

by Shibley

Quick Answer

How do I build an API status dashboard? Create a Next.js application with API routes that periodically check endpoint health, store results in Redis for caching and PostgreSQL for historical data, display real-time status with WebSocket updates, and send alerts via email/Slack when services go down. The core components are: health checkers, data storage, real-time UI updates, and alerting logic.

Architecture Overview

A production-ready API status dashboard consists of four layers:

1. Health Check Layer

Periodically pings API endpoints, checks response times, validates status codes, and detects anomalies. Runs on a scheduled interval (every 1-5 minutes).

2. Data Layer

  • Redis: Caches current status for instant reads
  • PostgreSQL: Stores historical health check results for trend analysis
  • Time-series optimization: Indexes and partitioning for efficient queries

3. Real-Time Layer

  • WebSocket connections push updates to browser clients immediately
  • Server-Sent Events (SSE) as a simpler alternative
  • Polling fallback for maximum compatibility

4. Alerting Layer

Detects status changes and triggers notifications via:

  • Email (transactional email services)
  • Slack/Discord webhooks
  • SMS (Twilio)
  • Custom webhooks for integration with PagerDuty, Opsgenie, etc.

Visual Flow

[Scheduled Jobs] → [Health Checkers] → [Data Layer]
                                            ↓
[Browser Client] ← [WebSocket/SSE] ← [API Routes]
                                            ↓
                                      [Alert System]

Tech Stack Options

Frontend Framework

Next.js (Recommended)

  • Server-side rendering for fast initial loads
  • API routes eliminate need for separate backend
  • Built-in image optimization and caching
  • Easy deployment to Vercel

React + Vite

  • Lighter-weight alternative
  • Faster development builds
  • Requires separate API server

Vue/Svelte

  • Both work great for dashboards
  • Choose based on team familiarity

Backend Runtime

Node.js with TypeScript

  • Same language as frontend
  • Excellent async handling for HTTP requests
  • Rich ecosystem of monitoring libraries

Python with FastAPI

  • Great for complex data processing
  • Strong data science libraries for anomaly detection
  • Async support with asyncio

Database

Redis

  • Store current status (key: service:{id}:status, value: {"status":"up","latency":45})
  • Cache aggregated metrics
  • Pub/sub for real-time updates
  • TTL for automatic cleanup

PostgreSQL

  • Store historical check results
  • Time-series tables with partitioning
  • Rich querying for trends and reports

TimescaleDB (PostgreSQL extension)

  • Optimized for time-series data
  • Automatic data retention policies
  • Better compression and query performance

Step-by-Step Tutorial

Let's build a production-ready API status dashboard using Next.js, TypeScript, Redis, and PostgreSQL.

Prerequisites

# Install Node.js 18+ and pnpm
node --version  # v18.0.0 or higher
pnpm --version  # 8.0.0 or higher

# Install Docker for local Redis/Postgres
docker --version

Step 1: Project Setup

# Create Next.js project with TypeScript
pnpm create next-app@latest api-status-dashboard \
  --typescript \
  --tailwind \
  --app \
  --src-dir

cd api-status-dashboard

# Install dependencies
pnpm add ioredis pg date-fns recharts ws
pnpm add -D @types/pg @types/ws prisma

# Initialize Prisma
pnpx prisma init

Step 2: Database Schema

Create prisma/schema.prisma:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Service {
  id          String   @id @default(cuid())
  name        String
  url         String
  type        String   // 'rest', 'graphql', 'grpc'
  checkInterval Int    @default(300000) // 5 minutes in ms
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  checks      Check[]
  incidents   Incident[]
  
  @@index([name])
}

model Check {
  id          String   @id @default(cuid())
  serviceId   String
  status      String   // 'up', 'down', 'degraded'
  latency     Int      // milliseconds
  statusCode  Int?
  errorMessage String?
  checkedAt   DateTime @default(now())
  service     Service  @relation(fields: [serviceId], references: [id])
  
  @@index([serviceId, checkedAt(sort: Desc)])
  @@index([checkedAt(sort: Desc)])
}

model Incident {
  id          String   @id @default(cuid())
  serviceId   String
  status      String   // 'investigating', 'identified', 'monitoring', 'resolved'
  title       String
  description String?
  startedAt   DateTime @default(now())
  resolvedAt  DateTime?
  service     Service  @relation(fields: [serviceId], references: [id])
  updates     IncidentUpdate[]
  
  @@index([serviceId])
  @@index([startedAt(sort: Desc)])
}

model IncidentUpdate {
  id          String   @id @default(cuid())
  incidentId  String
  status      String
  message     String
  createdAt   DateTime @default(now())
  incident    Incident @relation(fields: [incidentId], references: [id])
  
  @@index([incidentId])
}

Run migrations:

# Set your database URL
echo 'DATABASE_URL="postgresql://user:password@localhost:5432/status_dashboard"' > .env

# Run migrations
pnpx prisma migrate dev --name init
pnpx prisma generate

Step 3: Redis Client

Create src/lib/redis.ts:

// src/lib/redis.ts
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
  maxRetriesPerRequest: 3,
  retryStrategy: (times) => {
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
});

redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));

export default redis;

// Helper functions
export async function cacheServiceStatus(serviceId: string, data: any) {
  await redis.setex(
    `service:${serviceId}:status`,
    300, // 5 minutes TTL
    JSON.stringify(data)
  );
}

export async function getCachedServiceStatus(serviceId: string) {
  const cached = await redis.get(`service:${serviceId}:status`);
  return cached ? JSON.parse(cached) : null;
}

export async function publishStatusUpdate(serviceId: string, data: any) {
  await redis.publish('status:updates', JSON.stringify({ serviceId, ...data }));
}

Step 4: Health Check Engine

Create src/lib/health-checker.ts:

// src/lib/health-checker.ts
import { PrismaClient } from '@prisma/client';
import { cacheServiceStatus, publishStatusUpdate } from './redis';

const prisma = new PrismaClient();

interface CheckResult {
  status: 'up' | 'down' | 'degraded';
  latency: number;
  statusCode?: number;
  errorMessage?: string;
}

export async function checkEndpoint(url: string): Promise<CheckResult> {
  const startTime = Date.now();
  
  try {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
    
    const response = await fetch(url, {
      method: 'GET',
      signal: controller.signal,
      headers: {
        'User-Agent': 'StatusDashboard/1.0',
      },
    });
    
    clearTimeout(timeoutId);
    const latency = Date.now() - startTime;
    
    const status = response.ok 
      ? (latency > 1000 ? 'degraded' : 'up')
      : 'down';
    
    return {
      status,
      latency,
      statusCode: response.status,
    };
  } catch (error: any) {
    return {
      status: 'down',
      latency: Date.now() - startTime,
      errorMessage: error.message,
    };
  }
}

export async function checkService(serviceId: string) {
  const service = await prisma.service.findUnique({
    where: { id: serviceId },
  });
  
  if (!service) {
    throw new Error(`Service ${serviceId} not found`);
  }
  
  const result = await checkEndpoint(service.url);
  
  // Save to database
  const check = await prisma.check.create({
    data: {
      serviceId: service.id,
      status: result.status,
      latency: result.latency,
      statusCode: result.statusCode,
      errorMessage: result.errorMessage,
    },
  });
  
  // Cache current status
  await cacheServiceStatus(serviceId, {
    status: result.status,
    latency: result.latency,
    lastChecked: check.checkedAt,
  });
  
  // Publish real-time update
  await publishStatusUpdate(serviceId, {
    status: result.status,
    latency: result.latency,
    timestamp: check.checkedAt,
  });
  
  // Check if we need to create an incident
  await handleIncidents(service.id, result.status);
  
  return check;
}

async function handleIncidents(serviceId: string, currentStatus: string) {
  // Find open incidents
  const openIncident = await prisma.incident.findFirst({
    where: {
      serviceId,
      resolvedAt: null,
    },
  });
  
  if (currentStatus === 'down' && !openIncident) {
    // Create new incident
    await prisma.incident.create({
      data: {
        serviceId,
        status: 'investigating',
        title: 'Service Unavailable',
        description: 'Automated detection of service outage',
      },
    });
  } else if (currentStatus === 'up' && openIncident) {
    // Resolve incident
    await prisma.incident.update({
      where: { id: openIncident.id },
      data: {
        status: 'resolved',
        resolvedAt: new Date(),
      },
    });
  }
}

export async function checkAllServices() {
  const services = await prisma.service.findMany();
  
  const results = await Promise.allSettled(
    services.map(service => checkService(service.id))
  );
  
  return results;
}

Step 5: API Routes

Create src/app/api/services/route.ts:

// src/app/api/services/route.ts
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { getCachedServiceStatus } from '@/lib/redis';

const prisma = new PrismaClient();

export async function GET() {
  try {
    const services = await prisma.service.findMany({
      orderBy: { name: 'asc' },
    });
    
    // Enrich with cached status
    const enrichedServices = await Promise.all(
      services.map(async (service) => {
        const cachedStatus = await getCachedServiceStatus(service.id);
        
        if (cachedStatus) {
          return { ...service, currentStatus: cachedStatus };
        }
        
        // Fallback to latest check in DB
        const latestCheck = await prisma.check.findFirst({
          where: { serviceId: service.id },
          orderBy: { checkedAt: 'desc' },
        });
        
        return {
          ...service,
          currentStatus: latestCheck ? {
            status: latestCheck.status,
            latency: latestCheck.latency,
            lastChecked: latestCheck.checkedAt,
          } : null,
        };
      })
    );
    
    return NextResponse.json(enrichedServices);
  } catch (error) {
    console.error('Error fetching services:', error);
    return NextResponse.json(
      { error: 'Failed to fetch services' },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { name, url, type, checkInterval } = body;
    
    const service = await prisma.service.create({
      data: {
        name,
        url,
        type: type || 'rest',
        checkInterval: checkInterval || 300000,
      },
    });
    
    return NextResponse.json(service, { status: 201 });
  } catch (error) {
    console.error('Error creating service:', error);
    return NextResponse.json(
      { error: 'Failed to create service' },
      { status: 500 }
    );
  }
}

Create src/app/api/services/[id]/history/route.ts:

// src/app/api/services/[id]/history/route.ts
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const { searchParams } = new URL(request.url);
    const hours = parseInt(searchParams.get('hours') || '24');
    
    const since = new Date(Date.now() - hours * 60 * 60 * 1000);
    
    const checks = await prisma.check.findMany({
      where: {
        serviceId: params.id,
        checkedAt: { gte: since },
      },
      orderBy: { checkedAt: 'desc' },
      take: 500,
    });
    
    // Calculate uptime percentage
    const totalChecks = checks.length;
    const upChecks = checks.filter(c => c.status === 'up').length;
    const uptime = totalChecks > 0 ? (upChecks / totalChecks) * 100 : 0;
    
    return NextResponse.json({
      checks,
      uptime: uptime.toFixed(2),
      totalChecks,
      period: `${hours}h`,
    });
  } catch (error) {
    console.error('Error fetching history:', error);
    return NextResponse.json(
      { error: 'Failed to fetch history' },
      { status: 500 }
    );
  }
}

Step 6: Scheduled Health Checks

Create src/app/api/cron/health-check/route.ts:

// src/app/api/cron/health-check/route.ts
import { NextResponse } from 'next/server';
import { checkAllServices } from '@/lib/health-checker';

export async function GET(request: Request) {
  // Verify cron secret to prevent unauthorized calls
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  
  try {
    const results = await checkAllServices();
    
    return NextResponse.json({
      success: true,
      checked: results.length,
      timestamp: new Date().toISOString(),
    });
  } catch (error) {
    console.error('Health check cron error:', error);
    return NextResponse.json(
      { error: 'Health check failed' },
      { status: 500 }
    );
  }
}

// For Vercel cron jobs
export const runtime = 'nodejs';
export const maxDuration = 300; // 5 minutes

Configure in vercel.json:

{
  "crons": [{
    "path": "/api/cron/health-check",
    "schedule": "*/5 * * * *"
  }]
}

Step 7: Real-Time WebSocket Updates

Create src/app/api/websocket/route.ts:

// src/app/api/websocket/route.ts (for custom server)
// For Next.js on Vercel, use SSE instead (see below)

import { Server } from 'ws';
import redis from '@/lib/redis';

let wss: Server;

export function initWebSocket(server: any) {
  if (wss) return wss;
  
  wss = new Server({ server, path: '/api/ws' });
  
  // Subscribe to Redis pub/sub
  const subscriber = redis.duplicate();
  subscriber.subscribe('status:updates');
  
  subscriber.on('message', (channel, message) => {
    // Broadcast to all connected clients
    wss.clients.forEach((client) => {
      if (client.readyState === 1) { // OPEN
        client.send(message);
      }
    });
  });
  
  wss.on('connection', (ws) => {
    console.log('WebSocket client connected');
    
    ws.on('close', () => {
      console.log('WebSocket client disconnected');
    });
  });
  
  return wss;
}

Alternative: Server-Sent Events (SSE) for Vercel:

Create src/app/api/sse/route.ts:

// src/app/api/sse/route.ts
import redis from '@/lib/redis';

export async function GET() {
  const encoder = new TextEncoder();
  
  const stream = new ReadableStream({
    async start(controller) {
      const subscriber = redis.duplicate();
      await subscriber.subscribe('status:updates');
      
      subscriber.on('message', (channel, message) => {
        const data = `data: ${message}\n\n`;
        controller.enqueue(encoder.encode(data));
      });
      
      // Send heartbeat every 30s
      const interval = setInterval(() => {
        controller.enqueue(encoder.encode(': heartbeat\n\n'));
      }, 30000);
      
      // Cleanup on close
      return () => {
        clearInterval(interval);
        subscriber.unsubscribe();
        subscriber.quit();
      };
    },
  });
  
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

export const runtime = 'nodejs';

Step 8: Frontend Dashboard

Create src/app/page.tsx:

// src/app/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { formatDistanceToNow } from 'date-fns';

interface Service {
  id: string;
  name: string;
  url: string;
  currentStatus: {
    status: 'up' | 'down' | 'degraded';
    latency: number;
    lastChecked: string;
  } | null;
}

export default function Dashboard() {
  const [services, setServices] = useState<Service[]>([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // Initial load
    fetchServices();
    
    // Set up SSE connection for real-time updates
    const eventSource = new EventSource('/api/sse');
    
    eventSource.onmessage = (event) => {
      try {
        const update = JSON.parse(event.data);
        updateServiceStatus(update.serviceId, update);
      } catch (err) {
        console.error('SSE parse error:', err);
      }
    };
    
    eventSource.onerror = () => {
      console.error('SSE connection error');
      eventSource.close();
    };
    
    return () => {
      eventSource.close();
    };
  }, []);
  
  async function fetchServices() {
    try {
      const res = await fetch('/api/services');
      const data = await res.json();
      setServices(data);
    } catch (error) {
      console.error('Failed to fetch services:', error);
    } finally {
      setLoading(false);
    }
  }
  
  function updateServiceStatus(serviceId: string, statusData: any) {
    setServices(prev =>
      prev.map(service =>
        service.id === serviceId
          ? {
              ...service,
              currentStatus: {
                status: statusData.status,
                latency: statusData.latency,
                lastChecked: statusData.timestamp,
              },
            }
          : service
      )
    );
  }
  
  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-xl">Loading services...</div>
      </div>
    );
  }
  
  const overallStatus = services.every(s => s.currentStatus?.status === 'up')
    ? 'All Systems Operational'
    : services.some(s => s.currentStatus?.status === 'down')
    ? 'Partial Outage'
    : 'Degraded Performance';
  
  return (
    <div className="min-h-screen bg-gray-50">
      <div className="max-w-6xl mx-auto px-4 py-8">
        <header className="mb-8">
          <h1 className="text-4xl font-bold mb-2">API Status Dashboard</h1>
          <div className="flex items-center gap-3">
            <StatusBadge status={overallStatus} />
            <span className="text-gray-600">
              Last updated: {new Date().toLocaleString()}
            </span>
          </div>
        </header>
        
        <div className="space-y-4">
          {services.map(service => (
            <ServiceCard key={service.id} service={service} />
          ))}
        </div>
      </div>
    </div>
  );
}

function ServiceCard({ service }: { service: Service }) {
  const status = service.currentStatus;
  
  return (
    <div className="bg-white rounded-lg shadow p-6">
      <div className="flex items-center justify-between mb-2">
        <div className="flex items-center gap-3">
          <StatusIndicator status={status?.status || 'unknown'} />
          <h3 className="text-xl font-semibold">{service.name}</h3>
        </div>
        {status && (
          <div className="text-sm text-gray-500">
            {status.latency}ms • {formatDistanceToNow(new Date(status.lastChecked))} ago
          </div>
        )}
      </div>
      <div className="text-sm text-gray-600">{service.url}</div>
    </div>
  );
}

function StatusIndicator({ status }: { status: string }) {
  const colors = {
    up: 'bg-green-500',
    down: 'bg-red-500',
    degraded: 'bg-yellow-500',
    unknown: 'bg-gray-400',
  };
  
  return (
    <div className={`w-3 h-3 rounded-full ${colors[status as keyof typeof colors]}`} />
  );
}

function StatusBadge({ status }: { status: string }) {
  const isOperational = status === 'All Systems Operational';
  
  return (
    <span className={`px-4 py-2 rounded-full text-sm font-medium ${
      isOperational
        ? 'bg-green-100 text-green-800'
        : 'bg-red-100 text-red-800'
    }`}>
      {status}
    </span>
  );
}

Step 9: Alert System

Create src/lib/alerts.ts:

// src/lib/alerts.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

interface AlertConfig {
  email?: string[];
  slack?: string; // Webhook URL
  webhook?: string; // Custom webhook URL
}

export async function sendAlert(
  serviceId: string,
  status: 'down' | 'up',
  config: AlertConfig
) {
  const service = await prisma.service.findUnique({
    where: { id: serviceId },
  });
  
  if (!service) return;
  
  const message = status === 'down'
    ? `🔴 ${service.name} is DOWN`
    : `✅ ${service.name} is back UP`;
  
  const promises = [];
  
  // Email alerts
  if (config.email && config.email.length > 0) {
    promises.push(sendEmailAlert(config.email, service.name, status));
  }
  
  // Slack alerts
  if (config.slack) {
    promises.push(sendSlackAlert(config.slack, message, service));
  }
  
  // Custom webhook
  if (config.webhook) {
    promises.push(sendWebhookAlert(config.webhook, {
      service: service.name,
      status,
      timestamp: new Date().toISOString(),
      url: service.url,
    }));
  }
  
  await Promise.allSettled(promises);
}

async function sendEmailAlert(
  recipients: string[],
  serviceName: string,
  status: string
) {
  // Integrate with Resend, SendGrid, or your email provider
  const response = await fetch('https://api.resend.com/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: 'alerts@yourdomain.com',
      to: recipients,
      subject: `Alert: ${serviceName} is ${status}`,
      html: `
        <h2>${serviceName} Status Change</h2>
        <p>Current status: <strong>${status}</strong></p>
        <p>Time: ${new Date().toLocaleString()}</p>
      `,
    }),
  });
  
  return response.json();
}

async function sendSlackAlert(
  webhookUrl: string,
  message: string,
  service: any
) {
  const response = await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: message,
      attachments: [{
        color: service.currentStatus === 'down' ? 'danger' : 'good',
        fields: [
          { title: 'Service', value: service.name, short: true },
          { title: 'URL', value: service.url, short: true },
        ],
      }],
    }),
  });
  
  return response.text();
}

async function sendWebhookAlert(url: string, payload: any) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  
  return response.text();
}

Integrate alerts in the health checker:

// Add to src/lib/health-checker.ts
import { sendAlert } from './alerts';

// Inside handleIncidents function:
if (currentStatus === 'down' && !openIncident) {
  await sendAlert(serviceId, 'down', {
    email: ['ops@yourcompany.com'],
    slack: process.env.SLACK_WEBHOOK_URL,
  });
} else if (currentStatus === 'up' && openIncident) {
  await sendAlert(serviceId, 'up', {
    email: ['ops@yourcompany.com'],
    slack: process.env.SLACK_WEBHOOK_URL,
  });
}

Deployment Options

1. Vercel (Recommended for Next.js)

# Install Vercel CLI
pnpm add -g vercel

# Set environment variables
vercel env add DATABASE_URL
vercel env add REDIS_URL
vercel env add CRON_SECRET
vercel env add RESEND_API_KEY
vercel env add SLACK_WEBHOOK_URL

# Deploy
vercel --prod

Managed Services:

  • Database: Neon or Supabase (PostgreSQL)
  • Redis: Upstash (serverless Redis)
  • Monitoring: Vercel built-in analytics

2. Railway

Railway provides a seamless deployment experience with built-in databases:

# Install Railway CLI
npm i -g @railway/cli

# Login and initialize
railway login
railway init

# Add PostgreSQL and Redis
railway add --plugin postgresql
railway add --plugin redis

# Deploy
railway up

Railway automatically provisions databases and injects environment variables.

3. Self-Hosted (Docker Compose)

Create docker-compose.yml:

version: '3.8'

services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://postgres:password@db:5432/status
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis
  
  db:
    image: postgres:15
    environment:
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=status
    volumes:
      - postgres-data:/var/lib/postgresql/data
  
  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data

volumes:
  postgres-data:
  redis-data:

Deploy:

docker-compose up -d

4. DigitalOcean App Platform

# Create app.yaml
doctl apps create --spec app.yaml

Example app.yaml:

name: api-status-dashboard
region: nyc
databases:
  - name: postgres
    engine: PG
    production: true
  - name: redis
    engine: REDIS
    production: true
services:
  - name: web
    source_dir: /
    github:
      repo: your-username/api-status-dashboard
      branch: main
    build_command: pnpm install && pnpm build
    run_command: pnpm start
    envs:
      - key: DATABASE_URL
        scope: RUN_TIME
        type: SECRET
      - key: REDIS_URL
        scope: RUN_TIME
        type: SECRET

Or Just Use API Status Check!

Building and maintaining a production-grade API status dashboard requires significant effort:

  • Initial development: 40-80 hours
  • Infrastructure costs: $50-200/month
  • Ongoing maintenance: 5-10 hours/month
  • Scaling challenges: Database optimization, rate limiting, global monitoring
  • Alert fatigue: Fine-tuning thresholds to avoid false positives

We've already solved these problems.

API Status Check provides:

Pre-built monitoring for 100+ popular APIs (Stripe, OpenAI, AWS, etc.)
Real-time dashboard with historical uptime tracking
Smart alerts via email, Slack, Discord, and webhooks
5-minute setup — no code required
Generous free tier — start monitoring today

When to Build vs. Buy

Build your own if:

  • You need to monitor internal/private APIs only
  • You have specific compliance requirements
  • You want full control over data retention
  • You have engineering time to spare

Use API Status Check if:

  • You monitor third-party APIs (Stripe, Twilio, OpenAI, etc.)
  • You want to ship fast and avoid maintenance
  • You need multi-region monitoring
  • You want a proven solution with 99.9% uptime

Start monitoring in 5 minutes →


FAQs

1. How often should I check API health?

For critical services: Every 1-2 minutes
For standard services: Every 5 minutes
For low-priority services: Every 15-30 minutes

More frequent checks provide faster incident detection but increase infrastructure costs and may trigger rate limits on monitored APIs.

2. What's the best way to check if an API is truly down?

Use a multi-region strategy. Check from at least 2-3 geographic locations to distinguish between:

  • Network issues (one region fails, others succeed)
  • True outages (all regions fail)
  • Rate limiting (403/429 errors)

Also implement synthetic transactions that test full workflows, not just ping endpoints.

3. How do I avoid alert fatigue?

Implement smart alerting:

  • Threshold: Alert only after 2-3 consecutive failures
  • Cooldown: Don't re-alert for 15-30 minutes after first alert
  • Escalation: Start with Slack, escalate to SMS/PagerDuty after 10 minutes
  • Grouping: Batch multiple related failures into one incident

See our guide on API monitoring best practices.

4. Should I use WebSockets or Server-Sent Events?

WebSockets for:

  • Bi-directional communication (clients can send commands)
  • Very high-frequency updates (multiple per second)
  • Custom binary protocols

Server-Sent Events (SSE) for:

  • One-way updates (server → client)
  • Simpler implementation
  • Better compatibility with serverless (Vercel, Cloudflare)
  • Automatic reconnection

For status dashboards, SSE is usually the better choice.

5. How do I store time-series data efficiently?

Use time-bucketing and aggregation:

-- Store raw checks for last 7 days
DELETE FROM checks WHERE checked_at < NOW() - INTERVAL '7 days';

-- Aggregate older data into hourly buckets
CREATE TABLE check_aggregates (
  service_id TEXT,
  hour TIMESTAMP,
  avg_latency INT,
  uptime_percentage DECIMAL,
  total_checks INT
);

-- Run nightly:
INSERT INTO check_aggregates
SELECT 
  service_id,
  date_trunc('hour', checked_at) as hour,
  AVG(latency) as avg_latency,
  (COUNT(*) FILTER (WHERE status = 'up')::FLOAT / COUNT(*)) * 100 as uptime_percentage,
  COUNT(*) as total_checks
FROM checks
WHERE checked_at < NOW() - INTERVAL '7 days'
GROUP BY service_id, date_trunc('hour', checked_at);

Learn more in our PostgreSQL time-series optimization guide.

6. How do I monitor APIs behind authentication?

Store credentials securely and include them in health checks:

async function checkAuthenticatedEndpoint(url: string, apiKey: string) {
  const response = await fetch(url, {
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'User-Agent': 'StatusDashboard/1.0',
    },
  });
  
  // Validate response structure, not just status code
  if (response.ok) {
    const data = await response.json();
    // Check for expected fields
    if (!data.id || !data.status) {
      throw new Error('Unexpected response structure');
    }
  }
  
  return response;
}

Store API keys in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault).

7. Can I monitor GraphQL APIs?

Yes! Send a simple introspection or health check query:

async function checkGraphQLEndpoint(url: string) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: '{ __typename }', // Minimal query
    }),
  });
  
  const data = await response.json();
  
  if (data.errors) {
    throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
  }
  
  return response;
}

For production, use a real query that exercises critical paths.

8. How do I calculate SLA compliance?

Calculate uptime percentage over a specific period:

async function calculateSLA(serviceId: string, days: number = 30) {
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
  
  const checks = await prisma.check.findMany({
    where: {
      serviceId,
      checkedAt: { gte: since },
    },
  });
  
  const totalChecks = checks.length;
  const upChecks = checks.filter(c => c.status === 'up').length;
  
  const uptimePercentage = (upChecks / totalChecks) * 100;
  
  // Common SLA tiers
  const slaStatus = 
    uptimePercentage >= 99.99 ? 'Four Nines (99.99%)' :
    uptimePercentage >= 99.95 ? 'Three Nines Plus (99.95%)' :
    uptimePercentage >= 99.9 ? 'Three Nines (99.9%)' :
    uptimePercentage >= 99.5 ? 'Two Nines Plus (99.5%)' :
    'Below SLA';
  
  const allowedDowntime = {
    '99.99%': '4m 23s/month',
    '99.95%': '21m 54s/month',
    '99.9%': '43m 49s/month',
    '99.5%': '3h 36m/month',
  };
  
  return {
    uptimePercentage: uptimePercentage.toFixed(4),
    slaStatus,
    totalChecks,
    period: `${days} days`,
  };
}

Read more about understanding API SLAs.

9. What about monitoring SOAP APIs?

Parse SOAP XML responses:

import { parseStringPromise } from 'xml2js';

async function checkSOAPEndpoint(url: string, soapAction: string) {
  const soapEnvelope = `
    <?xml version="1.0"?>
    <soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
      <soap:Body>
        <HealthCheck xmlns="http://yournamespace.com/" />
      </soap:Body>
    </soap:Envelope>
  `;
  
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'text/xml',
      'SOAPAction': soapAction,
    },
    body: soapEnvelope,
  });
  
  const xml = await response.text();
  const parsed = await parseStringPromise(xml);
  
  // Validate SOAP response structure
  if (parsed['soap:Envelope']['soap:Body'][0]['soap:Fault']) {
    throw new Error('SOAP Fault detected');
  }
  
  return response;
}

10. How do I handle APIs with dynamic responses?

Implement response validation:

async function checkWithValidation(url: string, validator: (data: any) => boolean) {
  const response = await fetch(url);
  const data = await response.json();
  
  if (!validator(data)) {
    throw new Error('Response validation failed');
  }
  
  return { status: 'up', data };
}

// Example validator
const stripeValidator = (data: any) => {
  return data.object === 'balance' && 
         typeof data.available === 'object' &&
         Array.isArray(data.available);
};

await checkWithValidation('https://api.stripe.com/v1/balance', stripeValidator);

Wrapping Up

You now have a complete blueprint for building a production-ready API status dashboard. This tutorial covered:

✅ Architecture design and database schema
✅ Health check engine with incident tracking
✅ Real-time updates via Server-Sent Events
✅ Multi-channel alerting (email, Slack, webhooks)
✅ Deployment to Vercel, Railway, or self-hosted
✅ Best practices for time-series data and SLA tracking

Next steps:

  1. Clone the example repository
  2. Deploy to Vercel in under 10 minutes
  3. Add your first services to monitor
  4. Configure alerts for your team

Or skip the setup entirely and start using API Status Check — we've already built this for you.

Related guides:

Have questions? Join our Discord community or email us.


Last updated: January 30, 2025

Monitor Your APIs

Check the real-time status of 100+ popular APIs used by developers.

View API Status →