Building SaaS with Laravel (Part 6): Admin Dashboard & Metrics

· 17 min read

In Part 5, we built usage tracking. Now it's time for the part that you — as a SaaS owner — need most: an admin dashboard to monitor the entire system.

1. Admin Architecture

Admin operates outside the tenant context — you need to see all tenants, all data.

┌──────────────────────────────────┐
│          Admin Dashboard          │
│                                   │
│  ┌───────────┐  ┌──────────────┐ │
│  │  Revenue   │  │   Tenants    │ │
│  │  Metrics   │  │   Overview   │ │
│  └───────────┘  └──────────────┘ │
│  ┌───────────┐  ┌──────────────┐ │
│  │   Growth   │  │   Health     │ │
│  │   Charts   │  │   Checks    │ │
│  └───────────┘  └──────────────┘ │
└──────────────────────────────────┘

2. Admin Middleware

// app/Http/Middleware/AdminOnly.php
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class AdminOnly
{
    /**
     * Admin is determined by email in config.
     * Simple approach — no extra complexity for a personal SaaS.
     */
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if (! $user || ! in_array($user->email, config('admin.emails', []), true)) {
            abort(403, 'Unauthorized.');
        }

        return $next($request);
    }
}
// config/admin.php
<?php

return [
    'emails' => explode(',', env('ADMIN_EMAILS', '')),
];
ADMIN_EMAILS=you@example.com,co-founder@example.com

3. Metrics Service

The central service for calculating all metrics:

// app/Domain/Admin/Services/MetricsService.php
<?php

declare(strict_types=1);

namespace App\Domain\Admin\Services;

use App\Domain\Tenant\Models\Tenant;
use App\Domain\User\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Laravel\Cashier\Subscription;

class MetricsService
{
    /**
     * Main overview metrics.
     */
    public function overview(): array
    {
        return Cache::remember('admin:metrics:overview', 300, function () {
            return [
                'total_tenants' => Tenant::count(),
                'active_tenants' => Tenant::where('status', 'active')->count(),
                'trial_tenants' => Tenant::whereNotNull('trial_ends_at')
                    ->where('trial_ends_at', '>', now())
                    ->whereDoesntHave('subscriptions')
                    ->count(),
                'total_users' => User::count(),
                'mrr' => $this->calculateMRR(),
                'arr' => $this->calculateMRR() * 12,
                'churn_rate' => $this->calculateChurnRate(),
                'trial_conversion_rate' => $this->trialConversionRate(),
            ];
        });
    }

    /**
     * Monthly Recurring Revenue — the most important metric.
     */
    public function calculateMRR(): float
    {
        return Cache::remember('admin:metrics:mrr', 600, function () {
            $subscriptions = Subscription::where('stripe_status', 'active')->get();

            $mrr = 0;

            foreach ($subscriptions as $subscription) {
                $tenant = Tenant::find($subscription->billable_id);

                if (! $tenant?->plan) {
                    continue;
                }

                $plan = $tenant->plan;

                if ($subscription->stripe_price === $plan->stripe_yearly_price_id) {
                    // Yearly subscription → MRR = yearly / 12
                    $mrr += $plan->yearly_price / 12;
                } else {
                    // Monthly subscription
                    $mrr += $plan->monthly_price;
                }
            }

            return round($mrr, 2);
        });
    }

    /**
     * MRR over time (for charts).
     */
    public function mrrOverTime(int $months = 12): array
    {
        $data = [];

        for ($i = $months - 1; $i >= 0; $i--) {
            $date = now()->subMonths($i);
            $monthKey = $date->format('Y-m');

            // Count active subscriptions at the end of each month
            $activeCount = Subscription::where('stripe_status', 'active')
                ->where('created_at', '<=', $date->endOfMonth())
                ->where(function ($q) use ($date) {
                    $q->whereNull('ends_at')
                        ->orWhere('ends_at', '>', $date->endOfMonth());
                })
                ->count();

            $data[] = [
                'month' => $monthKey,
                'label' => $date->format('M Y'),
                'subscriptions' => $activeCount,
            ];
        }

        return $data;
    }

    /**
     * Churn rate: % of tenants who cancelled in the current month.
     */
    public function calculateChurnRate(): float
    {
        $startOfMonth = now()->startOfMonth();

        $activeStart = Subscription::where('stripe_status', 'active')
            ->where('created_at', '<', $startOfMonth)
            ->count();

        if ($activeStart === 0) {
            return 0;
        }

        $cancelled = Subscription::where('stripe_status', 'canceled')
            ->where('updated_at', '>=', $startOfMonth)
            ->count();

        return round(($cancelled / $activeStart) * 100, 2);
    }

