Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 029 / 100

Stripe Customers: a Payments & Stripe guide for developers

Link payments to users.

Expona Admin7 min read

Stripe Customers: a Payments & Stripe guide for developers

Building a payment system that scales requires more than just processing transactions in isolation. When you link payments to users through Stripe's Customer object, you unlock the ability to manage subscriptions, store payment methods, and build a cohesive billing experience across your platform. In this guide, we'll walk through the practical patterns that have worked in production across e-commerce, SaaS, and subscription businesses.

Why Stripe Customers Matter

Many developers start by processing one-off payments directly with card tokens or payment intents. This works for simple cases, but it doesn't scale when you need to:

  • Charge the same user multiple times
  • Let users manage their saved payment methods
  • Build recurring billing or subscriptions
  • Dispute or refund charges tied to a specific user
  • Analyze customer lifetime value and payment history

A Stripe Customer object becomes the bridge between your user database and Stripe. It's where you centralize all payment-related state for a given user, making future transactions simpler and more reliable.

Create One Customer Per User

The pattern is straightforward: when a user joins your platform, create a corresponding Stripe Customer. This happens once, typically during sign-up or the first payment attempt. The Stripe Customer ID then becomes a permanent foreign key in your users table.

Here's how you might do this with Node.js and the Stripe SDK:

typescript
import Stripe from 'stripe';

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

async function createStripeCustomerForUser(user: { id: string; email: string; name: string }) {
  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
  });

  return customer.id;
}

// In your user creation flow:
const newUser = await db.users.create({
  email: 'alice@example.com',
  name: 'Alice',
});

const stripeCustomerId = await createStripeCustomerForUser(newUser);

await db.users.update(newUser.id, {
  stripe_customer_id: stripeCustomerId,
});

Notice we're not passing any payment method data yet. That comes later. We're just establishing the relationship.

In production, you'll want to handle the case where a customer already exists (idempotency). Stripe's idempotency_key parameter helps here, but it's also wise to check your database first:

typescript
async function ensureStripeCustomer(user: { id: string; email: string; name: string }) {
  // Check if we already created a customer for this user
  const existingCustomer = await db.users.findOne({ id: user.id });
  if (existingCustomer.stripe_customer_id) {
    return existingCustomer.stripe_customer_id;
  }

  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
  });

  await db.users.update(user.id, {
    stripe_customer_id: customer.id,
  });

  return customer.id;
}

This ensures you never accidentally create duplicate customers for the same user, which would fragment your payment history and complicate billing.

Store the Stripe Customer ID

Your database schema needs a dedicated column for the Stripe Customer ID. Here's a typical migration:

typescript
// Knex migration example
export async function up(knex: Knex) {
  return knex.schema.table('users', (table) => {
    table.string('stripe_customer_id').unique().nullable();
    table.index('stripe_customer_id');
  });
}

export async function down(knex: Knex) {
  return knex.schema.table('users', (table) => {
    table.dropColumn('stripe_customer_id');
  });
}

The unique constraint prevents accidental duplication, and the index makes lookups fast when you need to find a user by their Stripe ID (especially useful when processing Stripe webhooks).

When a payment webhook arrives from Stripe, you'll receive a customer field in the event payload. You'll use this to find the right user in your database:

typescript
async function handleStripeChargeSucceeded(event: Stripe.Event) {
  const charge = event.data.object as Stripe.Charge;
  const stripeCustomerId = charge.customer as string;

  // Find the user by Stripe customer ID
  const user = await db.users.findOne({ stripe_customer_id: stripeCustomerId });

  if (!user) {
    console.error(`No user found for Stripe customer ${stripeCustomerId}`);
    return;
  }

  // Now you can safely update user records, send receipts, etc.
  await db.invoices.create({
    user_id: user.id,
    stripe_charge_id: charge.id,
    amount: charge.amount,
  });
}

Default Payment Methods

