Skip to main content

Build a Payment System: Stripe + Plaid 2026

·APIScout Team
Share:

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

Fraud Prevention with Stripe Radar

Stripe Radar is enabled by default on every Stripe account. Most teams set it up once and forget it — which is a mistake, because the defaults only cover the obvious cases.

The rules engine is the most powerful lever. In Dashboard → Radar → Rules, you write custom blocking logic that runs before each charge is attempted. Useful rules: block if the card's issuing country doesn't match the billing country (high fraud signal); block if the IP resolves to a high-risk region for your business; block if the same card has failed three or more times in the past 24 hours (card testing attack pattern). These rules are evaluated in order, and a matching rule can block the charge outright or trigger a review queue. Start conservative — you can always loosen rules after seeing false positive rates.

3D Secure authentication adds a second factor (a bank-issued prompt on mobile or desktop) when Radar's risk score is elevated. Enable adaptive 3DS by adding payment_method_options: { card: { request_three_d_secure: 'automatic' } } to your paymentIntents.create() call. With 'automatic', Stripe only triggers 3DS when the risk score warrants it — low-risk transactions sail through without friction. This is the right default for almost everyone.

Dispute handling requires a process, not just code. When a customer files a chargeback, Stripe sends a charge.dispute.created webhook and your clock starts: you typically have 7–10 days to submit evidence (the window varies by card network). Handle the webhook to notify your team immediately. Evidence worth collecting: the customer's IP address at purchase time, login activity logs, any emails or in-app communications about the purchase, and usage data proving the service was delivered. Stripe's Dashboard has a built-in dispute response form. A dispute you don't respond to is automatically lost — and the $15 dispute fee is charged regardless of the transaction amount.

Velocity limits protect against automated card testing attacks, where bots cycle through thousands of stolen card numbers looking for valid ones. Your Radar rules can cap charge attempts per IP address or email per hour. Supplement with application-level rate limiting (e.g., upstash/ratelimit) on your payment intent creation endpoint. Monitor the payment_intent.payment_failed rate — a sudden spike in failures on new card numbers is the clearest early signal of an attack.

Monitoring Your Payment System

A payment system that doesn't alert on failures is a liability. These are the metrics to watch in production.

Payment success rate is your most important signal. Track the ratio of payment_intent.succeeded to payment_intent.payment_failed events over time. A healthy baseline varies by business type (B2B SaaS sees higher success rates than consumer e-commerce), but a sudden drop — even a few percentage points — indicates a problem. Common causes: a bank outage affecting a major card network, a fraud spike triggering Radar blocks, or a code change that broke your payment flow.

Webhook delivery is often overlooked until it causes a production incident. Failed webhook deliveries mean your application's state has diverged from Stripe's — a payment succeeded in Stripe but your database still shows it as pending. Check Dashboard → Developers → Webhooks for failed deliveries and set up alerting on the error rate. Stripe retries failed webhook deliveries with exponential backoff for up to 72 hours, but if your endpoint is returning 500s, you need to know immediately.

Subscription churn breakdown tells different stories depending on its source. customer.subscription.deleted events come from both voluntary cancellations (the customer chose to leave) and involuntary churn (payment failed and the subscription was cancelled after all retries). Track these separately. High involuntary churn often means your dunning flow needs improvement — better retry timing, smarter email copy, or adding a card update flow so customers can fix expired cards before losing access.

Revenue metrics are available directly in Stripe. Dashboard → Billing → Revenue Recognition provides pre-built MRR, churn rate, and LTV charts. For custom metrics or data warehouse integration, Stripe's Data Pipeline syncs your Stripe data to Redshift or Snowflake on a schedule — no custom ETL required.


Going International: Currency, Compliance, and Settlement

When expanding beyond the US, payment complexity increases along multiple dimensions: currency handling, local payment methods, regulatory compliance, and settlement timing.

Currency handling: Stripe supports 135+ currencies for charge processing, but your pricing model determines how you expose this to users. The simplest approach is to price everything in USD and let Stripe's Dynamic Currency Conversion show users an estimated local amount at checkout. More polished: maintain a price matrix by currency using Stripe's price currency options, with local amounts that account for purchasing power and market conditions rather than pure exchange rate conversion. Stripe Radar's fraud models also benefit from local pricing — a purchase priced in USD for a Nigerian user looks different to the fraud model than the same purchase priced in NGN.

Local payment methods: cards are not the dominant payment method globally. Stripe's payment_method_types: 'automatic' handles this correctly by presenting the most relevant payment methods for the customer's location — iDEAL in the Netherlands, Bancontact in Belgium, SEPA Direct Debit in the EU, UPI in India, BLIK in Poland. The prerequisite is enabling those payment methods in Stripe Dashboard and, for regulated methods like SEPA Direct Debit, accepting the associated terms.

EU regulatory compliance: the Strong Customer Authentication (SCA) requirement mandates two-factor authentication for most card payments above €30. Stripe handles SCA by triggering 3D Secure when required by the customer's bank — your integration doesn't need special handling if you're using Payment Intents with automatic_payment_methods: true. For B2B payments, SEPA invoices have different settlement timelines (2–3 business days) than card payments (1–2 business days), which affects when you should provision access.

Settlement and payout timing: Stripe's default payout schedule is rolling two-day for the US and EU. For international expansions, some markets have longer payout schedules (7+ days) due to local banking requirements. Stripe's Instant Payouts feature allows same-day settlement to a debit card for an additional fee — useful for marketplaces that need to pay sellers quickly.

Plaid's bank account linking flow runs in an iframe and Plaid never shares raw credentials with your application — you receive an access_token that authorizes Plaid's API calls on the user's behalf. This token is per-item (a bank connection) and has no expiry by default, but users can revoke it from their bank's app settings or via Plaid's user portal.

Data retention matters for compliance: for apps that only need bank account verification (not ongoing transactions), use the auth product only — it pulls account and routing numbers without enabling transaction history access. For GDPR compliance in the EU, Plaid offers a data deletion API — call plaid.itemRemove() when a user requests data deletion; this revokes the access token and signals Plaid to delete the associated data. Document this in your privacy policy as part of your data deletion workflow.

The financial data your app receives via Plaid is sensitive and falls under financial privacy regulations in most jurisdictions (GLBA in the US, PSD2 in the EU). Treat Plaid access tokens with the same security posture as database credentials: store them encrypted at rest, never log them, and rotate them if your infrastructure is compromised. Plaid also supports a limited-access mode for the transactions product where you only receive transaction data for a specific date range — useful for income verification without granting indefinite access to the user's financial history.

Methodology

Stripe API version referenced: 2024-12-18.acacia (latest stable as of March 2026). Plaid SDK version: 17.x (TypeScript). All Stripe code examples use the stripe npm package 16.x. Code examples assume Next.js 15 App Router. Stripe Tax coverage: 50+ countries as of early 2026; check Stripe's Tax coverage page for your specific jurisdictions before enabling automatic tax. Plaid availability: 12,000+ financial institutions in the US; international Plaid support varies by market — verify coverage in your target countries before committing to Plaid as your bank connection layer. Currency conversion rates and settlement timing vary by Stripe region and are subject to change — consult Stripe's fee schedule for your registered business country.


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

Compare Stripe and Plaid on APIScout.

Related: Building a SaaS Backend, How to Add Stripe Payments to Your Next.js App, Subscription Billing with Lemon Squeezy 2026

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.