    /**
     * Conversion rate from trial to paid.
     */
    public function trialConversionRate(): float
    {
        $totalTrials = Tenant::whereNotNull('trial_ends_at')
            ->where('trial_ends_at', '<', now()) // Trial has ended
            ->count();

        if ($totalTrials === 0) {
            return 0;
        }

        $converted = Tenant::whereNotNull('trial_ends_at')
            ->where('trial_ends_at', '<', now())
            ->whereHas('subscriptions', function ($q) {
                $q->where('stripe_status', 'active');
            })
            ->count();

        return round(($converted / $totalTrials) * 100, 2);
    }

    /**
     * Top tenants by user count.
     */
    public function topTenants(int $limit = 10): array
    {
        return Tenant::withCount('users')
            ->with('plan')
            ->orderByDesc('users_count')
            ->limit($limit)
            ->get()
            ->map(fn (Tenant $t) => [
                'id' => $t->id,
                'name' => $t->name,
                'slug' => $t->slug,
                'plan' => $t->plan?->name ?? 'No plan',
                'users_count' => $t->users_count,
                'status' => $t->status,
                'created_at' => $t->created_at->format('Y-m-d'),
            ])
            ->toArray();
    }

    /**
     * Signups by day (last 30 days).
     */
    public function signupsOverTime(int $days = 30): array
    {
        return Tenant::where('created_at', '>=', now()->subDays($days))
            ->select(
                DB::raw('DATE(created_at) as date'),
                DB::raw('COUNT(*) as count')
            )
            ->groupBy('date')
            ->orderBy('date')
            ->get()
            ->map(fn ($row) => [
                'date' => $row->date,
                'count' => $row->count,
            ])
            ->toArray();
    }

    /**
     * Revenue breakdown by plan.
     */
    public function revenueByPlan(): array
    {
        return Tenant::whereHas('subscriptions', function ($q) {
            $q->where('stripe_status', 'active');
        })
            ->with('plan')
            ->get()
            ->groupBy(fn (Tenant $t) => $t->plan?->slug ?? 'unknown')
            ->map(function ($tenants, $planSlug) {
                $plan = $tenants->first()->plan;

                return [
                    'plan' => $plan?->name ?? 'Unknown',
                    'count' => $tenants->count(),
                    'mrr' => $tenants->sum(fn ($t) => $t->plan?->monthly_price ?? 0),
                ];
            })
            ->values()
            ->toArray();
    }
}

4. Admin Controller

// app/Http/Controllers/Admin/AdminDashboardController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Domain\Admin\Services\MetricsService;
use App\Http\Controllers\Controller;

class AdminDashboardController extends Controller
{
    public function __construct(
        private readonly MetricsService $metrics,
    ) {}

    public function index()
    {
        return view('admin.dashboard', [
            'overview' => $this->metrics->overview(),
            'mrrChart' => $this->metrics->mrrOverTime(),
            'signupsChart' => $this->metrics->signupsOverTime(),
            'revenueByPlan' => $this->metrics->revenueByPlan(),
            'topTenants' => $this->metrics->topTenants(),
        ]);
    }
}

5. Tenant Management Controller

// app/Http/Controllers/Admin/TenantManagementController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Domain\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class TenantManagementController extends Controller
{
    public function index(Request $request)
    {
        $query = Tenant::with(['plan', 'owner'])
            ->withCount('users');

        // Search
        if ($search = $request->input('search')) {
            $query->where(function ($q) use ($search) {
                $q->where('name', 'like', "%{$search}%")
                    ->orWhere('slug', 'like', "%{$search}%")
                    ->orWhereHas('owner', fn ($q) => $q->where('email', 'like', "%{$search}%"));
            });
        }

        // Filter by status
        if ($status = $request->input('status')) {
            $query->where('status', $status);
        }

        // Filter by plan
        if ($planId = $request->input('plan_id')) {
            $query->where('plan_id', $planId);
        }

        $tenants = $query->orderByDesc('created_at')->paginate(25);

        return view('admin.tenants.index', [
            'tenants' => $tenants,
        ]);
    }

    public function show(Tenant $tenant)
    {
        $tenant->load(['owner', 'plan', 'users', 'projects']);

        $subscription = $tenant->subscription('default');

        return view('admin.tenants.show', [
            'tenant' => $tenant,
            'subscription' => $subscription,
            'invoices' => $tenant->hasStripeId() ? $tenant->invoices() : [],
        ]);
    }

    public function suspend(Tenant $tenant)
    {
        $tenant->update(['status' => 'suspended']);

        return back()->with('success', "Tenant suspended: {$tenant->name}");
    }

    public function activate(Tenant $tenant)
    {
        $tenant->update(['status' => 'active']);

        return back()->with('success', "Tenant activated: {$tenant->name}");
    }

    public function impersonate(Tenant $tenant)
    {
        $admin = auth()->user();

        // Save admin session so we can return later
        session(['admin_impersonating' => $admin->id]);

        // Login as tenant owner
        auth()->login($tenant->owner);

        return redirect()->route('dashboard')
            ->with('info', "Impersonating: {$tenant->name}");
    }

    public function stopImpersonating()
    {
        $adminId = session('admin_impersonating');

        if ($adminId) {
            $admin = \App\Domain\User\Models\User::findOrFail($adminId);
            auth()->login($admin);
            session()->forget('admin_impersonating');
        }

        return redirect()->route('admin.dashboard')
            ->with('success', 'Returned to admin account.');
    }
}

