Stripe webhook testing without ngrok
Test Stripe webhook handlers in CI with no public endpoint. Use catchotp to capture Stripe's email-based test events and EventBridge to fan out to your handler.
Webhook testing is the part of building on Stripe that has not gotten any easier in five years. The Stripe CLI is great for “I want to see an event in my terminal.” It is much less great for “I want to assert in CI that my handler did the right thing when invoice.payment_failed fired.” Most teams reach for ngrok and a long-running public endpoint, which works on a developer laptop and does not work in CI.
This post walks through a different shape: capture Stripe’s events through a controlled email-OTP flow plus a deterministic webhook fixture, dispatch them through your own EventBridge bus, and assert against the handler’s database side effects. No public endpoint, no ngrok, no flakiness.
What “webhook testing” usually means in practice
Three failure modes show up in almost every team’s test suite.
Failure 1: the developer-laptop trap
The webhook handler works locally because ngrok is running. CI runs the same handler test, the test fails with “connection refused” on the webhook callback, and the team adds a skipInCi annotation. Six weeks later, a real bug ships because the only environment where the test ran was the one the developer had open.
Failure 2: mocking the handler entirely
The team wraps the handler call in a mock. Tests pass. The signature-verification logic, the idempotency-key check, the database-transaction rollback on duplicate event — none of it runs. Production breaks on a duplicated event in week one.
Failure 3: reusing the live test mode
The team points the webhook at a long-lived ngrok URL on a developer machine, in test mode. Three engineers’ machines all listen on the same endpoint. Events fire on whichever happens to be online. CI is silently disabled.
The right shape avoids all three: tests trigger Stripe events through a controllable, reproducible path, the handler runs the real signature-verification code, and assertions check the database — not the call to webhookHandler.handle() directly.
The architecture
The receive side is the part that needs replacing. Instead of “Stripe POSTs to a public URL,” we use:
Test Stripe
| |
| POST /signup ----+ |
| v |
| your app |
| | |
| | createSubscription -->+
| | |
| +<------ webhook event -+
| |
| (your handler runs here, real code)
| |
| v
| database
| ^
+------ assert -----
The trick: in the test environment, your app receives Stripe webhook events through a deterministic in-process delivery rather than via the public internet. The handler code is identical; only the transport changes.
In CI, that delivery is implemented as:
- A test creates a real Stripe test-mode subscription.
- Stripe sends the webhook event via its normal mechanisms — but the test environment’s webhook URL points at a private endpoint that is reachable from the CI runner only.
- Your handler runs, with real signature verification.
- Your test asserts on the database state.
The “private endpoint reachable from CI only” is the part that usually requires ngrok. Below is the alternative.
Step 1: stand up an EventBridge bus
The piece of infrastructure that replaces the public webhook URL is an EventBridge bus that your CI runner can write to and your application can read from. CDK / Terraform / pulumi all expose it; here it is in CDK shorthand:
import { EventBus } from 'aws-cdk-lib/aws-events';
const bus = new EventBus(this, 'StripeWebhookTestBus', {
eventBusName: 'stripe-webhook-test',
});
// IAM role assumed by CI to put events
const ciRole = new Role(this, 'CIPutEvents', { /* ... */ });
bus.grantPutEventsTo(ciRole);
Your application code in test mode reads from this bus instead of from an HTTP endpoint:
// app/lib/webhook-receiver.ts
if (process.env.NODE_ENV === 'test') {
// EventBridge → SQS → poll loop in test
startEventBridgePoller({
bus: 'stripe-webhook-test',
onEvent: (e) => handleStripeWebhook(e.detail),
});
} else {
// production: HTTP endpoint
app.post('/webhooks/stripe', handleStripeWebhookHttp);
}
Note: the same handleStripeWebhook function is called in both. The signature verification, the idempotency check, the database transaction — all of it runs in the test path.
Step 2: trigger real Stripe events
In your test, use the Stripe CLI’s trigger subcommand or the Stripe SDK to fire an event. Stripe sends it to the configured test-mode webhook endpoint, which we are about to wire up.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_TEST_KEY!);
await stripe.subscriptions.create({
customer: testCustomer.id,
items: [{ price: PRICE_ID }],
});
// Stripe will fire customer.subscription.created
The standard Stripe approach is “tell Stripe where the webhook endpoint is, and Stripe POSTs there.” We instead use Stripe’s own forwardingTo feature in the test endpoint config:
stripe listen \
--api-key $STRIPE_TEST_KEY \
--forward-to https://eventbridge-ingest.example.com/stripe \
--print-secret
Where eventbridge-ingest.example.com is a tiny Lambda Function URL that:
- Verifies the Stripe signature.
- Puts the event onto the EventBridge bus.
- Returns 200.
The signature-verifying ingest is ~30 lines:
import Stripe from 'stripe';
import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';
const stripe = new Stripe(process.env.STRIPE_TEST_KEY!);
const eb = new EventBridgeClient();
export async function handler(event: { headers: Record<string, string>; body: string }) {
const sig = event.headers['stripe-signature'];
const evt = stripe.webhooks.constructEvent(event.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
await eb.send(new PutEventsCommand({
Entries: [{
EventBusName: 'stripe-webhook-test',
Source: 'stripe.test',
DetailType: evt.type,
Detail: JSON.stringify(evt),
}],
}));
return { statusCode: 200, body: 'ok' };
}
This Lambda runs in your test AWS account. It does not need to be public on the internet — Stripe reaches it via the URL above.
Step 3: catchotp’s role — Stripe’s email events
Where catchotp comes in: Stripe also sends email notifications for many events. Receipts, dunning emails, invoice copies, customer-facing notifications. Testing these requires receiving real email — which is exactly the catchotp use case.
import { CatchOTP } from '@catchotp/sdk';
import Stripe from 'stripe';
import { test, expect } from '@playwright/test';
const otp = new CatchOTP({ apiKey: process.env.CATCHOTP_KEY! });
const stripe = new Stripe(process.env.STRIPE_TEST_KEY!);
test('failed payment dunning email lands', async () => {
// 1. Per-test Stripe customer with a programmable email address
const inbox = await otp.inboxes.create({ mode: 'ephemeral', ttlMinutes: 30 });
const customer = await stripe.customers.create({
email: inbox.address,
payment_method: 'pm_card_chargeCustomerFail', // intentional fail
invoice_settings: { default_payment_method: 'pm_card_chargeCustomerFail' },
});
// 2. Create a subscription that will trigger a failed-payment email
const sub = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: process.env.STRIPE_PRICE_ID! }],
payment_behavior: 'error_if_incomplete',
}).catch((e) => e); // expected to error
// 3. Wait for Stripe's customer-facing email to land
const message = await otp.messages.waitFor(inbox.id, {
subjectPattern: /your payment was unsuccessful/i,
timeoutSeconds: 60,
});
// 4. Assert content + that our handler updated the database
expect(message.text).toContain('payment was unsuccessful');
const dunning = await db.dunning.findFirst({ where: { stripeCustomerId: customer.id } });
expect(dunning?.state).toBe('past_due');
});
This single test asserts:
- Stripe correctly sent the failed-payment email.
- Our database side effect (dunning state) is correct.
- The email body matches what we expect customers to see.
All without ngrok, all in CI, all repeatable.
The full CI loop
Putting it together:
# .github/workflows/webhooks.yml
name: stripe-webhooks
on: [pull_request]
jobs:
test:
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/CIWebhookTest
aws-region: us-east-1
- run: pnpm install --frozen-lockfile
- run: pnpm test:webhook
env:
STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
CATCHOTP_KEY: ${{ secrets.CATCHOTP_KEY_CI }}
AWS_EVENTBUS_NAME: stripe-webhook-test
Three secrets, two infrastructure pieces (the EventBridge bus and the signature-verifying ingest Lambda), zero ngrok.
Why this beats stripe listen in CI
The Stripe CLI’s listen command is great for local development. In CI it has three problems:
- It needs an authenticated session. You either bake a credential into the runner or run an interactive auth flow per test, neither of which scales.
- It is a single-tunnel design. Two parallel CI runners using
stripe listenwill see each other’s events, because Stripe pushes to a single subscription. - It is not reproducible. A test that depends on “Stripe sent the event in under 5 seconds” is implicitly trusting Stripe’s delivery latency, which varies.
The EventBridge fan-out replaces all three: server-to-server credential, per-bus isolation, and replayable events.
What this does not test
Be honest about what is in scope.
In scope:
- Your handler logic.
- Your signature-verification code.
- Your idempotency-key check.
- Your database side effects.
- Stripe’s customer-facing email content.
Out of scope:
- Stripe’s actual delivery reliability (synthetic monitoring’s job).
- Your production webhook endpoint’s network reachability (production smoke tests).
- The behavior under sustained webhook event spikes (load test).
The pattern is a compromise: high-fidelity for the parts you can control, no-fidelity for the parts you cannot. That trade is correct for CI.
How catchotp helps
We are the receive side for the email half of this pattern. Stripe’s customer-facing emails — receipts, dunning, invoice copies — land in catchotp inboxes that are scoped per test, with sub-second delivery, and asserted against in the same test that triggered them.
Free tier: 5 inboxes, 1,000 messages a month, no credit card. Start free or read the E2E testing guide for fixture patterns.
Related reading
- How to Test OTP Flows in 2026 — the framework patterns this post builds on.
- Email testing in CI without burning real inboxes — secret scoping and parallelism.
- The E2E testing use case walks through Playwright fixtures in more depth.
The shorter version: Stripe webhook testing in CI is solvable without ngrok. EventBridge replaces the public URL, your real handler runs end-to-end, catchotp covers the email half, and the test stops being “skip in CI.”