Save products you love by clicking the heart icon.
Practical guide to using Codeberg, the tea CLI, and Forgejo Actions for self-hosted CI/CD pipelines — including secrets management, action migrations, and common pitfalls.
Stripe makes payments easy to integrate. Testing those payments reliably is another matter entirely. A single missed edge case — an unpaid session recorded as a purchase, a webhook replayed twice, or a download URL controlled by the client — can cost you real money and real customer trust.
This guide covers the complete Stripe testing paradigm, distilled from building and hardening a production checkout system with digital downloads, physical orders, and subscription billing.
Stripe operates in two modes, toggled in the Dashboard:
Key insight: Your server code doesn't change between modes. The only difference is which API keys you use. This means your test infrastructure should mirror production exactly — same webhook handlers, same idempotency logic, same validation.
Test card numbers (like 4242424242424242) work in two scenarios:
sk_test_*) — always work, whether through API or Checkoutsk_live_*) — accepted by the API (session creation succeeds), but rejected by Stripe Checkout's hosted payment page at the point of paymentThis means you can create checkout sessions programmatically with live keys and test cards, but the customer-facing payment step will fail. For end-to-end testing, always use test keys.
Not all test cards are equal. Stripe provides a rich set of cards to simulate specific scenarios:
| Card Number | Scenario | Expected Result |
|---|---|---|
4242424242424242 | Standard Visa | Payment succeeds |
4000056655665556 | Visa debit | Payment succeeds |
5555555555554444 | Mastercard | Payment succeeds |
4000000000000002 | Generic decline | Payment declined |
4000000000009995 | Insufficient funds | Decline with insufficient_funds |
| Card Number | Scenario | Authentication |
|---|---|---|
4000002500003155 | 3DS2 required | Challenge with any code |
4000002760002762 | 3DS2 required | Frictionless auto-approve |
4000000000003220 | 3DS required, fails | Challenge always fails |
Pro tip: Test 3DS cards with payment_method_options[card][request_three_d_secure] = 'any' to ensure your flow handles authentication challenges.
| Card Number | Scenario | Why It Matters |
|---|---|---|
4000000000000069 | Expired card | Your error handling |
4000000000000127 | Incorrect CVC | Validation feedback |
4000000000000101 | Processing error | Retry logic |
4242424242424241 | Wrong checksum | Card validation |
4000020000000003 | Charge later (auth) | Async payments |
A typical Stripe checkout integration has four stages:
One of the most common bugs: the checkout.session.completed webhook fires for all checkout sessions — including unpaid ones. SEPA payments arrive with payment_status: "unpaid" initially. Subscription checkouts fire this event too.
// ❌ WRONG — records purchases for unpaid sessions
case "checkout.session.completed": {
const session = event.data.object;
// Missing: no check on payment_status or mode!
pushPurchase({ session_id: session.id, ... });
}
// ✅ CORRECT — only record completed payments
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode !== "payment" || session.payment_status !== "paid") {
logger.info(`Skipping non-payment: ${session.id}`);
break;
}
pushPurchase({ session_id: session.id, ... });
}
A sophisticated attack: an attacker buys the cheapest product but overrides downloadUrl in the checkout request to point at an expensive product's file. The flow:
The fix: resolve download paths server-side from content frontmatter, ignoring client metadata entirely.
// Server-side resolver — single source of truth
export async function resolveProductDownloadUrl(slug: string) {
const content = await getContent("products", slug);
return content?.frontmatter?.download_url || null;
}
// In webhook: resolve from content, not from client metadata
const downloadUrl = await resolveProductDownloadUrl(item.slug);
const token = signDownloadToken({ product_slug: slug, download_url: downloadUrl });
Webhook idempotency covers Stripe → you. The reverse direction needs equal care: your server → Stripe. Without an idempotency key, a client network retry (POST /api/stripe/create-checkout) creates two checkout sessions — and two potential purchases.
// Create checkout with an idempotency key derived from cart + user
const session = await stripe.checkout.sessions.create(
{ mode: "payment", ... },
{ idempotencyKey: `${userId}:${cartFingerprint}` }
);
The same key must return the same session (Stripe dedupes); a different key creates a new one. Your tests must cover: same key twice → one session; different key → separate sessions; a retried request after a network error → no duplicate purchase.
Mock Stripe's webhook construction and test your handler logic in isolation:
// tests/unit/api/stripe/webhook.test.ts
vi.mock("@/lib/stripe", () => ({
constructWebhookEvent: mockConstructEvent,
}));
it("records purchase for paid checkout", async () => {
mockConstructEvent.mockReturnValue({
type: "checkout.session.completed",
data: {
object: {
mode: "payment",
payment_status: "paid",
id: "cs_test_123",
metadata: { productName: "Test", productSlug: "test" },
customer_details: { email: "buyer@test.com" },
},
},
});
const res = await POST(mockRequest("{}", "sig_abc"));
expect(res._status).toBe(200);
expect(mockPurchases).toHaveLength(1);
expect(mockSendMail).toHaveBeenCalled();
});
Coverage targets for webhook tests:
| Test Case | Why |
|---|---|
| Standard single-product purchase | Happy path |
| Multi-item cart purchase | Array handling, UNIQUE constraint |
| Subscription checkout (mode=subscription) | Should be skipped |
| Unpaid session (payment_status=unpaid) | Should be skipped |
| Duplicate webhook delivery | Idempotency |
| Missing metadata fields | Graceful degradation |
| Corrupt items JSON | Error handling |
| Physical/print orders | Shipping notification email |
| Email send failure | Error doesn't crash handler |
| Retried API request (same idempotency-key) | Dedupe on the Stripe API side |
| Dispute created (charge.dispute.created) | Chargeback handling |
| Slow webhook response | Retry semantics, async processing |
Install the Stripe CLI and forward real webhook events to your local server:
# Install (macOS)
brew install stripe/stripe-cli/stripe
# Login (gets your test-mode API key)
stripe login
# Forward webhooks to localhost:3000
stripe listen --forward-to localhost:3000/api/stripe/webhook
# This prints your webhook signing secret (whsec_...)
# Add it to your .env.local:
# STRIPE_WEBHOOK_SECRET=whsec_xxx
Then trigger a test event:
# Trigger a checkout completion event
stripe trigger checkout.session.completed
# Trigger a payment failure
stripe trigger payment_intent.payment_failed
# Trigger a subscription event
stripe trigger customer.subscription.updated
Test the entire flow: checkout → webhook → download, including state management across calls:
it("checkout → webhook → download flow succeeds", async () => {
// 1. Create checkout
const checkout = await createCheckout({ priceId: "price_test", ... });
expect(checkout._data.checkoutURL).toBeDefined();
// 2. Fire webhook (simulates Stripe calling your server)
mockConstructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: { mode: "payment", payment_status: "paid", ... } },
});
const webhook = await webhookHandler(mockRequest({}, "sig"));
expect(webhook._status).toBe(200);
// 3. Download with signed token
const token = signDownloadToken({ session_id: "cs_123", ... });
mockRetrieve.mockResolvedValue({ payment_status: "paid" });
const download = await downloadHandler(mockDownloadRequest(token));
expect(download._status).toBe(200);
});
Stripe retries webhook deliveries when your endpoint does not return a fast 2xx, using exponential backoff that can span days. A handler doing slow work synchronously (sending emails, generating invoices, building downloads) risks timing out: Stripe retries, and now the same event is in flight twice. Two rules:
2xx fast after signature verification and validation — enqueue the actual work// ✅ Webhook: validate, acknowledge, enqueue
export async function POST(request: Request) {
const event = verifyAndParseBody(await request.text(), request.headers.get("stripe-signature")!);
await purchaseQueue.enqueue({ session_id: event.data.object.id }); // durable queue
return Response.json({ received: true }); // fast 200 — no Stripe retry
}
Test this contract explicitly: simulate Stripe-style redelivery (same event twice with the same id), assert the handler returns 200 both times, and assert the queue processes the event exactly once.
| Stage | Tests | Speed | Stripe Interaction |
|---|---|---|---|
| CI (PR) | Unit tests with mocks | ~20s | None (mocked) |
| Staging | Stripe CLI forwarding | ~2m | Real test-mode API |
| Production deploy | Smoke tests | ~10s | API-only, no payments |
| Manual | Full E2E with test card | ~5m | Full test-mode flow |
# .env.local (development)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# .env.production (production)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Never commit real keys. Use .env.local (gitignored) for development and a secrets manager for production.
The test matrix above is example-based: every case is hand-written and asserts one specific scenario. Example tests are the foundation, but they have a ceiling — "we've exhausted example-based tests; property-based/fuzz testing finds what examples miss." Webhook handlers are stateful and concurrency-prone: duplicate deliveries, out-of-order events, and races between parallel webhook calls are exactly the bugs that hand-written examples miss because their timing masks the race.
With fast-check (or Hypothesis in Python), assert invariants that must hold for any event the handler receives:
import { fc, it } from "fast-check";
// Invariant: no purchase is ever recorded for an unpaid or non-payment session
it("never records a purchase for non-payment sessions", () => {
fc.assert(
fc.property(
fc.record({
mode: fc.constantFrom("payment", "subscription", "setup"),
payment_status: fc.constantFrom("paid", "unpaid", "no_payment_required"),
id: fc.uuid(),
}),
(session) => {
const handled = handleWebhook({
type: "checkout.session.completed",
data: { object: session },
});
const expected = session.mode === "payment" && session.payment_status === "paid";
expect(handled.purchaseRecorded).toBe(expected);
},
),
);
});
// Invariant: idempotency — duplicate delivery never double-records
it("is idempotent under duplicate delivery", () => {
fc.assert(
fc.property(fc.uuid(), (sessionId) => {
const event = {
type: "checkout.session.completed",
data: { object: { id: sessionId, mode: "payment", payment_status: "paid" } },
};
handleWebhook(event);
handleWebhook(event); // Stripe can deliver twice — or 100x
expect(purchasesFor(sessionId)).toHaveLength(1);
}),
);
});
What this buys you: the mode/payment_status guard from the Critical Guard section stops being a remembered rule and becomes an enforced invariant. This is the quality principle that keeps paying off in production systems: a guard that exists but isn't enforced is worse than no guard — it creates false confidence.
A checkout session is a state machine: open → completed | expired | canceled, with payment_status transitioning independently (SEPA arrives unpaid first). Model the lifecycle, then let the framework drive random command sequences (create, complete, expire, duplicate-webhook) against both the model and your real handlers — the states must match after every step. This is what caught a real bug in practice: two concurrent webhook deliveries both passed the duplicate check, then both recorded a purchase. A serial test never sees the race; a stateful fuzz run does.
Add a seeded fuzz layer that throws everything at the handler:
invoice.paid arriving before checkout.session.completedEvery failure is shrunk to a minimal reproducer (fast-check does this automatically), and that reproducer becomes a regression test. The regression-per-fix rule is non-negotiable: a fuzz finding without a regression test is a bug you have seen once and will see again.
payment_status check or the signature verification and confirm the tests failAlways verify webhook signatures. Never trust raw webhook payloads.
import crypto from "crypto";
function constructWebhookEvent(body: string, signature: string) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
if (!webhookSecret) throw new Error("Not configured");
const elements = signature.split(",");
const timestamp = elements.find((e) => e.startsWith("t="))?.slice(2);
const sig = elements.find((e) => e.startsWith("v1="))?.slice(3);
if (!timestamp || !sig) throw new Error("Invalid signature format");
// Reject events older than 5 minutes (replay protection)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) throw new Error("Stale webhook");
const signedPayload = `${timestamp}.${body}`;
const expected = crypto.createHmac("sha256", webhookSecret).update(signedPayload).digest("hex");
if (sig !== expected) throw new Error("Signature mismatch");
return JSON.parse(body);
}
Webhooks can be delivered multiple times. Always check for duplicates:
// Before processing
const existing = loadPurchases();
if (existing.some((p) => p.session_id === session.id)) {
logger.info("Duplicate webhook — skipping");
break;
}
Rate-limit all Stripe-facing endpoints:
// Create checkout — external API call
const isRateLimited = createRateLimiter(60_000, 10); // 10 per minute per IP
Don't use Math.random() for OTPs. Use the Web Crypto API:
export function generateOTP(): string {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return String(100000 + (buf[0] % 900000));
}
Download tokens must be:
Stripe metadata values are limited to 500 characters. A cart with 20+ items can exceed this:
// ❌ Unbounded
metadata.items = JSON.stringify(items);
// ✅ Guard size
const itemsJson = JSON.stringify(items.slice(0, 20));
metadata.items = itemsJson.length > 490 ? itemsJson.slice(0, 487) + '..."' : itemsJson;
checkout.session.completed fires for BOTH payment and subscription checkouts. Your handler must check session.mode:
if (session.mode !== "payment") break; // subscriptions use customer.subscription.* events
customer.subscription.updated fires on any subscription change, including cancellation. Don't blindly clear canceled_at:
// ❌ Wrong — wipes cancellation date
canceled_at: undefined;
// ✅ Correct — preserve unless reactivated
canceled_at: sub.status === "active"
? undefined
: sub.canceled_at
? new Date(sub.canceled_at * 1000).toISOString()
: undefined;
If your rate limiter runs after request.json(), an attacker can exhaust your server's resources before being throttled:
// ❌ Wrong — body parsed before rate check
const body = await request.json();
if (isRateLimited(ip)) return 429;
// ✅ Correct — rate check before parsing
if (isRateLimited(ip)) return 429;
const body = await request.json();
In private browsing or when storage is blocked, localStorage.getItem() throws:
// ❌ Crashes in private browsing
const [email] = useState(() => localStorage.getItem(KEY));
// ✅ Safe
const [email] = useState(() => {
try {
return localStorage.getItem(KEY) || "";
} catch {
return "";
}
});
Run through this checklist before every release:
4242 succeeds end-to-end (Stripe CLI mode)4000000000000002 handled gracefullypayment_status: unpaid) is skippedmode: subscription) is skippedTesting Stripe integrations isn't optional — it's a security requirement. A single vulnerability in payment handling can lead to inventory theft, customer data exposure, or revenue loss.
The pattern is always the same: never trust client input, verify everything server-side, and test edge cases that Stripe's happy path documentation doesn't cover.
The investment in comprehensive testing pays for itself the first time a webhook is replayed, a card is declined, or a customer disputes a charge. Build the testing infrastructure once, run it on every commit, and ship payments with confidence.