Welcome!

Slide to unlock and explore

Slide to unlock

Command Palette

Search for a command to run...

0
Blog
PreviousNext

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:

TierPriceAPIsRequests/minStorage
Free$01100256MB DB
Pro$12/mo101,0001GB DB each
Enterprise$49/moUnlimited10,0005GB 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 production

This 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:

  1. The main dashboard (https://apiforgelive.xyz)
  2. The API server health endpoint (/health)
  3. 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.xyz subdomain
  • Schema migrations: Right now, modifying a schema after provisioning requires re-provisioning. Adding ALTER TABLE support 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