Skip to main content

Building a Complete Payment System: Stripe + Plaid + Billing

·APIScout Team
paymentsstripeplaidbillingapi stack

Building a Complete Payment System: Stripe + Plaid + Billing

A complete payment system isn't just Stripe Checkout. It's payments + bank connections + subscription billing + invoicing + tax compliance + fraud prevention. Here's how to build the full stack with the right APIs for each layer.

The Payment Stack

┌─────────────────────────────────────┐
│  Checkout UI                        │  Stripe Elements, Checkout
│  (card forms, payment methods)      │
├─────────────────────────────────────┤
│  Payment Processing                 │  Stripe (cards, wallets)
│  (charge, refund, dispute)          │  Plaid (bank transfers)
├─────────────────────────────────────┤
│  Subscription Billing               │  Stripe Billing
│  (plans, metering, invoices)        │  (or Lago for usage-based)
├─────────────────────────────────────┤
│  Tax Compliance                     │  Stripe Tax
│  (calculation, collection, filing)  │  (or TaxJar, Avalara)
├─────────────────────────────────────┤
│  Bank Connections                   │  Plaid
│  (account linking, verification)    │  (or MX, Finicity)
├─────────────────────────────────────┤
│  Fraud Prevention                   │  Stripe Radar
│  (risk scoring, 3DS, rules)        │  (or Sift, Riskified)
└─────────────────────────────────────┘

Layer 1: Payment Processing with Stripe

One-Time Payments

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// Create a payment intent (server-side)
export async function createPayment(amount: number, customerId: string) {
  const paymentIntent = await stripe.paymentIntents.create({
    amount, // in cents: $10.00 = 1000
    currency: 'usd',
    customer: customerId,
    automatic_payment_methods: { enabled: true },
    metadata: {
      orderId: 'order_123',
    },
  });

  return { clientSecret: paymentIntent.client_secret };
}
// Confirm payment (client-side with Stripe Elements)
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';

function CheckoutForm({ clientSecret }: { clientSecret: string }) {
  const stripe = useStripe();
  const elements = useElements();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!stripe || !elements) return;

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: 'https://app.com/payment/success',
      },
    });

    if (error) {
      console.error(error.message);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      <button type="submit">Pay</button>
    </form>
  );
}

Handling Payment Webhooks

// The backbone of reliable payment processing
export async function handleStripeWebhook(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature')!;

  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  switch (event.type) {
    case 'payment_intent.succeeded': {
      const paymentIntent = event.data.object;
      await fulfillOrder(paymentIntent.metadata.orderId);
      break;
    }
    case 'payment_intent.payment_failed': {
      const paymentIntent = event.data.object;
      await notifyPaymentFailed(paymentIntent.customer as string);
      break;
    }
    case 'charge.dispute.created': {
      const dispute = event.data.object;
      await handleDispute(dispute);
      break;
    }
  }

  return new Response('OK', { status: 200 });
}

Layer 2: Subscription Billing

// Create a subscription
async function createSubscription(customerId: string, priceId: string) {
  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete',
    payment_settings: {
      save_default_payment_method: 'on_subscription',
    },
    expand: ['latest_invoice.payment_intent'],
  });

  return {
    subscriptionId: subscription.id,
    clientSecret: (subscription.latest_invoice as any)
      .payment_intent.client_secret,
  };
}

// Usage-based billing (metered)
async function reportUsage(subscriptionItemId: string, quantity: number) {
  await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
    quantity,
    timestamp: Math.floor(Date.now() / 1000),
    action: 'increment',
  });
}

// Webhook handlers for subscription lifecycle
async function handleSubscriptionWebhook(event: Stripe.Event) {
  switch (event.type) {
    case 'customer.subscription.created':
      await activateAccount(event.data.object.customer as string);
      break;
    case 'customer.subscription.updated':
      await updateAccountPlan(event.data.object);
      break;
    case 'customer.subscription.deleted':
      await deactivateAccount(event.data.object.customer as string);
      break;
    case 'invoice.payment_failed':
      await handleFailedPayment(event.data.object);
      break;
    case 'invoice.paid':
      await recordPayment(event.data.object);
      break;
  }
}

Layer 3: Bank Connections with Plaid

import { Configuration, PlaidApi, PlaidEnvironments } from 'plaid';

