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
| Mistake | Impact | Fix |
|---|---|---|
| Not using webhooks | Missing payment events | Webhook-driven architecture for all payment states |
| Storing card numbers | PCI compliance violation | Use Stripe Elements, never touch card data |
| No idempotency keys | Duplicate charges on retry | Always include idempotency key for payment creation |
| Ignoring failed payments | Lost revenue | Dunning emails, retry logic, grace periods |
| No dispute handling | Lost disputes, penalties | Monitor disputes, respond within deadline |
| Testing with live keys | Real charges during development | Always use sk_test_ keys in development |
Compare payment APIs on APIScout — Stripe vs PayPal vs Square vs Adyen, with pricing calculators and feature comparisons.