Skip to main content

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

  1. Prerequisites
  2. Setting Up Stripe
  3. Project Structure
  4. Server-Side Stripe Client
  5. Client-Side Stripe
  6. Customer Management
  7. Saving Payment Methods
  8. Charging Saved Payment Methods
  9. Payment Record Management
  10. Error Handling
  11. Complete Example
  12. Testing
  13. 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

  1. Go to dashboard.stripe.com
  2. Navigate to Developers > API Keys
  3. Copy your Publishable key (starts with pk_)
  4. 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?

  • loadStripe creates 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

  1. Off-Session: Payment is made without the customer present
  2. Idempotency Key: Prevents duplicate charges if request is retried
  3. 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 NumberScenario
4242424242424242Successful payment
4000000000000002Card declined
4000000000009995Insufficient funds
4000000000000069Expired card
4000002500003155Requires 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

  1. Always use idempotency keys: Prevents duplicate charges on retries
  2. Store Stripe IDs: Save customer ID, payment intent ID for debugging
  3. Handle ACH separately: Different flow than card payments
  4. Graceful degradation: App should work if Stripe is unavailable
  5. Test extensively: Use test mode before going live
  6. Log everything: Stripe responses are crucial for debugging

Resources

Written by

Sean Stuart Urgel
Senior Software Engineer @ Casper Studios