Introduction
By day 91 of production payment work, you've likely handled real transactions, debugged webhook failures, and learned that one misconfiguration can cost you money and customer trust. A sandbox environment for payments is your safety net: it's where you test the entire payment flow without touching real funds, real customer data, or production infrastructure. In this guide, we'll walk through setting up and maintaining sandbox environments for payments, a step-by-step approach that isolates test data, lets you seed realistic scenarios, and keeps your live keys far away from experimental code.
Stripe sandboxes isolate test data
Stripe gives you two completely separate environments: test mode and live mode. When you log into your Stripe Dashboard and switch to test mode (toggle in the top-left), every API call, webhook, and transaction is isolated in a sandbox that never touches real money or real customer records. This isolation is not cosmetic—Stripe maintains separate databases, separate payment processors, and separate audit logs for test versus live.
Test mode uses test keys (prefixed pk_test_ for publishable keys and sk_test_ for secret keys), while live mode uses live keys (prefixed pk_live_ and sk_live_). Any payment intent, customer, subscription, or webhook triggered in test mode never appears in live mode, and vice versa. This boundary is absolute and intentional: it prevents accidental charges and keeps test noise out of your revenue reporting.
In your application code, you typically configure your Stripe client at startup. Here's how you might set it in a NestJS service:
import Stripe from 'stripe';
@Injectable()
export class StripeService {
private stripe: Stripe;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('STRIPE_API_KEY');
const stripeVersion = '2023-10-16';
this.stripe = new Stripe(apiKey, { apiVersion: stripeVersion });
}
async createPaymentIntent(amount: number, currency: string) {
return this.stripe.paymentIntents.create({
amount,
currency,
payment_method_types: ['card'],
});
}
}
The value of STRIPE_API_KEY determines which sandbox you're in. When you run tests or a staging environment, this env var holds a test key; production uses a live key. The Stripe SDK doesn't care—it routes calls to the correct endpoint based on the key prefix. You've now achieved environment isolation at the infrastructure level.
Seed realistic customers and products
A blank sandbox is useless. You need test data that mirrors production scenarios: customers with different countries, subscription states, payment histories, and products with various pricing tiers and tax settings. Seeding is the process of populating your sandbox with this realistic data so your tests and manual exploration are meaningful.
Stripe provides test card numbers and customer fixtures. For example, the card 4242 4242 4242 4242 (with any future expiration and CVC) always succeeds in test mode, while 4000 0000 0000 0002 always fails. But cards alone don't make a realistic scenario. You typically want to seed:
- Test customers with various metadata, email addresses, and subscription histories.
- Products and prices reflecting your pricing model (monthly, annual, usage-based).
- Invoice items, coupons, and tax rates matching your business rules.
- Webhook events to test your event handlers.
A common pattern is to create a seed script that runs once when the sandbox is first set up, or before each test suite. Here's a simplified example:
import Stripe from 'stripe';
async function seedTestData(stripe: Stripe) {
// Create a product
const product = await stripe.products.create({
name: 'Test Premium Plan',
type: 'service',
metadata: { environment: 'test' },
});
// Create prices for different intervals
const monthlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 2999, // $29.99
currency: 'usd',
recurring: { interval: 'month' },
});
const annualPrice = await stripe.prices.create({
product: product.id,
unit_amount: 29990, // $299.90
currency: 'usd',
recurring: { interval: 'year' },
});
// Create a test customer
const customer = await stripe.customers.create({
email: 'testuser@example.com',
description: 'Test customer for sandbox',
metadata: { testScenario: 'basic-subscription' },
});
// Create a subscription
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: monthlyPrice.id }],
});
console.log('Seeded data:', {
productId: product.id,
priceIds: [monthlyPrice.id, annualPrice.id],
customerId: customer.id,
subscriptionId: subscription.id,
});
return { product, customer, subscription };
}
// Run on app startup or before test suite
const stripe = new Stripe(process.env.STRIPE_TEST_KEY);
await seedTestData(stripe);
By seeding with metadata tags like environment: 'test', you can later filter your sandbox queries and reporting to exclude noise. This seeding script becomes part of your test setup, ensuring every developer and CI environment starts with known, consistent data.
Reset between test runs
Unlike a traditional SQL database, Stripe doesn't offer a "reset all data" button in test mode. You can't truncate your test customer table. However, you can adopt strategies to effectively reset or isolate test runs:
Idempotency keys: Stripe supports idempotent requests. When you include an idempotency_key header, Stripe guarantees that if you retry the same request with the same key, it returns the same result without creating duplicate charges. This is invaluable for testing: you can safely retry failed requests without worry.
async function createIdempotentPaymentIntent(
stripe: Stripe,
amount: number,
customerId: string,
idempotencyKey: string,
) {
return stripe.paymentIntents.create(
{
amount,
currency: 'usd',
customer: customerId,
},
{ idempotencyKey }, // Stripe SDK passes this as a header
);
}
Unique identifiers per test run: When you seed data, include a test run identifier (timestamp, UUID, or test suite name) in metadata or descriptions. This lets you query and clean up data from a specific test run without affecting other tests.
const testRunId = `test-${Date.now()}`;
const customer = await stripe.customers.create({
email: `test-${testRunId}@example.com`,
metadata: { testRunId },
});
// Later, query all customers from this run
const allTestCustomers = await stripe.customers.list({
limit: 100,
// Note: Stripe API doesn't directly filter by metadata in list calls,
// so you'd iterate and filter in your code
});
const thisRunsCustomers = allTestCustomers.data.filter(
(c) => c.metadata?.testRunId === testRunId,
);
Separate test accounts for different test suites: For large teams, some organizations maintain multiple Stripe test accounts (e.g., one for payment flow tests, one for subscription tests, one for billing tests). This provides hard isolation and avoids test interference. You switch between accounts by changing the API key.
In CI/CD pipelines, a common pattern is to spin up a fresh Stripe test account for each build, seed it, run tests, and then discard it. This is overkill for most teams but ensures zero cross-test contamination.
Never mix test and live keys
This rule is simple in theory, terrible in practice if neglected. Mixing test and live keys—using a test key in production or a live key in staging—leads to disasters: charges against real customers during development, test data polluting live analytics, or test webhooks triggering live workflows.
Prevention starts with strict key management. Store keys in environment-specific secret stores:
- Development: Local
.envfile (never committed) or a local secrets manager. Use test keys only. - Staging: Secrets stored in your infra platform (AWS Secrets Manager, Kubernetes Secrets, etc.), also test keys.
- Production: Secrets in the same infra platform, but live keys only. Restrict access via IAM roles.
Here's a configuration pattern in NestJS using environment-based guards:
@Injectable()
export class StripeConfigService {
private stripe: Stripe;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('STRIPE_API_KEY');
const environment = this.configService.get<string>('NODE_ENV');
// Guard: ensure test key in non-production environments
if (environment !== 'production' && apiKey?.startsWith('sk_live_')) {
throw new Error(
'FATAL: Live Stripe key detected in non-production environment. ' +
'Check your environment configuration.',
);
}
// Guard: ensure live key in production
if (environment === 'production' && !apiKey?.startsWith('sk_live_')) {
throw new Error(
'FATAL: Test Stripe key detected in production. ' +
'Check your environment configuration.',
);
}
this.stripe = new Stripe(apiKey, { apiVersion: '2023-10-16' });
}
}
Additionally, log every Stripe operation with its key prefix (not the full key) so you can audit which sandbox was used:
async function createPaymentIntent(amount: number) {
const keyPrefix = this.configService
.get<string>('STRIPE_API_KEY')
.substring(0, 10);
console.log(`Creating payment intent with key prefix: ${keyPrefix}`);
// ... rest of logic
}
In webhooks, always verify the webhook source by checking the endpoint secret. Stripe signs each webhook with a secret unique to that endpoint. If someone misconfigures a webhook to point to production logic with a test endpoint secret, the signature verification fails and you're protected:
@Post('/webhook')
async handleWebhook(@Req() req: Request) {
const sig = req.headers['stripe-signature'] as string;
const webhookSecret = this.configService.get<string>(
'STRIPE_WEBHOOK_SECRET',
);
try {
const event = this.stripe.webhooks.constructEvent(
req.rawBody,
sig,
webhookSecret,
);
// Process event
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
throw new BadRequestException('Webhook signature mismatch');
}
}
Common mistakes
- Hardcoding keys in source code: Even test keys should never be committed. Always use environment variables or secret stores.
- Reusing test data across test runs: If your test suite assumes a customer with ID
cus_12345exists, but a previous test deleted it, your suite breaks. Use unique identifiers or properly isolate state. - Skipping webhook testing in sandbox: Webhooks in test mode work normally but require a tunnel (ngrok, etc.) or a staging server. Many teams skip webhook testing locally and then discover issues in production. Test webhooks early and often.
- Forgetting to test failure paths: Stripe provides specific test cards for declined payments, 3D Secure challenges, etc. Use them. A sandbox is worthless if you only test the happy path.
- Mixing multiple test Stripe accounts in one codebase: If you have multiple Stripe test accounts and switch between them by changing environment variables, you'll eventually use the wrong key. Document which account each environment uses and consider enforcing it in code.
Key takeaways
- Stripe test mode is a complete, isolated sandbox. Test keys never touch real money; use them everywhere except production.
- Seed your sandbox with realistic customers, products, and prices so tests mirror real scenarios.
- Use unique identifiers and metadata to track and clean up test data across runs without full resets.
- Enforce strict separation of test and live keys via environment variables, configuration guards, and webhook endpoint secrets.
- Test the full payment flow in sandbox, including webhook handling and failure scenarios, before going live.






Comments
Loading comments…