6. Admin Routes

// routes/web.php
Route::prefix('admin')
    ->middleware(['auth', 'admin'])
    ->name('admin.')
    ->group(function () {
        Route::get('/', [AdminDashboardController::class, 'index'])->name('dashboard');

        // Tenant management
        Route::get('/tenants', [TenantManagementController::class, 'index'])->name('tenants.index');
        Route::get('/tenants/{tenant}', [TenantManagementController::class, 'show'])->name('tenants.show');
        Route::post('/tenants/{tenant}/suspend', [TenantManagementController::class, 'suspend'])->name('tenants.suspend');
        Route::post('/tenants/{tenant}/activate', [TenantManagementController::class, 'activate'])->name('tenants.activate');

        // Impersonation
        Route::post('/tenants/{tenant}/impersonate', [TenantManagementController::class, 'impersonate'])->name('tenants.impersonate');
        Route::post('/stop-impersonating', [TenantManagementController::class, 'stopImpersonating'])->name('stop-impersonating');

        // Metrics API (for charts)
        Route::get('/api/metrics/mrr', fn () => response()->json(
            app(MetricsService::class)->mrrOverTime()
        ))->name('api.metrics.mrr');

        Route::get('/api/metrics/signups', fn () => response()->json(
            app(MetricsService::class)->signupsOverTime()
        ))->name('api.metrics.signups');
    });

7. Dashboard View

{{-- resources/views/admin/dashboard.blade.php --}}
@extends('layouts.admin')

