Back to BlogQuality & Testing

Testing Payments and Webhooks End to End: A Stripe Test Plan

Rupak Amin

Founder & Lead Engineer, RAITHub

12 min read

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 wrongReal-world causeTest that catches it
A customer is charged twiceA network retry repeats the create callRepeat the same request with one idempotency key; assert one charge
Access or an order is granted twiceThe same event is delivered more than onceProcess the same event ID twice; assert one change
Wrong final statusEvents arrive out of orderDeliver "updated" before "created"; assert the correct end state
Access continues after a failed renewalNobody tested month twoTest clock renewal with a card that declines
Fake events acceptedSignature not checked, or checked on a parsed bodySend an unsigned and a wrongly signed event; assert 400 and no change
Payment succeeded, database not updatedHandler crashed after returning 200Fail 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 numberBehaviourUse it to test
4242 4242 4242 4242SucceedsThe happy path, and renewals that should succeed
4000 0000 0000 0002Declined, generic_declineThe decline message and a retry
4000 0000 0000 9995Declined, insufficient_fundsSpecific decline reasons shown to the customer
4000 0025 0000 3155Requires authentication unless set up for future payments3D Secure on first payment, then off-session renewals
4000 0027 6000 3184Requires authentication on every transactionOff-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.ready event, 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).

TestSetupMust hold
Client retryCall your "create payment" code twice with the same keyOne PaymentIntent in Stripe, one row in your database
Key reuse with changed amountSame key, different amountYour code surfaces Stripe's error, never silently charges
Duplicate eventDeliver one event ID twiceOne change, one email
Out of orderDeliver customer.subscription.updated before createdCorrect final status, from the latest object state
Crash after acknowledgingMake the database write fail onceThe event is retried or kept in a queue, not lost
Late failureSuccess event, then a failure or refund days laterA 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.

WhereWhat runs
Every pull requestUnit tests with signed fake events, the replay table above, idempotency tests with Stripe mocked at the client
Nightly, in a CI sandboxA small suite against the real sandbox API: create, pay, decline, one test clock renewal
Before a payment releaseCLI triggers and one full browser checkout with test cards, including 3D Secure
At launch, onceOne 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.

test stripe webhooksstripe test modestripe cli triggerstripe test clocksidempotencypayment testing

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.