Founder & Lead Engineer, RAITHub
Test payments in four layers. Unit test your webhook handler with signed fake events. Fire real events at a local server with the Stripe CLI. Use test clocks for renewals and failed renewals. Run a replay suite that sends events twice and out of order. Use Stripe's test cards in a sandbox, never real cards. The goal is proof that one payment changes your database exactly once.
This is the test plan, not the debugging guide. If webhooks are failing right now, start with Stripe webhook not firing or webhook returns 200 but the subscription is not updated. The examples use Stripe, Node.js and TypeScript, and the same layers apply to any provider that sends webhooks.
What goes wrong in payment systems that tests must catch?
Very little goes wrong on the happy path. The bugs live in retries, duplicates, timing and state that changes days after checkout. Write your tests from this list, not from the demo flow.
| What goes wrong | Real-world cause | Test that catches it |
|---|---|---|
| A customer is charged twice | A network retry repeats the create call | Repeat the same request with one idempotency key; assert one charge |
| Access or an order is granted twice | The same event is delivered more than once | Process the same event ID twice; assert one change |
| Wrong final status | Events arrive out of order | Deliver "updated" before "created"; assert the correct end state |
| Access continues after a failed renewal | Nobody tested month two | Test clock renewal with a card that declines |
| Fake events accepted | Signature not checked, or checked on a parsed body | Send an unsigned and a wrongly signed event; assert 400 and no change |
| Payment succeeded, database not updated | Handler crashed after returning 200 | Fail the database write; assert the event is retried or queued |
How do Stripe test mode and sandboxes work?
A sandbox is an isolated test environment in your Stripe account where transactions use test values and don't move funds. Stripe's Services Agreement prohibits testing in live mode with real payment method details (Stripe: testing). Use your test API keys and the documented test cards. These are the ones most payment test suites need:
| Card number | Behaviour | Use it to test |
|---|---|---|
| 4242 4242 4242 4242 | Succeeds | The happy path, and renewals that should succeed |
| 4000 0000 0000 0002 | Declined, generic_decline | The decline message and a retry |
| 4000 0000 0000 9995 | Declined, insufficient_funds | Specific decline reasons shown to the customer |
| 4000 0025 0000 3155 | Requires authentication unless set up for future payments | 3D Secure on first payment, then off-session renewals |
| 4000 0027 6000 3184 | Requires authentication on every transaction | Off-session payments that need the customer to come back |
Any future expiry date and any three-digit CVC work (same source). Stripe also recommends a separate sandbox for continuous integration, so CI's test objects don't interfere with local development (Stripe: test clocks).
How do you unit test a Stripe webhook handler?
Sign fake events with your test secret and pass them through the real verification code. Keep the event handling in a function that takes the verified event and the database, so you can call it directly and assert on the result. Deduplicate by recording each event ID in a table with a unique key:
CREATE TABLE processed_stripe_events (
event_id text PRIMARY KEY,
type text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now()
);
Stripe says endpoints might receive the same event more than once, and recommends logging processed event IDs and skipping those already logged (Stripe: webhooks). The stripe-node library provides webhooks.generateTestHeaderString to build a valid signature for a test payload (stripe-node README).
// tests/stripe-webhook.test.ts
import Stripe from 'stripe'
import { describe, it, expect, beforeEach } from 'vitest'
import { handleStripeEvent } from '../src/billing/handle-stripe-event'
import { db, resetDb, countPaymentsFor } from './helpers/db'
const stripe = new Stripe('sk_test_dummy')
const secret = 'whsec_test_secret'
function signedEvent(event: object) {
const payload = JSON.stringify(event)
const header = stripe.webhooks.generateTestHeaderString({ payload, secret })
return stripe.webhooks.constructEvent(payload, header, secret)
}
const paid = {
id: 'evt_test_1',
object: 'event',
type: 'invoice.paid',
data: { object: { id: 'in_test_1', customer: 'cus_test_1', amount_paid: 120000 } },
}
describe('invoice.paid', () => {
beforeEach(resetDb)
it('records one payment when the same event arrives twice', async () => {
const event = signedEvent(paid)
await handleStripeEvent(event, db)
await handleStripeEvent(event, db)
expect(await countPaymentsFor('in_test_1')).toBe(1)
})
it('rejects a payload signed with the wrong secret', () => {
const payload = JSON.stringify(paid)
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: 'whsec_wrong' })
expect(() => stripe.webhooks.constructEvent(payload, header, secret)).toThrow()
})
})
Inside handleStripeEvent, insert the event ID and apply the change in the same database transaction. If the insert hits the unique key, the event was already handled and you return early. If the change fails, the transaction rolls back the event ID too, so a retry can process it properly.
How do you fire real webhook events at a local server?
Use the Stripe CLI. stripe listen forwards events from your sandbox to a local endpoint and prints a signing secret to use while it runs (Stripe: webhooks). stripe trigger then creates real test objects through the API, and those objects produce real events.
# Terminal 1: forward sandbox events to your app
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Terminal 2: create objects that emit events
stripe trigger payment_intent.succeeded
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completed \
--add checkout_session:metadata.tenant_id=tenant_test_1
Two details from the CLI reference matter for testing. Triggering has side effects: it creates all the API objects it needs, and one trigger can fire other events too (payment_intent.succeeded also sends payment_intent.created). And the --override, --add and --remove flags change the fixture's parameters, which is how you attach the metadata your handler looks up (Stripe CLI: trigger). If your handler reads a tenant or order ID from metadata, trigger it once with the metadata and once without, and check that the second case fails safely.
Once a bug is fixed, you can replay the real event that exposed it. The Dashboard resends events for up to 15 days after creation, and stripe events resend works for up to 30 days (Stripe: webhooks).
How do you test renewals, trials and failed payments with test clocks?
With test clocks, which let you move a sandbox customer's time forward. Create a clock with a frozen start time, attach a new customer and subscription, then advance the clock and watch the invoices and events that follow (Stripe: test clocks, API and advanced usage).
# Create a clock at 1 Jan 2027 00:00 UTC
curl https://api.stripe.com/v1/test_helpers/test_clocks \
-u "$STRIPE_TEST_KEY:" \
-d frozen_time=1798761600 \
-d name="Monthly renewal, card declines"
# Create the customer with test_clock=clock_..., subscribe them, then:
curl https://api.stripe.com/v1/test_helpers/test_clocks/clock_123/advance \
-u "$STRIPE_TEST_KEY:" \
-d frozen_time=1801440000 # 1 Feb 2027
The limits shape how you design these tests. According to the same page:
- You can move a clock only forward, by up to two intervals of the shortest subscription period at a time. For a monthly plan, that is two months per advance.
- Each clock holds up to three customers, each with up to three subscriptions.
- Advancing takes a few seconds. Wait for the
test_helpers.test_clock.readyevent, or poll the clock's status, before you assert anything. - Clocks are deleted automatically 30 days after creation. Deleting one deletes its customers and cancels their subscriptions.
- Advancing doesn't currently collect bank debit payments such as
us_bank_account, so test ACH failures separately.
The cases worth a clock each: a renewal that succeeds, a renewal where the card declines, a trial that converts, a trial that ends with no payment method, and a mid-cycle upgrade with proration. After each advance, assert on your own database, not only on Stripe. What matters is whether your app granted or removed access correctly.
How do you test idempotency on both sides of the API?
Outgoing and incoming need different tests. When you call Stripe, send an idempotency key on every POST. Stripe saves the status code and body of the first request for a key, including 500 errors, and returns the same result for retries. Keys can be up to 255 characters and may be pruned once they are at least 24 hours old. Reusing a key with different parameters returns an error (Stripe: idempotent requests).
When Stripe calls you, expect duplicates, retries and any order. Stripe doesn't guarantee delivery in the order events are generated, and warns against using the created timestamp to order events or detect duplicates. In live mode it retries failed deliveries for up to three days with exponential backoff. In a sandbox, it retries three times over a few hours (Stripe: webhooks).
| Test | Setup | Must hold |
|---|---|---|
| Client retry | Call your "create payment" code twice with the same key | One PaymentIntent in Stripe, one row in your database |
| Key reuse with changed amount | Same key, different amount | Your code surfaces Stripe's error, never silently charges |
| Duplicate event | Deliver one event ID twice | One change, one email |
| Out of order | Deliver customer.subscription.updated before created | Correct final status, from the latest object state |
| Crash after acknowledging | Make the database write fail once | The event is retried or kept in a queue, not lost |
| Late failure | Success event, then a failure or refund days later | A reversing entry, and access or balance corrected |
What belongs in CI, and what do you check by hand before launch?
Keep live network calls out of the pull request gate. Calls to Stripe are slow and sometimes fail for reasons unrelated to your change, which turns into flaky builds.
| Where | What runs |
|---|---|
| Every pull request | Unit tests with signed fake events, the replay table above, idempotency tests with Stripe mocked at the client |
| Nightly, in a CI sandbox | A small suite against the real sandbox API: create, pay, decline, one test clock renewal |
| Before a payment release | CLI triggers and one full browser checkout with test cards, including 3D Secure |
| At launch, once | One real low-value transaction in live mode with your own card, then a refund |
The live-mode check tests the configuration: live keys, the live webhook endpoint and its separate signing secret. Stripe generates a different secret for test and live mode on the same endpoint (Stripe: webhooks). For mocking third-party calls in browser tests without making them flaky, see the flaky end-to-end tests guide.
How does PropDesk test its Stripe rent collection?
PropDesk is a property management platform RAITHub built, with 4 roles and rent collected through Stripe. It has 1,024 automated tests, 932 unit and 92 end to end, run in CI. Rent adds date logic to the usual payment risks. The cases worth writing first are grace periods, a late fee charged only once, a payment still processing on the last grace day, the same webhook delivered twice, and a bank debit that fails after first reporting success. That list, and the design behind it, is in how to build an online rent collection system. For subscription billing rather than rent, see how to add Stripe billing to a SaaS.
Why RAITHub for this
Because RAITHub ships payment code with its tests gated in CI, so replays, duplicates and failed renewals are tested with every change rather than left for launch week. PropDesk's Stripe rent collection runs inside a 1,024-test suite. RAITHub can add this test layer to an existing integration through the QA and test automation service, or rebuild the webhook handling itself through API and backend development. Both are fixed-scope, quoted in writing after a free 15-minute technical audit, and the code is yours.
When you don't need us
- You use a hosted checkout or payment links and your app does nothing when a payment arrives. There is little of your own code to test.
- Your team already has the replay table above in CI. Add test clock renewals and you have covered most of the risk.
- You need a PCI or compliance assessment. That needs a qualified assessor. RAITHub tests your integration's behaviour and is not SOC 2 or ISO 27001 certified.
- You are building a licensed financial product. RAITHub has not shipped a regulated fintech product, so pair any build with a compliance partner.
To get your payment and webhook flows covered by tests, book the free 15-minute technical audit.
Documentation checked on 29 September 2026.
Frequently asked questions
How do I test Stripe webhooks locally?
Run stripe listen --forward-to with your local webhook URL, use the signing secret it prints, then run stripe trigger with an event type such as invoice.payment_failed. Triggers create real test objects, so expect related events too.
Can I unit test a webhook handler without calling Stripe?
Yes. Build the event payload yourself, sign it with webhooks.generateTestHeaderString from stripe-node, and pass it through constructEvent and your handler. Assert on your database.
How do I test subscription renewals without waiting a month?
Use Stripe test clocks. Attach a new customer and subscription to a clock, then advance it, up to two billing intervals at a time, and check the invoices, events and your app's access rules after each step.
Which test card simulates a declined payment?
4000 0000 0000 0002 gives a generic decline, and 4000 0000 0000 9995 declines with insufficient funds. Use 4242 4242 4242 4242 for success. All work in a sandbox with any future date and any CVC.
How do I make sure a webhook is not processed twice?
Record each event ID in a table with a unique key, inside the same transaction as the change it causes. Test it by delivering the same event twice and asserting one change.
Should payment tests call the real Stripe API in CI?
Not on every pull request. Mock Stripe at the client there, and run a small suite against a dedicated CI sandbox nightly and before payment releases.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.