@section('content')
<div class="max-w-7xl mx-auto py-8 px-4">
    <h1 class="text-3xl font-bold mb-8">Admin Dashboard</h1>

    {{-- KPI Cards --}}
    <div class="grid grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">MRR</p>
            <p class="text-3xl font-bold text-green-600">
                ${{ number_format($overview['mrr'], 0) }}
            </p>
        </div>

        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">ARR</p>
            <p class="text-3xl font-bold">
                ${{ number_format($overview['arr'], 0) }}
            </p>
        </div>

        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">Active Tenants</p>
            <p class="text-3xl font-bold">{{ $overview['active_tenants'] }}</p>
            <p class="text-xs text-gray-400">
                +{{ $overview['trial_tenants'] }} on trial
            </p>
        </div>

        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">Total Users</p>
            <p class="text-3xl font-bold">{{ $overview['total_users'] }}</p>
        </div>
    </div>

    {{-- Secondary KPIs --}}
    <div class="grid grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">Churn Rate (this month)</p>
            <p class="text-2xl font-bold {{ $overview['churn_rate'] > 5 ? 'text-red-500' : 'text-green-600' }}">
                {{ $overview['churn_rate'] }}%
            </p>
        </div>

        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">Trial → Paid Conversion</p>
            <p class="text-2xl font-bold">{{ $overview['trial_conversion_rate'] }}%</p>
        </div>

        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <p class="text-sm text-gray-500">ARPU</p>
            <p class="text-2xl font-bold">
                ${{ $overview['active_tenants'] > 0
                    ? number_format($overview['mrr'] / $overview['active_tenants'], 2)
                    : '0' }}
            </p>
        </div>
    </div>

    {{-- Revenue by Plan --}}
    <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <h3 class="font-semibold mb-4">Revenue by Plan</h3>
            <table class="w-full text-sm">
                <thead>
                    <tr class="text-left text-gray-500 border-b dark:border-gray-700">
                        <th class="pb-2">Plan</th>
                        <th class="pb-2">Tenants</th>
                        <th class="pb-2 text-right">MRR</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach($revenueByPlan as $plan)
                    <tr class="border-b dark:border-gray-700">
                        <td class="py-2 font-medium">{{ $plan['plan'] }}</td>
                        <td class="py-2">{{ $plan['count'] }}</td>
                        <td class="py-2 text-right">${{ number_format($plan['mrr'], 0) }}</td>
                    </tr>
                    @endforeach
                </tbody>
            </table>
        </div>

        {{-- Signups Chart --}}
        <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
            <h3 class="font-semibold mb-4">Signups (last 30 days)</h3>
            <canvas id="signupsChart" height="200"></canvas>
        </div>
    </div>

    {{-- Top Tenants --}}
    <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
        <div class="flex justify-between items-center mb-4">
            <h3 class="font-semibold">Top Tenants</h3>
            <a href="{{ route('admin.tenants.index') }}" class="text-sm text-blue-600">
                View all →
            </a>
        </div>
        <table class="w-full text-sm">
            <thead>
                <tr class="text-left text-gray-500 border-b dark:border-gray-700">
                    <th class="pb-2">Tenant</th>
                    <th class="pb-2">Plan</th>
                    <th class="pb-2">Members</th>
                    <th class="pb-2">Status</th>
                    <th class="pb-2">Created</th>
                </tr>
            </thead>
            <tbody>
                @foreach($topTenants as $tenant)
                <tr class="border-b dark:border-gray-700">
                    <td class="py-2">
                        <a href="{{ route('admin.tenants.show', $tenant['id']) }}" class="font-medium text-blue-600 hover:underline">
                            {{ $tenant['name'] }}
                        </a>
                        <span class="text-xs text-gray-400 block">{{ $tenant['slug'] }}</span>
                    </td>
                    <td class="py-2">{{ $tenant['plan'] }}</td>
                    <td class="py-2">{{ $tenant['users_count'] }}</td>
                    <td class="py-2">
                        <span class="px-2 py-0.5 rounded-full text-xs
                            {{ $tenant['status'] === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' }}">
                            {{ $tenant['status'] }}
                        </span>
                    </td>
                    <td class="py-2 text-gray-500">{{ $tenant['created_at'] }}</td>
                </tr>
                @endforeach
            </tbody>
        </table>
    </div>
</div>

{{-- Chart.js --}}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
const signupsData = @json($signupsChart);
new Chart(document.getElementById('signupsChart'), {
    type: 'bar',
    data: {
        labels: signupsData.map(d => d.date),
        datasets: [{
            label: 'Signups',
            data: signupsData.map(d => d.count),
            backgroundColor: 'rgba(59, 130, 246, 0.5)',
            borderColor: 'rgb(59, 130, 246)',
            borderWidth: 1,
        }]
    },
    options: {
        responsive: true,
        scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
        plugins: { legend: { display: false } },
    }
});
</script>
@endsection

8. Health Check Endpoint

// app/Http/Controllers/Admin/HealthCheckController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;

class HealthCheckController extends Controller
{
    public function __invoke()
    {
        $checks = [
            'database' => $this->checkDatabase(),
            'cache' => $this->checkCache(),
            'queue' => $this->checkQueue(),
            'storage' => $this->checkStorage(),
            'stripe' => $this->checkStripe(),
        ];

        $allHealthy = collect($checks)->every(fn ($check) => $check['status'] === 'ok');

        return response()->json([
            'status' => $allHealthy ? 'healthy' : 'degraded',
            'checks' => $checks,
            'timestamp' => now()->toIso8601String(),
        ], $allHealthy ? 200 : 503);
    }

    private function checkDatabase(): array
    {
        try {
            $start = microtime(true);
            DB::select('SELECT 1');
            $time = round((microtime(true) - $start) * 1000, 2);

            return ['status' => 'ok', 'response_time_ms' => $time];
        } catch (\Exception $e) {
            return ['status' => 'error', 'message' => 'Database unreachable'];
        }
    }

    private function checkCache(): array
    {
        try {
            Cache::put('health_check', true, 10);

            return Cache::get('health_check')
                ? ['status' => 'ok']
                : ['status' => 'error', 'message' => 'Cache read failed'];
        } catch (\Exception $e) {
            return ['status' => 'error', 'message' => 'Cache unreachable'];
        }
    }

    private function checkQueue(): array
    {
        try {
            $size = Queue::size();

            return [
                'status' => $size < 1000 ? 'ok' : 'warning',
                'queue_size' => $size,
            ];
        } catch (\Exception $e) {
            return ['status' => 'error', 'message' => 'Queue unreachable'];
        }
    }

    private function checkStorage(): array
    {
        $free = disk_free_space(storage_path());
        $total = disk_total_space(storage_path());
        $usedPercent = round((1 - $free / $total) * 100, 1);

        return [
            'status' => $usedPercent < 90 ? 'ok' : 'warning',
            'disk_usage_percent' => $usedPercent,
            'free_gb' => round($free / 1073741824, 2),
        ];
    }

    private function checkStripe(): array
    {
        try {
            \Stripe\Stripe::setApiKey(config('cashier.secret'));
            \Stripe\Balance::retrieve();

            return ['status' => 'ok'];
        } catch (\Exception $e) {
            return ['status' => 'error', 'message' => 'Stripe API unreachable'];
        }
    }
}

9. Admin Notifications

Send notifications when important events occur:

// app/Domain/Admin/Notifications/DailyMetricsNotification.php
<?php

declare(strict_types=1);

namespace App\Domain\Admin\Notifications;

use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class DailyMetricsNotification extends Notification
{
    public function __construct(
        private readonly array $metrics,
    ) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage())
            ->subject("Daily SaaS Metrics — {$this->metrics['date']}")
            ->line("**MRR**: \${$this->metrics['mrr']}")
            ->line("**New signups today**: {$this->metrics['new_signups']}")
            ->line("**Active tenants**: {$this->metrics['active_tenants']}")
            ->line("**Churn rate**: {$this->metrics['churn_rate']}%")
            ->action('View Dashboard', route('admin.dashboard'));
    }
}

