Skip to main content

How to Test Stripe Webhooks in CI

Short answer

Checkout success pages render before checkout.session.completed arrives—UI-green is not proof of fulfillment. Test Stripe webhooks with Stripe CLI forwarding or signed fixture POSTs, idempotent handlers verified by probe Assert, and polling until DB state matches event type—not first paint on /success. Cover signature verification, duplicate delivery, out-of-order events, and handler failures without hitting live Stripe in every spec.

Part of Testing Guides by integrations.

Who this is for

Teams using Stripe Billing, Checkout, or Connect where subscription state, order fulfillment, or entitlements update via webhook handlers—not solely synchronous API responses.

Typical stacks: Next.js /api/webhooks/stripe, Rails StripeEvent, Laravel Cashier, or serverless functions on invoice.paid, customer.subscription.updated, etc.

Why testing Stripe webhooks matters

Webhook bugs are revenue-critical:

  • Double fulfillment — duplicate payment_intent.succeeded without idempotency on event.id
  • Stuck subscriptions — handler 500 → Stripe retries for days; user paid but app shows free tier
  • Security incidents — missing signature verification → attacker POSTs fake checkout.session.completed
  • Race with UI — success page loads before handler runs; test passes, prod support tickets flood
  • Wrong event handlinginvoice.payment_failed ignored while invoice.paid tested only

Complexity map

ScenarioEdge caseWhy tests breakApproach
Invalid signatureWrong whsec_ secretAll events 400Stripe CLI secret or generateTestHeaderString
Duplicate deliverySame event.id twiceDouble ship/chargeIdempotency table; probe single row
Out-of-orderinvoice.paid before subscription.createdPartial stateHandler tolerates missing FK; probe eventual
Handler timeoutSlow DBStripe retriesReturn 200 fast; async queue + probe
Success page raceWebhook after navigationFlakePoll order probe, not URL
Missed eventUser refreshes successStuck pendingReconciliation job + manual replay test
Test vs live keysWrong modeSilent no-opAssert livemode flag in handler
Connect accountsaccount fieldWrong tenantSeed Connect account; probe scoped row
MetadatarunId lostCannot trace test orderPass metadata at Checkout create
Dead letterMax retries exceededManual opsProbe DLQ row + alert scenario

Stripe CLI in local dev and CI

Forward events to your handler during development and CI jobs that run the app locally:

# Install: https://stripe.com/docs/stripe-cli
stripe listen --forward-to localhost:3000/api/webhooks/stripe --print-secret

Export the printed whsec_... as STRIPE_WEBHOOK_SECRET for your app and test fixtures.

CI pattern:

stripe listen --forward-to localhost:3000/api/webhooks/stripe --print-secret &
LISTEN_PID=$!
export STRIPE_WEBHOOK_SECRET=$(stripe listen --print-secret 2>/dev/null | tail -1)

npm run dev &
npx wait-on tcp:3000

# Trigger test events
stripe trigger checkout.session.completed

npm run test:e2e
kill $LISTEN_PID

For pipelines without CLI, POST signed fixtures (next section).

Signed fixture POSTs (no CLI)

Use Stripe's test helpers or construct signature header from raw body + secret:

import Stripe from 'stripe';

const payload = JSON.stringify(fixtureEvent);
const header = stripe.webhooks.generateTestHeaderString({
payload,
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});

await request.post('/api/webhooks/stripe', {
headers: { 'stripe-signature': header, 'content-type': 'application/json' },
data: payload,
});

Store fixtures under tests/fixtures/stripe/ with stable event.id per scenario.

Signature verification test (negative)

Required security spec:

await request.post('/api/webhooks/stripe', {
data: fixtureEvent,
headers: { 'stripe-signature': 't=0,v1=invalid' },
});
await expect(probeWebhookRejectedCount(runId)).toBe(1);
// No subscription row created
await expect.poll(() => probeSubscription(userId)).toBeNull();

Never disable verification in test env without separate unsigned route blocked at network edge.

Idempotency on event.id

const eventId = 'evt_test_duplicate_' + runId;
const fixture = { ...baseFixture, id: eventId };

await postSignedWebhook(fixture);
await postSignedWebhook(fixture); // duplicate

await expect.poll(() => probeFulfillmentCount(orderId)).toBe(1);

Implement processed_stripe_events table or Redis SET—probe is authoritative.

Out-of-order events

Simulate arrival sequence that prod sees under load:

  1. POST invoice.paid before customer record linked
  2. Probe handler retries or queues until consistent
  3. POST customer.subscription.updated
  4. Probe final entitlement matches paid state

