Testing time-based billing logic without waiting days or months is one of the biggest challenges in payment engineering. Stripe test clocks solve this by letting you manipulate time in your test environment, triggering renewals, expirations, and trial endings on demand. In this practical guide to Stripe test clocks, we'll walk through creating test clocks, attaching customers and subscriptions, advancing time to simulate real billing cycles, and verifying that your webhooks and emails fire correctly.
Why test clocks matter for billing systems
In production, subscription logic runs over weeks, months, or years. You can't afford to wait that long during development and QA. Test clocks let you compress time: create a subscription with a trial that ends tomorrow in production, then advance your test clock by 25 hours to trigger the trial-end webhook immediately. This means you can validate your entire billing funnel—renewals, dunning, cancellations, reactivations—in a single sprint.
Without test clocks, teams often resort to creating mock events manually or disabling time-based logic entirely in test mode. Both approaches leave gaps. Manual events don't test your scheduled webhook handlers. Disabling logic means you're not testing the code path that matters most. Test clocks bridge that gap.
Create a test clock
A test clock is a virtual clock scoped to a test mode Stripe account. When you create one, you set its initial time. All subsequent API calls and webhook events issued under that clock's context use that frozen time. Let's create one:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16',
});
async function createTestClock() {
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000), // Unix timestamp, now
});
console.log('Test clock created:', clock.id);
console.log('Frozen time:', new Date(clock.frozen_time * 1000));
return clock;
}
createTestClock();
```
<p>The <code>frozen_time</code> parameter takes a Unix timestamp. We're starting at "now" in this example, but you can set it to any point in the past or future. The clock ID (e.g., <code>clock_1234567890</code>) is what you'll reference when creating customers and subscriptions under this test environment.</p>
<p>Keep the clock ID in your test suite or environment variables. You'll need it for every subsequent operation that must respect the same virtual timeline.</p>
<h2 id="attach-customers-and-subscriptions">Attach customers and subscriptions</h2>
<p>Once you have a test clock, create customers and subscriptions as normal—but include the clock ID in your API calls. This binds those resources to your frozen time:</p>
<pre><code class="language-typescript">async function createTestSubscription(clockId: string) {
// Create a customer attached to this test clock
const customer = await stripe.customers.create(
{
email: 'test@example.com',
description: 'Test billing cycle customer',
},
{
stripeAccount: undefined, // or your Connect account ID
// The clock context is set via headers, handled by the Stripe SDK
idempotencyKey: `test-sub-${clockId}-${Date.now()}`,
}
);
// Add a payment method (test card)
const paymentMethod = await stripe.paymentMethods.create({
type: 'card',
card: {
number: '4242424242424242',
exp_month: 12,
exp_year: 2025,
cvc: '314',
},
});
await stripe.paymentMethods.attach(paymentMethod.id, {
customer: customer.id,
});
// Retrieve product and price (assuming they exist)
const price = await stripe.prices.retrieve('price_1234567890');
// Create subscription with trial
const subscription = await stripe.subscriptions.create(
{
customer: customer.id,
items: [{ price: price.id }],
payment_behavior: 'default_incomplete',
trial_period_days: 7,
default_payment_method: paymentMethod.id,
},
{
stripeAccount: undefined,
// In real usage, you pass the clock context via headers:
headers: {
'Stripe-Mock-Test-Clock-ID': clockId,
},
}
);
console.log('Subscription created:', subscription.id);
console.log('Trial ends at:', new Date(subscription.trial_end * 1000));
return { customer, subscription };
}
```
<p>Notice the <code>headers: { 'Stripe-Mock-Test-Clock-ID': clockId }</code> line. This tells Stripe to bind this subscription to your test clock. All events and state changes for this subscription will use the clock's frozen time, not real time.</p>
<p>If you're using a Node.js SDK version earlier than the one that natively supports clock headers, you may need to pass the clock ID differently. Check your Stripe SDK version and documentation to confirm the exact approach.</p>
<h2 id="advance-time-to-trigger-renewals-and-expirations">Advance time to trigger renewals and expirations</h2>
<p>Now for the magic: advance the test clock to trigger billing cycles. Let's say your subscription has a 7-day trial that ends at timestamp 1699000000. Advance the clock past that time:</p>
<pre><code class="language-typescript">async function advanceTestClock(clockId: string, secondsForward: number) {
const updated = await stripe.testHelpers.testClocks.advance(clockId, {
frozen_time: Math.floor(Date.now() / 1000) + secondsForward,
});
console.log('Clock advanced to:', new Date(updated.frozen_time * 1000));
return updated;
}
// Example: advance by 8 days to trigger trial end and first renewal
async function simulateBillingCycle() {
const clock = await createTestClock();
const { subscription } = await createTestSubscription(clock.id);
console.log('Subscription created, trial ends:', subscription.trial_end);
// Advance 8 days (691200 seconds)
await advanceTestClock(clock.id, 691200);
// Now fetch the subscription—it should have renewed
const updated = await stripe.subscriptions.retrieve(subscription.id);
console.log('Subscription status after renewal:', updated.status);
console.log('Next renewal date:', updated.current_period_end);
}
simulateBillingCycle();
```
<p>When you advance the clock, Stripe processes all scheduled actions that fall within that time window—trial endings, renewals, dunning attempts, subscription expirations. These happen synchronously in your test environment, making it fast and deterministic.</p>
<p>You can advance a single clock multiple times. Each call moves it forward from its current position. This is useful for simulating a series of billing cycles or payment failures in sequence.</p>
<h2 id="verify-webhooks-and-emails">Verify webhooks and emails</h2>
<p>The real test is whether your handlers respond correctly. When you advance the clock, Stripe emits webhooks as if time had truly passed. Your webhook endpoint should receive events like <code>invoice.created</code>, <code>invoice.payment_succeeded</code>, <code>customer.subscription.updated</code>, and <code>customer.subscription.trial_will_end</code>.</p>
<p>In your test suite, listen for these webhooks and assert on their content:</p>
<pre><code class="language-typescript">import express, { Request, Response } from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
let capturedEvents: any[] = [];
app.post('/webhooks/stripe', (req: Request, res: Response) => {
const event = JSON.parse(req.body.toString());
console.log('Webhook received:', event.type);
capturedEvents.push(event);
res.json({ received: true });
});
async function testBillingCycleWithWebhooks() {
const clock = await createTestClock();
const { subscription } = await createTestSubscription(clock.id);
capturedEvents = [];
// Advance clock to trigger trial end and renewal
await advanceTestClock(clock.id, 691200);
// Give webhooks a moment to arrive
await new Promise(resolve => setTimeout(resolve, 500));
// Verify expected events
const trialEndedEvents = capturedEvents.filter(
e => e.type === 'customer.subscription.trial_will_end'
);
const renewalEvents = capturedEvents.filter(
e => e.type === 'customer.subscription.updated'
);
console.assert(trialEndedEvents.length > 0, 'Trial end event received');
console.assert(renewalEvents.length > 0, 'Renewal event received');
console.log('All expected webhooks received!');
}
testBillingCycleWithWebhooks();
For email verification, Stripe doesn't natively trigger emails during test clock operations. However, you can use your webhook events to mock or verify email logic. Listen for invoice.payment_succeeded and confirm that your email service was called with the correct recipient and content:
// Mock email service for testing
const mockEmailService = {
sentEmails: [] as any[],
send: (to: string, subject: string, body: string) => {
mockEmailService.sentEmails.push({ to, subject, body });
},
};
// In your webhook handler
app.post('/webhooks/stripe', (req: Request, res: Response) => {
const event = JSON.parse(req.body.toString());
if (event.type === 'invoice.payment_succeeded') {
const invoice = event.data.object;
// Trigger your email logic
mockEmailService.send(
invoice.customer_email,
'Receipt for your subscription renewal',
`Invoice ${invoice.number} for ${invoice.amount_paid / 100} charged.`
);
}
res.json({ received: true });
});
// In your test
async function testRenewalEmail() {
const clock = await createTestClock();
const { subscription, customer } = await createTestSubscription(clock.id);
mockEmailService.sentEmails = [];
await advanceTestClock(clock.id, 691200);
await new Promise(resolve => setTimeout(resolve, 500));
const receiptEmails = mockEmailService.sentEmails.filter(
e => e.to === customer.email && e.subject.includes('Receipt')
);
console.assert(receiptEmails.length > 0, 'Renewal receipt email sent');
}
testRenewalEmail();
Common mistakes
- Forgetting to pass the clock ID to create requests: If you don't include the clock ID header or pass it correctly, the customer and subscription won't be bound to your test clock. They'll use real time instead. Always double-check the SDK documentation for your version.
- Advancing the clock without waiting for async processing: Stripe processes webhooks and state changes asynchronously. Add a small delay (
setTimeout) before querying the subscription or checking your webhook queue. 500ms is usually sufficient in test mode. - Using production payment methods in test mode: Make sure you're creating test cards (
4242...,5555...) and test payment methods. Real cards will fail and pollute your test data. - Not deleting or resetting clocks between test runs: Each test should create its own clock to avoid state leakage. Alternatively, reset your Stripe test data between suites using Stripe's dashboard or API.
- Advancing the clock too far at once: If you jump months ahead in a single advance call, all intermediate state changes happen at once. For testing error scenarios (like failed renewals), advance incrementally and introduce failures between steps.
Key takeaways
- Test clocks let you compress subscription cycles into seconds, making billing logic testable during development without waiting for real time to pass.
- Create a clock with an initial
frozen_time, then bind customers and subscriptions to it via the clock ID header when calling the Stripe API. - Advance the clock to trigger renewals, trial endings, and dunning attempts. All scheduled events within that time window fire synchronously.
- Capture and verify webhooks in your test suite to ensure your handlers respond correctly to billing events and state changes.
- For email testing, use your webhook events and mock email services. Assert that the right emails were sent to the right recipients after each clock advance.
- Remember that test clock operations are deterministic and isolated—each test should create its own clock and reset data between runs to avoid cross-test pollution.






Comments
Loading comments…