Schedule the daily report:

// routes/console.php
Schedule::call(function () {
    $metrics = app(MetricsService::class);
    $overview = $metrics->overview();

    $adminEmails = config('admin.emails');

    foreach ($adminEmails as $email) {
        $admin = User::where('email', $email)->first();
        $admin?->notify(new DailyMetricsNotification([
            'date' => now()->format('Y-m-d'),
            'mrr' => $overview['mrr'],
            'new_signups' => Tenant::whereDate('created_at', today())->count(),
            'active_tenants' => $overview['active_tenants'],
            'churn_rate' => $overview['churn_rate'],
        ]));
    }
})->dailyAt('08:00');

10. Testing Admin

// tests/Feature/AdminDashboardTest.php
<?php

use App\Domain\Tenant\Actions\CreateTenantAction;
use App\Domain\User\Models\User;

test('non-admin cannot access admin dashboard', function () {
    $user = User::factory()->create(['email' => 'regular@test.com']);

    $this->actingAs($user)
        ->get('/admin')
        ->assertForbidden();
});

test('admin can access dashboard', function () {
    config(['admin.emails' => ['admin@test.com']]);
    $admin = User::factory()->create(['email' => 'admin@test.com']);

    $this->actingAs($admin)
        ->get('/admin')
        ->assertOk()
        ->assertSee('Admin Dashboard');
});

test('admin can view tenant details', function () {
    config(['admin.emails' => ['admin@test.com']]);
    $admin = User::factory()->create(['email' => 'admin@test.com']);

    $user = User::factory()->create();
    $tenant = (new CreateTenantAction())->execute($user, 'Test Co');

    $this->actingAs($admin)
        ->get("/admin/tenants/{$tenant->id}")
        ->assertOk()
        ->assertSee('Test Co');
});

test('admin can suspend tenant', function () {
    config(['admin.emails' => ['admin@test.com']]);
    $admin = User::factory()->create(['email' => 'admin@test.com']);

    $user = User::factory()->create();
    $tenant = (new CreateTenantAction())->execute($user, 'Bad Tenant');

    $this->actingAs($admin)
        ->post("/admin/tenants/{$tenant->id}/suspend")
        ->assertRedirect();

    expect($tenant->fresh()->status)->toBe('suspended');
});

test('health check returns healthy status', function () {
    config(['admin.emails' => ['admin@test.com']]);
    $admin = User::factory()->create(['email' => 'admin@test.com']);

    $this->actingAs($admin)
        ->get('/admin/health')
        ->assertOk()
        ->assertJson(['status' => 'healthy']);
});

Summary

In Part 6, we built:

  • MetricsService to calculate MRR, ARR, churn rate, conversion rate
  • Admin dashboard with KPI cards, charts, top tenants
  • Tenant management: search, filter, suspend, activate
  • Impersonation for debugging customer issues
  • Health check endpoint for monitoring
  • Daily metrics email for admins
  • Charts with Chart.js

Key metrics every SaaS needs to track:

  • MRR (Monthly Recurring Revenue) — metric #1
  • Churn rate — should be < 5%/month
  • Trial conversion rate — target > 20%
  • ARPU (Average Revenue Per User)

Next up: API & Webhooks — building REST APIs for third-party integrations and a webhook system.

Comments