by InlinexDev

Stripe Payment Integration Patterns for Node.js SaaS Applications

Practical patterns for integrating Stripe payments in Node.js apps, including wallets, subscriptions, webhooks, and credit-based billing.

StripepaymentsNode.jsSaaSbilling

Stripe Payment Models for SaaS

Not every SaaS app needs a monthly subscription. After integrating Stripe into multiple production apps, here are three payment models and when each makes sense.

Model 1: Prepaid Wallet

Used in ShipAnywhere for shipping credits. Merchants top up their wallet and spend credits on shipments.

Creating a Checkout Session for Top-Up

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

app.post('/api/wallet/topup', async (req, res) => {
  const { amount } = req.body; // amount in cents
  const user = req.user;

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: {
          name: 'Wallet Top-Up',
          description: `Add $${(amount / 100).toFixed(2)} to your wallet`
        },
        unit_amount: amount
      },
      quantity: 1
    }],
    mode: 'payment',
    success_url: `${BASE_URL}/wallet?success=true`,
    cancel_url: `${BASE_URL}/wallet?cancelled=true`,
    metadata: {
      userId: user.id,
      type: 'wallet_topup'
    }
  });

  res.json({ url: session.url });
});

Processing the Webhook

app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object;
    if (session.metadata.type === 'wallet_topup') {
      await addWalletBalance(
        session.metadata.userId,
        session.amount_total
      );
    }
  }

  res.json({ received: true });
});

Why Wallets Work

  • Reduces per-transaction Stripe fees (one top-up vs. many small charges)
  • Instant spending — no payment delay when generating labels
  • Predictable revenue — merchants prepay before consuming services

Model 2: Credit-Based Billing

Used in Pixel Prep for image processing credits. Users buy credit packs and spend one credit per image processed.

const CREDIT_PACKS = [
  { id: 'pack_50', credits: 50, price: 499, name: '50 Credits' },
  { id: 'pack_200', credits: 200, price: 1499, name: '200 Credits' },
  { id: 'pack_500', credits: 500, price: 2999, name: '500 Credits' }
];

app.post('/api/credits/purchase', async (req, res) => {
  const pack = CREDIT_PACKS.find(p => p.id === req.body.packId);
  if (!pack) return res.status(400).json({ error: 'Invalid pack' });

  const session = await stripe.checkout.sessions.create({
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: { name: pack.name },
        unit_amount: pack.price
      },
      quantity: 1
    }],
    mode: 'payment',
    metadata: {
      userId: req.user.id,
      credits: pack.credits,
      type: 'credit_purchase'
    },
    success_url: `${BASE_URL}/dashboard?purchased=true`,
    cancel_url: `${BASE_URL}/pricing`
  });

  res.json({ url: session.url });
});

Credit Deduction

async function deductCredit(userId) {
  const result = await db.query(
    `UPDATE users 
     SET credits = credits - 1 
     WHERE id = $1 AND credits > 0 
     RETURNING credits`,
    [userId]
  );
  
  if (result.rowCount === 0) {
    throw new Error('Insufficient credits');
  }
  
  return result.rows[0].credits;
}

The credits > 0 condition in the WHERE clause prevents negative balances even under concurrent requests.

Model 3: Monthly Subscription

Best for ongoing services with predictable usage:

app.post('/api/subscribe', async (req, res) => {
  const { priceId } = req.body;
  
  let customer;
  if (req.user.stripeCustomerId) {
    customer = req.user.stripeCustomerId;
  } else {
    const stripeCustomer = await stripe.customers.create({
      email: req.user.email,
      metadata: { userId: req.user.id }
    });
    customer = stripeCustomer.id;
    await updateUserStripeId(req.user.id, customer);
  }

  const session = await stripe.checkout.sessions.create({
    customer,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${BASE_URL}/dashboard`,
    cancel_url: `${BASE_URL}/pricing`
  });

  res.json({ url: session.url });
});

Webhook Best Practices

  1. Always verify signatures — never process unverified webhooks
  2. Use raw body parsing — Stripe verification needs the raw request body
  3. Make handlers idempotent — Stripe may retry failed webhooks
  4. Respond quickly — return 200 immediately, process asynchronously
  5. Log everything — store raw webhook events for debugging
// Idempotent webhook processing
async function processWebhookEvent(event) {
  const existing = await db.query(
    'SELECT id FROM webhook_events WHERE stripe_event_id = $1',
    [event.id]
  );
  
  if (existing.rowCount > 0) {
    return; // Already processed
  }
  
  await db.query(
    'INSERT INTO webhook_events (stripe_event_id, type, data) VALUES ($1, $2, $3)',
    [event.id, event.type, JSON.stringify(event.data)]
  );
  
  // Process the event...
}

Choosing Your Model

| Model | Best For | Example | |-------|---------|----------| | Wallet | Variable per-unit costs | Shipping, API calls | | Credits | Fixed per-unit costs | Image processing, AI generation | | Subscription | Ongoing access | SaaS dashboards, tools |

Many apps benefit from combining models — a base subscription with credit-based overage billing, for instance.

Conclusion

Stripe's flexibility supports virtually any billing model. The key is choosing the model that matches your users' consumption patterns and keeping your webhook handling bulletproof.