Tutorial: Stripe Payment Integration
This tutorial walks through implementing a complete Stripe payment system with saved payment methods, off-session charging, and support for both cards and ACH bank transfers in a TypeScript application.
Table of Contents
- Prerequisites
- Setting Up Stripe
- Project Structure
- Server-Side Stripe Client
- Client-Side Stripe
- Customer Management
- Saving Payment Methods
- Charging Saved Payment Methods
- Payment Record Management
- Error Handling
- Complete Example
- Testing
- Troubleshooting
Prerequisites
- Stripe account with API keys
- Node.js/Bun installed
- A TypeScript project
- Database for storing payment records (examples use Drizzle ORM)
Setting Up Stripe
Step 1: Get Your API Keys
- Go to dashboard.stripe.com
- Navigate to Developers > API Keys
- Copy your Publishable key (starts with
pk_) - Copy your Secret key (starts with
sk_)
Step 2: Install Dependencies
# Using bun
bun add stripe @stripe/stripe-js
# Using npm
npm install stripe @stripe/stripe-js
Step 3: Environment Variables
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
Project Structure
app/
├── lib/
│ └── stripe.ts # Core Stripe functions (server & client)
├── repositories/
│ └── payment.ts # Payment record management
└── trpc/routes/
└── payment.ts # API endpoints
Server-Side Stripe Client
Create a server-side Stripe client that can be safely used in API routes:
// app/lib/stripe.ts
import Stripe from "stripe";
/**
* Create a server-side Stripe client
* Should only be called on the server (in loaders, actions, or tRPC routes)
*/
export function createStripeClient(secretKey: string) {
return new Stripe(secretKey, {
apiVersion: "2025-12-15.clover", // Use latest stable version
typescript: true,
});
}
Usage in tRPC Context
// app/trpc/index.ts
import { createStripeClient } from "@/lib/stripe";
export const createTRPCContext = async (opts: {
headers: Headers;
cfContext: Env;
}) => {
// Create Stripe client if secret key is configured
const stripe = opts.cfContext.STRIPE_SECRET_KEY
? createStripeClient(opts.cfContext.STRIPE_SECRET_KEY)
: null;
return {
// ... other context
stripe,
};
};
Client-Side Stripe
For payment forms using Stripe Elements:
// app/lib/stripe.ts
import { loadStripe, type Stripe as StripeClient } from "@stripe/stripe-js";
// Client-side Stripe instance cache (keyed by publishable key)
const stripePromiseCache = new Map<string, Promise<StripeClient | null>>();
/**
* Get the client-side Stripe instance for use with Stripe Elements
* The publishable key should be passed from the loader via environment
*/
export function getStripe(publishableKey: string) {
if (!publishableKey) {
console.error("Stripe publishable key is required");
return Promise.resolve(null);
}
if (!stripePromiseCache.has(publishableKey)) {
stripePromiseCache.set(publishableKey, loadStripe(publishableKey));
}
return stripePromiseCache.get(publishableKey)!;
}
Why Cache the Promise?
loadStripecreates a new instance each call- Multiple instances can cause issues
- Caching ensures one instance per publishable key
Customer Management
Creating a Stripe Customer
Create a customer when a user/organization signs up:
// app/lib/stripe.ts
import Stripe from "stripe";
/**
* Create a Stripe customer for an organization
*/
export async function createCustomer(
stripe: Stripe,
input: {
name: string;
email: string;
metadata?: Record<string, string>;
}
) {
return stripe.customers.create({
name: input.name,
email: input.email,
metadata: input.metadata,
});
}
Best Practices for Customer Creation
// In your organization creation flow
const customer = await createCustomer(stripe, {
name: organization.name,
email: organization.email,
metadata: {
organizationId: organization.id,
createdBy: userId,
},
});
// Store the customer ID in your database
await db.update(organization)
.set({ stripeCustomerId: customer.id })
.where(eq(organization.id, organizationId));
Saving Payment Methods
Use Setup Intents to save payment methods without charging:
Creating a Setup Intent
// app/lib/stripe.ts
/**
* Payment method types supported by the platform
*/
export type SupportedPaymentMethodType = "card" | "us_bank_account";
/**
* Create a setup intent for collecting payment method without charging.
* Supports both card and ACH (US bank account) payment methods.
*/
export async function createSetupIntent(
stripe: Stripe,
customerId: string,
options?: {
metadata?: Record<string, string>;
/**
* Payment method types to accept. Defaults to both card and ACH.
* - "card": Credit/debit cards
* - "us_bank_account": ACH Direct Debit (US bank accounts)
*/
paymentMethodTypes?: SupportedPaymentMethodType[];
}
) {
const paymentMethodTypes = options?.paymentMethodTypes ?? ["card", "us_bank_account"];
// Build payment method options for US bank account if included
const paymentMethodOptions: Stripe.SetupIntentCreateParams["payment_method_options"] = {};
if (paymentMethodTypes.includes("us_bank_account")) {
paymentMethodOptions.us_bank_account = {
// Use Financial Connections for instant bank verification
financial_connections: {
permissions: ["payment_method", "balances"],
},
};
}
return stripe.setupIntents.create({
customer: customerId,
payment_method_types: paymentMethodTypes,
payment_method_options: paymentMethodOptions,
metadata: options?.metadata,
});
}
Attaching and Setting Default Payment Method
/**
* Attach a payment method to a customer
*/
export async function attachPaymentMethod(
stripe: Stripe,
paymentMethodId: string,
customerId: string
) {
return stripe.paymentMethods.attach(paymentMethodId, {
customer: customerId,
});
}
/**
* Set the default payment method for a customer
*/
export async function setDefaultPaymentMethod(
stripe: Stripe,
customerId: string,
paymentMethodId: string
) {
return stripe.customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}
Charging Saved Payment Methods
The key to charging saved payment methods is off-session payments:
// app/lib/stripe.ts
/**
* Result type for chargePayment function
*/
export interface ChargePaymentResult {
success: boolean;
paymentIntentId?: string;
receiptUrl?: string;
error?: string;
/**
* For ACH payments, the payment may still be processing.
* When true, the payment was initiated but hasn't completed yet
* (typically 4-5 business days for ACH).
*/
isProcessing?: boolean;
}
/**
* Charge a saved payment method synchronously.
* Uses off-session payment with the customer's default payment method.
* Returns immediately with the payment result (no webhook required).
*
* @param stripe - Server-side Stripe client
* @param input - Payment details including idempotencyKey to prevent duplicate charges
* @returns ChargePaymentResult with success status and receipt URL if successful
*/
export async function chargePayment(
stripe: Stripe,
input: {
customerId: string;
amount: number; // Amount in cents
description: string;
metadata?: Record<string, string>;
/** Unique key to prevent duplicate charges (should be the payment record ID) */
idempotencyKey: string;
}
): Promise<ChargePaymentResult> {
try {
// Get customer's default payment method
const customer = await stripe.customers.retrieve(input.customerId);
if (customer.deleted) {
return { success: false, error: "Customer has been deleted" };
}
const defaultPaymentMethod = customer.invoice_settings?.default_payment_method;
if (!defaultPaymentMethod) {
return { success: false, error: "No payment method on file" };
}
// Create and confirm PaymentIntent synchronously (off-session)
// Use idempotencyKey to prevent duplicate charges from race conditions
const paymentIntent = await stripe.paymentIntents.create(
{
amount: input.amount,
currency: "usd",
customer: input.customerId,
payment_method: defaultPaymentMethod as string,
off_session: true,
confirm: true,
description: input.description,
metadata: input.metadata,
},
{
idempotencyKey: input.idempotencyKey,
}
);
// Check the result
if (paymentIntent.status === "succeeded") {
// Get the receipt URL from the charge
let receiptUrl: string | undefined;
if (paymentIntent.latest_charge) {
const charge = await stripe.charges.retrieve(
paymentIntent.latest_charge as string
);
receiptUrl = charge.receipt_url ?? undefined;
}
return {
success: true,
paymentIntentId: paymentIntent.id,
receiptUrl,
};
} else if (paymentIntent.status === "processing") {
// ACH payments can take 4-5 business days to process
// This is a valid success state for bank transfers
return {
success: true,
paymentIntentId: paymentIntent.id,
isProcessing: true,
};
} else if (paymentIntent.status === "requires_action") {
// Payment requires authentication (3D Secure)
return {
success: false,
paymentIntentId: paymentIntent.id,
error: "Payment requires additional authentication. Please update your payment method.",
};
} else {
return {
success: false,
paymentIntentId: paymentIntent.id,
error: paymentIntent.last_payment_error?.message || "Payment failed",
};
}
} catch (error) {
// Handle Stripe card errors
if (error instanceof Stripe.errors.StripeCardError) {
return {
success: false,
error: error.message || "Card was declined",
};
}
// Handle other Stripe errors
if (error instanceof Stripe.errors.StripeError) {
return {
success: false,
error: error.message || "Payment processing error",
};
}
// Unknown error
console.error("[Stripe] Unexpected error during payment:", error);
return {
success: false,
error: "An unexpected error occurred while processing payment",
};
}
}
Key Concepts
- Off-Session: Payment is made without the customer present
- Idempotency Key: Prevents duplicate charges if request is retried
- Processing State: ACH payments may not complete immediately
Payment Record Management
Track payments in your database:
// app/repositories/payment.ts
import { eq, and, desc } from "drizzle-orm";
import { payment } from "@/db/schema";
import { NotFoundError, CreationError, UpdateError } from "@/models/errors";
import type { Context } from "@/trpc";
type Database = Context["db"];
export type PaymentStatus =
| "pending"
| "processing"
| "paid"
| "failed"
| "refunded";
interface CreatePaymentInput {
organizationId: string;
description: string;
amount: number; // Amount in cents
inventionId?: string | null;
dueDate?: Date;
}
/**
* Create a payment record.
* This creates a pending payment that can be triggered later.
*/
export async function createPayment(db: Database, input: CreatePaymentInput) {
try {
const paymentId = crypto.randomUUID();
await db.insert(payment).values({
id: paymentId,
organizationId: input.organizationId,
inventionId: input.inventionId ?? null,
description: input.description,
amount: input.amount,
status: "pending",
dueDate: input.dueDate,
});
const created = await db
.select()
.from(payment)
.where(eq(payment.id, paymentId))
.limit(1);
return created[0]!;
} catch (error) {
throw new CreationError("payment", "Failed to create payment", error);
}
}
interface UpdatePaymentStatusInput {
paymentId: string;
status: PaymentStatus;
stripePaymentIntentId?: string;
stripeReceiptUrl?: string;
paidAt?: Date;
failureReason?: string;
}
/**
* Update payment status and Stripe references.
* Called when processing payment or receiving webhook.
*/
export async function updatePaymentStatus(
db: Database,
input: UpdatePaymentStatusInput
) {
try {
await db
.update(payment)
.set({
status: input.status,
stripePaymentIntentId: input.stripePaymentIntentId,
stripeReceiptUrl: input.stripeReceiptUrl,
paidAt: input.paidAt,
failureReason: input.failureReason,
})
.where(eq(payment.id, input.paymentId));
return await getPaymentById(db, { paymentId: input.paymentId });
} catch (error) {
throw new UpdateError("payment", "Failed to update payment status", error);
}
}
Error Handling
Stripe Error Types
import Stripe from "stripe";
try {
// Stripe operation
} catch (error) {
if (error instanceof Stripe.errors.StripeCardError) {
// Card was declined
// error.code: 'card_declined', 'insufficient_funds', etc.
// error.decline_code: 'generic_decline', 'insufficient_funds', etc.
} else if (error instanceof Stripe.errors.StripeRateLimitError) {
// Too many requests
} else if (error instanceof Stripe.errors.StripeInvalidRequestError) {
// Invalid parameters
} else if (error instanceof Stripe.errors.StripeAPIError) {
// Stripe API issue
} else if (error instanceof Stripe.errors.StripeConnectionError) {
// Network issue
} else if (error instanceof Stripe.errors.StripeAuthenticationError) {
// Invalid API key
} else {
// Unknown error
}
}
User-Friendly Error Messages
function getPaymentErrorMessage(error: unknown): string {
if (error instanceof Stripe.errors.StripeCardError) {
const messages: Record<string, string> = {
card_declined: "Your card was declined. Please try a different card.",
insufficient_funds: "Insufficient funds. Please use a different payment method.",
expired_card: "Your card has expired. Please update your payment method.",
incorrect_cvc: "The security code is incorrect. Please try again.",
processing_error: "An error occurred processing your card. Please try again.",
};
return messages[error.code || ""] || error.message || "Card payment failed.";
}
return "An error occurred processing your payment. Please try again.";
}
Complete Example
tRPC Route for Processing Payment
// app/trpc/routes/payment.ts
import { z } from "zod";
import { protectedProcedure, createTRPCRouter } from "..";
import { TRPCError } from "@trpc/server";
import { chargePayment } from "@/lib/stripe";
import * as paymentRepository from "@/repositories/payment";
export const paymentRouter = createTRPCRouter({
/**
* Process a pending payment
*/
processPayment: protectedProcedure
.input(z.object({
paymentId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
if (!ctx.stripe) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Payment processing is not configured",
});
}
// Get the payment record
const payment = await paymentRepository.getPaymentById(ctx.db, {
paymentId: input.paymentId,
});
if (!payment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Payment not found",
});
}
if (payment.status !== "pending") {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Payment is already ${payment.status}`,
});
}
// Get the organization's Stripe customer ID
const organization = await getOrganization(ctx.db, {
organizationId: payment.organizationId,
});
if (!organization.stripeCustomerId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "No payment method on file",
});
}
// Charge the payment
const result = await chargePayment(ctx.stripe, {
customerId: organization.stripeCustomerId,
amount: payment.amount,
description: payment.description,
metadata: {
paymentId: payment.id,
organizationId: payment.organizationId,
},
idempotencyKey: payment.id, // Use payment ID as idempotency key
});
// Update the payment record
if (result.success) {
await paymentRepository.updatePaymentStatus(ctx.db, {
paymentId: payment.id,
status: result.isProcessing ? "processing" : "paid",
stripePaymentIntentId: result.paymentIntentId,
stripeReceiptUrl: result.receiptUrl,
paidAt: result.isProcessing ? undefined : new Date(),
});
} else {
await paymentRepository.updatePaymentStatus(ctx.db, {
paymentId: payment.id,
status: "failed",
stripePaymentIntentId: result.paymentIntentId,
failureReason: result.error,
});
}
return result;
}),
});
Testing
Test Card Numbers
| Card Number | Scenario |
|---|---|
4242424242424242 | Successful payment |
4000000000000002 | Card declined |
4000000000009995 | Insufficient funds |
4000000000000069 | Expired card |
4000002500003155 | Requires 3D Secure |
Test Bank Accounts (ACH)
Use Stripe's test bank account:
- Routing:
110000000 - Account:
000123456789
Troubleshooting
Common Issues
1. "No such customer" error
Cause: Customer ID doesn't exist or was deleted.
Solution: Verify customer exists before charging:
const customer = await stripe.customers.retrieve(customerId);
if (customer.deleted) {
// Handle deleted customer
}
2. "No payment method" error
Cause: Customer has no default payment method set.
Solution: Ensure Setup Intent flow sets the default:
await setDefaultPaymentMethod(stripe, customerId, paymentMethodId);
3. Payment stuck in "processing"
Cause: ACH payments take 4-5 business days.
Solution: This is expected for bank transfers. Show appropriate UI:
if (result.isProcessing) {
showMessage("Payment initiated. Bank transfers typically take 4-5 business days.");
}
4. Duplicate charges
Cause: Missing or wrong idempotency key.
Solution: Always use a unique, stable key like the payment record ID:
idempotencyKey: payment.id // Not a random UUID
5. "requires_action" status
Cause: Card requires 3D Secure authentication.
Solution: For off-session payments, this indicates the card can't be used without customer present. Prompt to update payment method.
Best Practices
- Always use idempotency keys: Prevents duplicate charges on retries
- Store Stripe IDs: Save customer ID, payment intent ID for debugging
- Handle ACH separately: Different flow than card payments
- Graceful degradation: App should work if Stripe is unavailable
- Test extensively: Use test mode before going live
- Log everything: Stripe responses are crucial for debugging
