Truth:** Use a managed Postgres instance (Supabase, Neon, or Turso). Avoid proprietary no-code backends. The internal dashboard and the public app share the same database or a replicated read-replica.
2. Unified Webhook Ingestion: A single API route handles all incoming webhooks. This route validates signatures, ensures idempotency, and writes to a webhook_events table before routing logic.
3. Internal Dashboard: A lightweight Next.js/Remix application protected by a simple API key or magic link. This dashboard queries the unified schema to show revenue, user health, and error rates.
4. Code-First Automation: Replace Zapier/Make with serverless functions triggered by database changes or scheduled cron jobs. This eliminates per-action costs and improves reliability.
Technical Implementation
1. Unified Webhook Handler
This TypeScript implementation ensures that all external events are captured reliably. It uses a database transaction to guarantee that an event is logged before any business logic executes, preventing lost events.
// lib/webhooks/handler.ts
import { db } from '@/lib/db';
import { verifyStripeSignature } from '@/lib/stripe';
import { handleStripeEvent } from '@/lib/events/stripe';
import { handleResendEvent } from '@/lib/events/resend';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
const provider = req.headers.get('x-provider') || 'unknown';
try {
// 1. Verification
if (provider === 'stripe') {
const event = verifyStripeSignature(body, signature);
// 2. Idempotency & Transaction
await db.$transaction(async (tx) => {
// Check if event already processed
const existing = await tx.webhookEvent.findUnique({
where: { external_id: event.id }
});
if (existing) {
return { status: 'duplicate', id: event.id };
}
// Log event immediately
await tx.webhookEvent.create({
data: {
external_id: event.id,
provider: 'stripe',
type: event.type,
payload: event.data,
processed_at: new Date(),
}
});
// 3. Route to Handler
await handleStripeEvent(tx, event);
});
return new Response('OK', { status: 200 });
}
// Handle other providers similarly...
return new Response('Unsupported Provider', { status: 400 });
} catch (error) {
// Log to monitoring service (e.g., Sentry)
console.error('Webhook processing failed:', error);
return new Response('Error', { status: 500 });
}
}
2. Internal Dashboard Query Pattern
The internal dashboard should not query external APIs. It should query the local database, which is populated by the webhook handlers. This ensures the dashboard loads instantly and works even if third-party APIs are down.
// app/dashboard/page.tsx
import { db } from '@/lib/db';
import { formatCurrency } from '@/lib/utils';
export default async function DashboardPage() {
// Single query to aggregate key metrics
const metrics = await db.$queryRaw`
SELECT
COUNT(DISTINCT u.id) as total_users,
COUNT(DISTINCT CASE WHEN s.status = 'active' THEN s.id END) as active_subs,
SUM(s.amount) as mrr
FROM users u
LEFT JOIN subscriptions s ON u.id = s.user_id
WHERE u.created_at >= NOW() - INTERVAL '30 days'
`;
// Recent webhook failures for ops visibility
const recentErrors = await db.webhookEvent.findMany({
where: { status: 'error' },
orderBy: { created_at: 'desc' },
take: 5
});
return (
<div className="grid gap-6 p-6">
<div className="grid grid-cols-3 gap-4">
<MetricCard label="Total Users" value={metrics.total_users} />
<MetricCard label="Active Subs" value={metrics.active_subs} />
<MetricCard label="MRR" value={formatCurrency(metrics.mrr)} />
</div>
{recentErrors.length > 0 && (
<Alert variant="destructive">
<AlertTitle>Recent Webhook Failures</AlertTitle>
<ul>
{recentErrors.map(e => (
<li key={e.id}>{e.type} - {e.error_message}</li>
))}
</ul>
</Alert>
)}
</div>
);
}
3. Schema Design for the OS
The database schema must support the unified model. Key tables include:
users: Core identity.
subscriptions: Linked to Stripe Customer ID.
webhook_events: Audit log of all external events.
internal_notes: Developer annotations on users or metrics.
feature_flags: Local control of feature rollout.
Pitfall Guide
- Notion as the Source of Truth: Using Notion as the primary database for user data or operations introduces rate limits, poor querying capabilities, and sync latency. Notion is for documentation; Postgres is for data.
- Zapier/Make Dependency for Core Logic: Relying on no-code automation for critical paths (e.g., provisioning accounts, updating billing) creates invisible failure modes. If a Zap fails, users may be charged but not provisioned. Move critical logic to server-side code.
- Ignoring Idempotency in Webhooks: Webhooks are delivered at least once. Without idempotency checks (verifying
event.id before processing), the stack will create duplicate records, double-charge users, or send multiple emails.
- Fragmented Analytics: Using Mixpanel for product analytics, GA4 for traffic, and Stripe for revenue creates a "dashboard fatigue" loop. Consolidate analytics events into the database or a single warehouse (e.g., Postgres + Materialize) for a unified view.
- No Backup Strategy for the OS: Indie hackers often backup the production app but neglect the "OS" data. Implement automated daily backups of the Postgres instance with retention policies. Test restoration quarterly.
- Over-Engineering the Internal Tool: The internal dashboard should be minimal. Avoid building complex workflows inside the OS. The goal is visibility and manual override capabilities, not replicating the product's complexity.
- Hardcoding Secrets in Environment Variables: As the stack grows, managing
.env files becomes error-prone. Use a secrets manager (Vercel Vault, Doppler, or AWS Secrets Manager) and rotate keys regularly, especially for webhook secrets.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| MVP (< 1k users) | Supabase + Next.js + Stripe | Rapid development, generous free tier, unified auth/DB. | $0 β $25/mo |
| Growth ($10k MRR) | Neon + Vercel Pro + Resend | Better performance scaling, dedicated support, lower transaction costs. | ~$100 β $150/mo |
| Data-Heavy Product | Supabase + Materialize | Real-time analytics on Postgres without ETL complexity. | ~$50 β $100/mo |
| Regulated Industry | Self-hosted Postgres + VPS | Full data sovereignty and audit control. | ~$30/mo + DevOps time |
Configuration Template
Copy this stack.config.ts to initialize the environment structure and define the unified stack components.
// stack.config.ts
export const stackConfig = {
database: {
provider: 'postgres',
url: process.env.DATABASE_URL,
schema: './prisma/schema.prisma', // or drizzle schema
backup: {
enabled: true,
frequency: 'daily',
retention_days: 30
}
},
webhooks: {
endpoint: '/api/webhooks',
providers: ['stripe', 'resend', 'vercel'],
idempotency_window_hours: 24
},
dashboard: {
path: '/internal',
auth: {
type: 'api-key', // or 'magic-link'
header: 'X-Internal-Key'
},
metrics: ['mrr', 'churn', 'active_users', 'error_rate']
},
monitoring: {
error_tracking: 'sentry',
uptime: 'betteruptime',
alerts: {
webhook_failures: true,
cost_spike_threshold: 0.20 // 20% increase
}
}
};
Quick Start Guide
- Initialize Project: Run
npx create-indie-stack my-os to scaffold the repository with the unified architecture, TypeScript config, and internal dashboard boilerplate.
- Configure Environment: Copy
.env.example to .env and populate DATABASE_URL, STRIPE_SECRET_KEY, and INTERNAL_API_KEY.
- Deploy Database: Run
npm run db:migrate to create the schema. Connect your managed Postgres instance.
- Deploy Webhooks: Push to Vercel/Netlify. Update Stripe and Resend settings to point to
https://<your-domain>/api/webhooks.
- Verify Stack: Trigger a test webhook. Check the internal dashboard at
/internal to confirm the event appears in the logs and metrics update.
This stack reduces operational complexity, eliminates data silos, and provides a scalable foundation for the one-person business. By centralizing control in code and the database, the indie hacker retains agency over their product's trajectory and economics.