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.succeededwithout idempotency onevent.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 handling —
invoice.payment_failedignored whileinvoice.paidtested only
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Invalid signature | Wrong whsec_ secret | All events 400 | Stripe CLI secret or generateTestHeaderString |
| Duplicate delivery | Same event.id twice | Double ship/charge | Idempotency table; probe single row |
| Out-of-order | invoice.paid before subscription.created | Partial state | Handler tolerates missing FK; probe eventual |
| Handler timeout | Slow DB | Stripe retries | Return 200 fast; async queue + probe |
| Success page race | Webhook after navigation | Flake | Poll order probe, not URL |
| Missed event | User refreshes success | Stuck pending | Reconciliation job + manual replay test |
| Test vs live keys | Wrong mode | Silent no-op | Assert livemode flag in handler |
| Connect accounts | account field | Wrong tenant | Seed Connect account; probe scoped row |
| Metadata | runId lost | Cannot trace test order | Pass metadata at Checkout create |
| Dead letter | Max retries exceeded | Manual ops | Probe 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:
- POST
invoice.paidbefore customer record linked - Probe handler retries or queues until consistent
- POST
customer.subscription.updated - 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
| Event | Minimum test |
|---|---|
checkout.session.completed | Order/subscription active probe |
invoice.payment_failed | Grace period / dunning flag |
customer.subscription.deleted | Entitlement revoked probe |
charge.refunded | Refund row + balance |
payment_intent.payment_failed | Checkout 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-pattern | Why it fails | Better approach |
|---|---|---|
| Assert success page only | Webhook never ran | Poll probe |
| Shared Stripe customer | Parallel CI collision | Per-run customer id |
| Skip signature test | Fake POST exploit | Negative spec |
sleep(5000) after checkout | Still racing | expect.poll probe |
| Live Stripe every PR | Slow, quota | Signed fixtures + selective Checkout E2E |
| No duplicate event test | Double fulfillment | Replay same event.id |
| Ignore failed invoice path | Silent churn | payment_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.
- Arrange: Checkout Session with metadata.runId; STRIPE_WEBHOOK_SECRET configured; signed fixture or CLI forward ready.
- Act: Complete payment in Checkout UI OR POST signed webhook after simulated redirect.
- 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).
Related scenarios
- Stripe payments — Checkout UI and test cards
- Webhooks async — generic idempotency patterns
- Third-party embeds — Stripe iframe Checkout
- Ecommerce checkout — probe Assert patterns
- Subscriptions billing — plan changes via webhooks
External references
- Stripe CLI
- Stripe webhooks overview
- Sign webhook payloads
- Stripe test cards
- stripe trigger CLI
- Playwright polling
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.