Handlers must not assume Stripe's documentation order matches delivery order.

E2E: Checkout UI + webhook + probe

// Arrange: seed user + create Checkout Session via test API with metadata.runId
const { checkoutUrl, orderId } = await seedCheckoutSession(runId);

// Act: complete Checkout with Stripe test card (see payments guide)
await page.goto(checkoutUrl);
// ... fill 4242 test card via Stripe iframe — see third-party embeds guide

// Assert: poll probe — NOT success page headline alone
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-order/${orderId}`);
return (await res.json()).status;
}, { timeout: 30_000 }).toBe('paid');

Alternatively Arrange signed webhook after UI Act that stops at "redirect to success" to isolate handler tests from Checkout UI.

Handler failure and retry

Toggle test endpoint to return 500 on first POST:

await enableWebhookFailureOnce(runId);
await postSignedWebhook(fixture);
await expect.poll(() => probeHandlerAttempts(eventId)).toBeGreaterThan(1);
await expect.poll(() => probeOrderStatus(orderId)).toBe('paid');

Verify Stripe retry semantics or internal queue—document expected behavior.

Event type coverage matrix

EventMinimum test
checkout.session.completedOrder/subscription active probe
invoice.payment_failedGrace period / dunning flag
customer.subscription.deletedEntitlement revoked probe
charge.refundedRefund row + balance
payment_intent.payment_failedCheckout error state

Prioritize by TrueCoverage webhook_event_type prod volume—not all 200+ event types.

Metadata and traceability

Pass metadata: { runId, testScenario } when creating Checkout Session in Arrange—probe logs correlate handler processing to Playwright test.

Anti-patterns

Anti-patternWhy it failsBetter approach
Assert success page onlyWebhook never ranPoll probe
Shared Stripe customerParallel CI collisionPer-run customer id
Skip signature testFake POST exploitNegative spec
sleep(5000) after checkoutStill racingexpect.poll probe
Live Stripe every PRSlow, quotaSigned fixtures + selective Checkout E2E
No duplicate event testDouble fulfillmentReplay same event.id
Ignore failed invoice pathSilent churnpayment_failed fixture

Example scenario

Situation: Customer completes Checkout; webhook arrives 2–10 seconds after success redirect.

Expected outcome: Order status paid and entitlement granted exactly once when handler processes checkout.session.completed.

Why UI-only automation breaks: Success page shows thank-you while probe still pending—test passes if it does not poll.

  1. Arrange: Checkout Session with metadata.runId; STRIPE_WEBHOOK_SECRET configured; signed fixture or CLI forward ready.
  2. Act: Complete payment in Checkout UI OR POST signed webhook after simulated redirect.
  3. Assert: expect.poll probe order paid; duplicate event.id does not double-grant.

TestChimp workflow: Instrument webhook_event_type × processing_outcome; compare prod vs test when invoice.payment_failed spikes untested.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Connect scenarios to your QA workflow

Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).

External references

Frequently asked questions

How do I test Stripe webhooks locally and in CI?

Use stripe listen --forward-to to pipe events to your handler, exporting STRIPE_WEBHOOK_SECRET from --print-secret. Or POST fixture JSON with stripe-signature from generateTestHeaderString. Poll probe until handler side effects appear.

What if the webhook arrives after the success page loads?

Success page should show pending or poll client-side; tests must wait on probe until handler processed event—not assert on first paint of thank-you text.

How do I test duplicate webhook delivery?

POST same event id twice with valid signature. Probe Assert single side effect—one order fulfilled, one subscription row. Handler must idempotently key on event.id.

How do I simulate a failed webhook handler?

Toggle test endpoint to return 500 on first POST; verify retry or queue eventually succeeds. Probe handler attempt count and final order state.

Do I need Stripe CLI in CI if I use signed fixtures?

No—signed fixtures are faster for handler unit/integration tests. Keep one E2E with CLI or trigger for pipeline confidence; use fixtures for matrix of event types.

How do I test signature verification?

POST valid fixture with bad stripe-signature header; probe rejects and no DB mutation. Required security spec alongside happy path.

We handle 12 Stripe event types—which are covered?

Compare prod vs test-run across webhook_event_type × processing_outcome in TrueCoverage. Prioritize high-volume prod events for signed fixture tests when adding billing flows—/testchimp evolve closes gaps.

Webhook tests flake on timing—waitForTimeout or poll?

Poll probe until handler side effects appear—never fixed sleeps. See [flaky waits gotcha](/guides/gotchas/playwright-flaky-waits-and-timing).

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo