Payment webhooks are the highest-stakes webhooks you'll ever wire up. A missed payment_intent.succeeded means a customer paid and didn't get their thing. A double-processed charge.refunded means you accidentally refunded twice. The bugs hide until they don't.
This is the practical guide to testing Stripe and PayPal webhooks end-to-end — from inspecting the raw payloads to verifying signatures, simulating failures, and asserting on the full async flow.
The Three Layers of Webhook Testing
For payment webhooks specifically:
Layer What you're testing How
Inspection What does the provider actually send? YoBox Webhook Tester, Stripe CLI
Handler logic Does my code do the right thing on a known payload? Unit tests with fixture payloads
End-to-end Does the full flow work, including signature, retries, idempotency? Integration tests against test mode
Skip any layer and bugs leak.
Layer 1: Inspect the Payloads
Before writing the handler, see what the provider sends. The docs are usually right; "usually" is a problem when money's involved.
Stripe
Use the YoBox Webhook Tester:
Generate a capture URL in YoBox.
In the Stripe dashboard → Developers → Webhooks → Add endpoint.
Paste the YoBox URL. Select the events you care about.
Use the Stripe CLI to trigger test events: stripe trigger payment_intent.succeeded.
Watch the capture log fill in.
You'll see the full payload, including:
- Stripe-Signature header (the HMAC you'll verify)
- Stripe-Version header
- JSON body with type, data.object, etc.
Save a few payloads as test fixtures. You'll want them for unit tests.
PayPal
Same flow, but PayPal's UI is more cumbersome:
Generate a YoBox capture URL.
In the PayPal Developer dashboard → My Apps & Credentials → your app → "Add webhook."
Paste the URL. Select events.
Use the simulator: Webhook Simulator → choose event type → enter your URL.
Inspect in YoBox.
PayPal payloads are noisier than Stripe's. The signature scheme is also more complex (involves cert chain verification, not just HMAC). Capture early, save fixtures.
Layer 2: Unit Test the Handler
With fixtures in hand, write unit tests that hit your handler directly:
import paymentSucceeded from './fixtures/stripe-payment-intent-succeeded.json';
test('handles payment_intent.succeeded', async () => {
const result = await handleStripeWebhook(paymentSucceeded);
expect(result.status).toBe('processed');
expect(orderRepo.markPaid).toHaveBeenCalledWith({
orderId: paymentSucceeded.data.object.metadata.order_id,
amount: paymentSucceeded.data.object.amount,
});
});
test('is idempotent on duplicate event', async () => {
await handleStripeWebhook(paymentSucceeded);
const second = await handleStripeWebhook(paymentSucceeded);
expect(second.status).toBe('skipped-duplicate');
expect(orderRepo.markPaid).toHaveBeenCalledTimes(1);
});
test('rejects bad signature', async () => {
await expect(
handleStripeWebhook(paymentSucceeded, { signature: 'bogus' })
).rejects.toThrow(/signature/);
});
Run on every PR.
Layer 3: End-to-End in Test Mode
The most important layer and the one teams most often skip. The flow:
Spin up your app pointed at Stripe (or PayPal) test mode.
Configure the webhook endpoint to be your app's real handler URL (via ngrok in dev, your staging URL in CI).
Create a real test charge with the Stripe CLI or PayPal sandbox.
Assert on:
- The webhook arrived
- Your handler returned 2xx within timeout
- The database state is correct
- Any downstream side effects fired Using the Stripe CLI in CI
# Forward webhooks to localhost while running tests
stripe listen --forward-to http://localhost:3000/webhooks/stripe &
#Trigger a known event stripe trigger payment_intent.succeeded
Now your test asserts on the resulting state ```
{% endraw %}
The Stripe CLI handles signature verification and gives you a known webhook secret you can use in tests.
Pairing with YoBox for capture verification
Sometimes you want to assert that your app sent a webhook downstream — for example, a notification to a downstream service after the payment processed. Point that downstream URL at the YoBox Webhook Tester in test mode:
{% raw %}
```ts
test('after payment, notifies fulfillment service', async () => {
const captureUrl = 'https://yobox.dev/webhook/' + crypto.randomUUID();
await setFulfillmentWebhookUrl(captureUrl);
await triggerStripePaymentInTestMode();
const captured = await pollForWebhook(captureUrl, 30_000);
expect(captured.body.event).toBe('order.paid');
expect(captured.body.amount).toBeGreaterThan(0);
});
Signature Verification: Stripe
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const signature = req.headers.get('stripe-signature');
const body = await req.text(); // raw body, not parsed
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response('invalid signature', { status: 400 });
}
// Idempotency check
if (await wasProcessed(event.id)) {
return new Response('ok', { status: 200 });
}
// Process
await processStripeEvent(event);
await markProcessed(event.id);
return new Response('ok', { status: 200 });
}
Three things to never skip:
Use the raw body. Body parsing destroys the signature.
Catch and 400 on invalid signature. Don't 500 (the provider will retry).
Idempotency by event ID. Stripe explicitly recommends this.
Signature Verification: PayPal
PayPal is more involved. You need to fetch their cert and verify the signature chain.
import { verifyPayPalWebhook } from './paypal-verify';
export async function POST(req: Request) {
const headers = {
'paypal-transmission-id': req.headers.get('paypal-transmission-id')!,
'paypal-transmission-time': req.headers.get('paypal-transmission-time')!,
'paypal-transmission-sig': req.headers.get('paypal-transmission-sig')!,
'paypal-cert-url': req.headers.get('paypal-cert-url')!,
'paypal-auth-algo': req.headers.get('paypal-auth-algo')!,
};
const body = await req.text();
const valid = await verifyPayPalWebhook({
headers,
body,
webhookId: process.env.PAYPAL_WEBHOOK_ID!,
});
if (!valid) return new Response('invalid', { status: 400 });
// ... process
return new Response('ok');
}
Use PayPal's official SDK if at all possible — implementing the verification yourself is error-prone.
Common Stripe Webhook Bugs to Test For
Missing idempotency. Same payment_intent.succeeded arrives twice, you mark the order paid twice and ship twice.
Out-of-order events. charge.refunded arrives before charge.succeeded. Your handler crashes on missing parent.
Test events processed in production. Always check event.livemode.
Body parsed before signature verified. Signature fails 100% of the time.
Slow 2xx. Stripe retries; you process duplicates.
Treating payment_intent.processing as success. It's not. Wait for succeeded.
Refund webhooks not handled. Customer disputes a charge, your DB still shows it paid.
Common PayPal Webhook Bugs
Signature verification skipped because "it's hard." Don't. Use the SDK.
Event types not mapped to product events. PayPal sends PAYMENT.CAPTURE.COMPLETED for what Stripe calls payment_intent.succeeded. Different vocabulary, same concept.
Sandbox vs live confusion. Both modes use similar URLs; check the credentials, not the URL.
Multiple webhook subscriptions firing same event twice. Audit your subscription list.
What to Test Manually
Some tests are easier to run manually in test mode than to automate:
Card decline flows. Trigger with Stripe's 4000000000000002 test card.
3DS / SCA flows. Trigger with 4000002500003155.
Dispute creation. Stripe lets you simulate disputes in the dashboard.
PayPal sandbox checkout. Use sandbox buyer accounts.
For each, capture the full webhook sequence in YoBox and turn into a test fixture.
End-to-End with Email
Most payment flows also send an email to the customer (receipt, confirmation). To test the full flow:
Sign up a test user with a YoBox Temp Mail address.
Trigger payment in Stripe test mode.
Wait for the receipt email in the temp inbox.
Wait for the webhook to fire (capture in YoBox Webhook Tester or your real handler).
Assert on database state, email body, and webhook payload.
See "Email Testing Guide for Developers" and "Webhook Testing Complete Guide" for the patterns.
FAQ
Can I test Stripe webhooks without ngrok?
Yes — use the Stripe CLI's stripe listen for local, or deploy to a staging URL. For inspection, YoBox Webhook Tester.
Why is my Stripe signature always failing?
99% of the time: body is being parsed before verification. Use raw body.
Does PayPal sign webhooks?
Yes — it's just more complex than Stripe's HMAC. Use the official SDK.
Should I unit test or integration test payment webhooks?
Both. Unit tests with fixtures for handler logic; integration tests in Stripe test mode for the full flow.
Can I replay a Stripe webhook?
Yes — from the Stripe dashboard webhook log, click "Resend." Useful for replaying after fixing handler bugs.
Bottom Line
Payment webhooks deserve more test coverage than they usually get. Inspect the real payloads with YoBox Webhook Tester, save fixtures, unit test the handler, end-to-end test in provider test mode, and never skip signature verification or idempotency. The cost of getting this right once is way lower than the cost of refunding 1,000 customers because of a duplicate-event bug.
YoBox Team
Builder behind YoBox — a privacy-first toolbox for developers and QA engineers covering disposable email, webhook capture, regex, secure passwords, Docker, and end-to-end testing.
Top comments (6)
Layer 2 has a blind spot: a webhook endpoint carries its own pinned API version, set separately from the one your SDK uses. Your fixtures freeze at whatever that endpoint was pinned to on the day you captured them, so when someone bumps the version in the dashboard the payload shape shifts underneath you and every fixture test stays green. Nothing goes red until production. You already read
Stripe-Versionin layer 1, so the missing step is asserting on it: record the version next to the fixtures and fail the suite when the live endpoint stops matching.The idempotency test proves dedupe on
event.id. The costlier bug is two different events describing the same state change, or related events arriving out of order, since delivery order isn't guaranteed. That one wants a fixture pair.That’s a really good catch. I was treating
Stripe-Versionas a Layer 1 concern, but you’re right that the endpoint’s pinned version creates a separate source of fixture drift.Recording the expected API version alongside each fixture and failing when the live endpoint no longer matches is a much better guardrail than simply asserting the payload shape.
And agreed on idempotency — testing the same
event.idtwice only covers duplicate delivery. A fixture pair representing two different events for the same state transition, delivered out of order, would catch a much nastier class of production bugs.I’m adding both to the checklist. The second one especially deserves its own test case.
One thing on the out-of-order case: assert that the final state is identical regardless of arrival order, not that the handler processed them in the right sequence. The order-agnostic assertion forces the fix instead of just detecting the bug, since the only way to pass it is to apply an event when the state it carries is newer than what you have already applied and drop it otherwise. That guard also covers duplicate delivery for free, because a replayed event is just an event carrying state you have already applied. Enforcing ordering at the queue is the other option and it costs a lot more.
That's a great distinction. I was thinking in terms of detecting out-of-order delivery, but you're right—the better test is to assert that the final state is identical regardless of arrival order.
I also like the way that naturally generalizes duplicate handling. Instead of treating duplicates as a separate problem, they become just another case of receiving state that's already been applied.
That's a much cleaner mental model than trying to enforce ordering everywhere. I'll update that section to reflect the state-based approach rather than focusing on event order itself.
One thing worth covering in the rewrite: the "apply it if it's newer" guard needs something to compare, and Stripe's
createdis a unix timestamp at second granularity, so two events touching the same object inside the same second tie and the guard has nothing to order them by. There's no monotonic per-object version on the event to fall back on either. The way out is to treat the event as a signal only, re-fetch the object by id, and apply whatever the API hands back, since that's current truth and arrival order stops mattering at all. Costs an extra API call per event, which is usually the trade you want.That’s an important caveat, and it changes the recommendation quite a bit.
createdlooks useful as an ordering signal until you hit two events for the same object within the same second, where there simply isn’t enough information to establish an order.I like the “webhook as a signal, API as source of truth” model much better here. Fetching the object by ID makes the handler converge on current state instead of trying to reconstruct state from delivery order.
The extra API call is a real trade-off, but for state-changing webhooks it seems much safer than building ordering guarantees that Stripe doesn't actually provide. I’ll make that distinction explicit in the rewrite.