APIForge - Stripe Billing, Tiered Limits, and Shipping to Production
How APIForge integrates Stripe for subscription billing, enforces tiered API limits, handles webhook events, and what it took to go from local dev to a production deployment.
The Billing Problem
You can build the best developer tool in the world and still fail if the billing layer is broken. Too restrictive and users churn before they see the value. Too lenient and you're subsidizing heavy users. And if the billing code is buggy, you either don't charge people or you charge them incorrectly — both are bad.
This post covers how APIForge handles Stripe integration, tier enforcement, and what shipping to production actually looked like.
Subscription Tiers
APIForge has three tiers:
| Tier | Price | APIs | Requests/min | Storage |
|---|---|---|---|---|
| Free | $0 | 1 | 100 | 256MB DB |
| Pro | $12/mo | 10 | 1,000 | 1GB DB each |
| Enterprise | $49/mo | Unlimited | 10,000 | 5GB DB each |
The free tier is real — not crippled. You get one fully isolated API with real PostgreSQL and real subdomain routing. The limits kick in only at scale.
Stripe Integration
We use Stripe Checkout for subscription creation and Stripe's Customer Portal for plan changes and cancellations. This offloads the entire payment UI to Stripe — no card form on our end, no PCI scope.
Creating a Checkout Session
async function createCheckoutSession(
userId: string,
tier: "pro" | "enterprise"
): Promise<string> {
const user = await db.query(
"SELECT email, stripe_customer_id FROM users WHERE id = $1",
[userId]
);
const priceId = {
pro: process.env.STRIPE_PRO_PRICE_ID!,
enterprise: process.env.STRIPE_ENTERPRISE_PRICE_ID!,
}[tier];
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: user.rows[0].stripe_customer_id ?? undefined,
customer_email: user.rows[0].stripe_customer_id
? undefined
: user.rows[0].email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId },
subscription_data: {
metadata: { userId },
},
});
return session.url!;
}Webhook Handler
The webhook is the authoritative source of truth for subscription state. We never trust the redirect URL alone — a user could close the browser before the redirect fires.
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
const sig = req.headers["stripe-signature"] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return res.status(400).send("Invalid signature");
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
await handleSubscriptionActivated(
session.metadata!.userId,
session.subscription as string
);
break;
}
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(sub);
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
await handleSubscriptionCancelled(sub.metadata.userId);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailed(invoice.subscription as string);
break;
}
}
res.json({ received: true });
}
);Idempotency
Stripe can deliver webhooks more than once. Every handler checks whether the event has already been processed before making any database changes:
async function handleSubscriptionActivated(
userId: string,
subscriptionId: string
): Promise<void> {
const existing = await db.query(
"SELECT id FROM subscriptions WHERE stripe_subscription_id = $1",
[subscriptionId]
);
if (existing.rows.length > 0) return; // already processed
await db.query(
`INSERT INTO subscriptions (user_id, stripe_subscription_id, tier, status)
VALUES ($1, $2, $3, 'active')
ON CONFLICT (user_id) DO UPDATE
SET tier = EXCLUDED.tier, status = 'active',
stripe_subscription_id = EXCLUDED.stripe_subscription_id`,
[userId, subscriptionId, getTierFromPriceId(subscriptionId)]
);
await enforceNewLimits(userId);
}Tier Enforcement
When a user downgrades or their payment fails, we need to bring their account into compliance with the new tier limits. We don't delete their data immediately — we give a 7-day grace period.
async function enforceNewLimits(userId: string): Promise<void> {
const { tier } = await getUserTier(userId);
const limits = TIER_LIMITS[tier];
const apis = await db.query(
"SELECT id, slug FROM user_apis WHERE user_id = $1 ORDER BY created_at DESC",
[userId]
);
// If over the API limit, mark excess as suspended (not deleted)
if (apis.rows.length > limits.maxApis) {
const excess = apis.rows.slice(limits.maxApis);
for (const api of excess) {
await suspendApi(api.slug); // stops the container, keeps data
}
}
// Update rate limit tier in Redis
await redis.set(`tier:${userId}`, tier, "EX", 3600);
}Suspended APIs are greyed out in the dashboard with a clear message. Upgrading instantly un-suspends them — the containers restart and the Nginx config is re-enabled.
Production Deployment
Server Setup
APIForge runs on a single VPS (4 vCPU, 8GB RAM, 160GB SSD). Not Kubernetes, not a managed cloud — a plain Ubuntu server. The constraint was cost: the free tier needs to be genuinely free, which means infrastructure overhead has to be minimal.
The stack on the server:
Nginx (reverse proxy + SSL termination)
↕
Node.js API server (PM2, cluster mode, 4 workers)
↕
Redis (rate limiting, session cache)
↕
PostgreSQL (platform data: users, keys, subscriptions)
↕
Docker Engine (provisioned API containers)
PM2 Configuration
// ecosystem.config.js
module.exports = {
apps: [
{
name: "apiforge",
script: "dist/index.js",
instances: 4,
exec_mode: "cluster",
env_production: {
NODE_ENV: "production",
PORT: 4000,
},
max_memory_restart: "1G",
log_date_format: "YYYY-MM-DD HH:mm:ss",
},
],
};Zero-Downtime Deploys
Deploys use PM2's reload command (not restart) which does a rolling restart of cluster workers — each worker is replaced one at a time, so there's always at least one worker handling requests.
# deploy.sh
git pull origin main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --env productionThis whole script runs in under 30 seconds.
Monitoring
We use Uptime Kuma for uptime monitoring (ironic, given that's another project on this blog). Three checks:
- The main dashboard (
https://apiforgelive.xyz) - The API server health endpoint (
/health) - A canary provisioned API that runs every 10 minutes end-to-end
The canary check is the most useful — it catches issues that don't affect the dashboard but break provisioning (Docker daemon, disk space, port exhaustion).
Lessons Learned
On Stripe webhooks: Always use the webhook as your source of truth, not the redirect. We had a bug early on where a user would complete checkout, the redirect fired, we updated their tier — but then the webhook also fired and ran the same update logic twice, causing a constraint violation. Fixed by adding idempotency checks in every handler.
On Docker cleanup: Running docker system prune manually seemed fine until it deleted volumes for containers that were stopped (not running) during off-peak hours. We now only prune images, never volumes, and explicitly whitelist containers that should survive restarts.
On port allocation: Finding a free port by scanning from 5433 to 6000 works fine at small scale. At larger scale you want a proper port registry. For now it's a SELECT MAX(db_port) + 1 FROM provisioned_apis with a lock, which is good enough.
On free tier abuse: Some users provision an API, scrape the generated code, and delete it immediately to stay under the limit. That's fine — the provisioning cost is low and the generated code isn't secret. The free tier is intentionally useful.
What's Next
APIForge is live at apiforgelive.xyz. The core loop — describe it, get a URL — works well. The next things on the roadmap are:
- Custom domains: Let users bring their own domain instead of the
.apiforgelive.xyzsubdomain - Schema migrations: Right now, modifying a schema after provisioning requires re-provisioning. Adding
ALTER TABLEsupport is the most-requested feature - Export to code: Download the full generated codebase as a ZIP so you can self-host it
Source is at github.com/ishikabhoyar/apiforge.
This is the fourth and final post in the APIForge series. Posts: Introduction · Provisioning · Routing & OpenAPI · Billing & Production