const plaid = new PlaidApi(new Configuration({
  basePath: PlaidEnvironments.production,
  baseOptions: {
    headers: {
      'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
      'PLAID-SECRET': process.env.PLAID_SECRET,
    },
  },
}));

// Step 1: Create link token (server-side)
async function createLinkToken(userId: string) {
  const response = await plaid.linkTokenCreate({
    user: { client_user_id: userId },
    client_name: 'Your App',
    products: ['auth', 'transactions'],
    country_codes: ['US'],
    language: 'en',
  });

  return response.data.link_token;
}

// Step 2: Exchange public token after user links account
async function exchangePublicToken(publicToken: string) {
  const response = await plaid.itemPublicTokenExchange({
    public_token: publicToken,
  });

  // Store access_token securely — used for all future requests
  return response.data.access_token;
}

// Step 3: Get account details
async function getAccounts(accessToken: string) {
  const response = await plaid.accountsGet({
    access_token: accessToken,
  });

  return response.data.accounts.map(account => ({
    id: account.account_id,
    name: account.name,
    type: account.type,
    balance: account.balances.current,
    mask: account.mask, // Last 4 digits
  }));
}

// Step 4: Initiate ACH transfer (Stripe + Plaid)
async function createACHPayment(
  accessToken: string,
  accountId: string,
  customerId: string
) {
  // Get Stripe bank account token from Plaid
  const response = await plaid.processorStripeBankAccountTokenCreate({
    access_token: accessToken,
    account_id: accountId,
  });

  // Attach to Stripe customer
  const bankAccount = await stripe.customers.createSource(customerId, {
    source: response.data.stripe_bank_account_token,
  });

  return bankAccount;
}

Layer 4: Tax Compliance

// Stripe Tax — automatic tax calculation
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{
    price: 'price_pro_monthly',
    quantity: 1,
  }],
  automatic_tax: { enabled: true }, // Stripe calculates tax automatically
  customer: customerId,
  success_url: 'https://app.com/success',
  cancel_url: 'https://app.com/cancel',
});

// Tax is calculated based on:
// - Customer location (from payment method or shipping address)
// - Product tax code (set on the Price or Product)
// - Local tax laws (Stripe keeps these updated)

The Full Payment Architecture

// Complete payment service combining all layers
class PaymentSystem {
  constructor(
    private stripe: Stripe,
    private plaid: PlaidApi,
  ) {}

  // Customer lifecycle
  async createCustomer(email: string, name: string) {
    return stripe.customers.create({ email, name });
  }

  // One-time payment
  async chargeCustomer(customerId: string, amount: number, description: string) {
    return stripe.paymentIntents.create({
      customer: customerId,
      amount,
      currency: 'usd',
      description,
      automatic_payment_methods: { enabled: true },
    });
  }

  // Subscription
  async subscribe(customerId: string, planId: string) {
    return stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: planId }],
    });
  }

  // Bank connection
  async linkBankAccount(userId: string) {
    return this.createLinkToken(userId);
  }

  // Refund
  async refund(paymentIntentId: string, amount?: number) {
    return stripe.refunds.create({
      payment_intent: paymentIntentId,
      amount, // Partial refund if specified
    });
  }

  // Invoice
  async createInvoice(customerId: string, items: Array<{ description: string; amount: number }>) {
    for (const item of items) {
      await stripe.invoiceItems.create({
        customer: customerId,
        amount: item.amount,
        currency: 'usd',
        description: item.description,
      });
    }

    const invoice = await stripe.invoices.create({
      customer: customerId,
      auto_advance: true,
    });

    return stripe.invoices.finalizeInvoice(invoice.id);
  }
}

Common Mistakes

MistakeImpactFix
Not using webhooksMissing payment eventsWebhook-driven architecture for all payment states
Storing card numbersPCI compliance violationUse Stripe Elements, never touch card data
No idempotency keysDuplicate charges on retryAlways include idempotency key for payment creation
Ignoring failed paymentsLost revenueDunning emails, retry logic, grace periods
No dispute handlingLost disputes, penaltiesMonitor disputes, respond within deadline
Testing with live keysReal charges during developmentAlways use sk_test_ keys in development

Compare payment APIs on APIScout — Stripe vs PayPal vs Square vs Adyen, with pricing calculators and feature comparisons.

Comments