Once a customer is created, you can attach payment methods to them. A payment method might be a card, bank account, or digital wallet. The Stripe API lets you set one as the default for automatic billing.

Here's a scenario: a user adds a credit card during checkout. You attach it to their Stripe Customer and optionally mark it as default:

typescript
async function attachPaymentMethodToCustomer(
  stripeCustomerId: string,
  paymentMethodId: string,
  setAsDefault: boolean = false
) {
  const paymentMethod = await stripe.paymentMethods.attach(paymentMethodId, {
    customer: stripeCustomerId,
  });

  if (setAsDefault) {
    await stripe.customers.update(stripeCustomerId, {
      invoice_settings: {
        default_payment_method: paymentMethodId,
      },
    });
  }

  return paymentMethod;
}

When the user later subscribes to a plan or you need to charge them, Stripe automatically uses the default payment method. No need to pass card details again:

typescript
async function chargeCustomerWithDefault(
  stripeCustomerId: string,
  amountInCents: number
) {
  const paymentIntent = await stripe.paymentIntents.create({
    customer: stripeCustomerId,
    amount: amountInCents,
    currency: 'usd',
    off_session: true, // Charge without explicit user confirmation
  });

  return paymentIntent;
}

The off_session: true flag tells Stripe this is not a user-initiated payment (like a subscription renewal), which may affect transaction fees and compliance requirements depending on your region and payment method type.

Use Metadata for Your User ID

Stripe Customer objects have a metadata field where you can store key-value pairs. This is invaluable for storing your own user ID, which serves as a backup lookup mechanism and simplifies reconciliation:

typescript
async function createStripeCustomerWithMetadata(user: { id: string; email: string; name: string }) {
  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name,
    metadata: {
      user_id: user.id, // Your internal user ID
      created_at: new Date().toISOString(),
      source: 'web_signup', // Track where the customer came from
    },
  });

  return customer.id;
}

When a webhook arrives, you now have two ways to find the user:

typescript
async function findUserFromStripeEvent(stripeCustomerId: string) {
  // Method 1: Look up by stripe_customer_id directly
  let user = await db.users.findOne({ stripe_customer_id: stripeCustomerId });
  if (user) return user;

  // Method 2: Fetch the customer from Stripe and use metadata
  const customer = await stripe.customers.retrieve(stripeCustomerId);
  const userId = customer.metadata?.user_id;

  if (userId) {
    user = await db.users.findOne({ id: userId });
  }

  return user;
}

This redundancy is crucial in production. If your database link gets out of sync (which happens), the metadata gives you a recovery path.

Common Mistakes

  • Creating duplicate customers: Without idempotency checks, a network retry or race condition can create multiple customers for the same user. Always check your database first or use an idempotency key.
  • Forgetting to store the customer ID: Some teams create a Stripe Customer but don't save the ID in their database. You then lose the link when the payment flow ends. Always persist the ID immediately.
  • Not using metadata: Metadata is free and lightweight. Storing your internal user ID there is cheap insurance against data inconsistencies.
  • Updating customer email manually: If a user changes their email, remember to update both your database and Stripe. Otherwise, receipts and notifications go to the wrong address.
  • Deleting customers in Stripe: You generally shouldn't delete customers once they exist. Mark them as inactive in your app instead. Deleting breaks historical records and can cause reconciliation headaches.

Key Takeaways

  • Create a Stripe Customer for each user once, during sign-up or first payment, and store the customer ID as a permanent foreign key in your users table.
  • Use the customer ID to fetch payment history, attach new payment methods, and link all future charges to the same customer record.
  • Set a default payment method on the customer so recurring charges and subscriptions work automatically without re-requesting payment details.
  • Store your internal user ID in the customer's metadata field as a recovery mechanism if your database link ever goes out of sync.
  • Build idempotency into customer creation to prevent duplicates during retries or network failures.
  • Always keep the Stripe customer email in sync with your user's email, since Stripe uses it for receipts and notifications.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles