Testing Stripe Payment Integration: Webhooks, Idempotency, and Error Scenarios
Stripe provides a test mode with real API behavior, test card numbers for different scenarios, and a CLI for webhook testing. Testing a payment integration requires covering happy-path charges, declined cards, 3D Secure flows, webhook receipt and idempotency, refunds, and subscription lifecycle events. This guide covers all of them.
Payment integrations are uniquely high-stakes: bugs mean lost revenue, double charges, failed refunds, or security incidents. Stripe's test environment is excellent — it behaves identically to production — but you still need to know how to use it effectively.
Setting Up the Test Environment
Never use production API keys in tests. Stripe test keys start with sk_test_ and pk_test_. Store them as environment variables:
# .env.test
STRIPE_SECRET_KEY=sk_test_your_test_key_here
STRIPE_PUBLISHABLE_KEY=pk_test_your_test_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_hereIn Python:
# conftest.py
import os
import pytest
import stripe
@pytest.fixture(autouse=True)
def stripe_test_config():
"""Configure Stripe with test keys for all tests."""
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
assert stripe.api_key.startswith("sk_test_"), \
"Tests must use Stripe test keys (sk_test_*), not production keys"
yieldTest Card Numbers
Stripe provides specific card numbers for different scenarios. Memorize these:
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 |
Successful charge |
4000 0000 0000 0002 |
Declined (card_declined) |
4000 0000 0000 9995 |
Declined (insufficient_funds) |
4000 0025 0000 3155 |
3D Secure required |
4000 0000 0000 0069 |
Expired card |
4000 0000 0000 0127 |
Incorrect CVC |
4000 0000 0000 0119 |
Processing error |
Use these in tests rather than hardcoding random card numbers.
Testing Successful Charges
import stripe
import pytest
def test_successful_charge():
"""Test a basic successful charge."""
# Create a payment method with the success test card
payment_method = stripe.PaymentMethod.create(
type="card",
card={
"number": "4242424242424242",
"exp_month": 12,
"exp_year": 2030,
"cvc": "123"
}
)
# Create a payment intent
intent = stripe.PaymentIntent.create(
amount=1999, # $19.99 in cents
currency="usd",
payment_method=payment_method.id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"}
)
assert intent.status == "succeeded"
assert intent.amount == 1999
assert intent.currency == "usd"
def test_charge_creates_receipt_email():
"""Verify receipt email configuration is set correctly."""
payment_method = stripe.PaymentMethod.create(
type="card",
card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2030, "cvc": "123"}
)
intent = stripe.PaymentIntent.create(
amount=5000,
currency="usd",
payment_method=payment_method.id,
receipt_email="customer@example.com",
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"}
)
assert intent.status == "succeeded"
assert intent.receipt_email == "customer@example.com"Testing Declined Cards
@pytest.mark.parametrize("card_number,expected_decline_code", [
("4000000000000002", "card_declined"),
("4000000000009995", "insufficient_funds"),
("4000000000000069", "expired_card"),
("4000000000000127", "incorrect_cvc"),
])
def test_card_decline_scenarios(card_number, expected_decline_code):
"""Verify declined cards are handled correctly."""
payment_method = stripe.PaymentMethod.create(
type="card",
card={"number": card_number, "exp_month": 12, "exp_year": 2030, "cvc": "123"}
)
with pytest.raises(stripe.error.CardError) as exc_info:
stripe.PaymentIntent.create(
amount=2000,
currency="usd",
payment_method=payment_method.id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"}
)
error = exc_info.value
assert error.code == expected_decline_code, \
f"Expected decline code {expected_decline_code}, got {error.code}"
# Verify payment intent is in failed state
payment_intent_id = error.error.payment_intent.id
intent = stripe.PaymentIntent.retrieve(payment_intent_id)
assert intent.status == "requires_payment_method"Testing Webhook Receipt
Webhooks are the most important part of a payment integration — they're how you know a payment actually succeeded in Stripe's systems. Test them thoroughly.
First, the webhook handler:
# app/webhooks.py
import stripe
import os
def handle_webhook(payload: bytes, sig_header: str) -> dict:
"""Verify and handle a Stripe webhook."""
webhook_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
try:
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
except stripe.error.SignatureVerificationError:
raise ValueError("Invalid webhook signature")
if event.type == "payment_intent.succeeded":
handle_payment_succeeded(event.data.object)
elif event.type == "payment_intent.payment_failed":
handle_payment_failed(event.data.object)
elif event.type == "customer.subscription.deleted":
handle_subscription_cancelled(event.data.object)
return {"received": True}Test the webhook handler with real-looking payloads:
import json
import time
import hmac
import hashlib
from app.webhooks import handle_webhook
def create_test_webhook_payload(event_type: str, data: dict) -> tuple[bytes, str]:
"""Create a signed webhook payload for testing."""
webhook_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
timestamp = int(time.time())
payload = json.dumps({
"id": f"evt_test_{timestamp}",
"object": "event",
"api_version": "2024-06-20",
"type": event_type,
"data": {"object": data},
"created": timestamp
}).encode("utf-8")
# Create Stripe signature
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
signature = hmac.new(
webhook_secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
sig_header = f"t={timestamp},v1={signature}"
return payload, sig_header
def test_webhook_payment_succeeded():
"""Test that payment.succeeded webhook triggers fulfillment."""
payment_intent_data = {
"id": "pi_test_12345",
"object": "payment_intent",
"amount": 2999,
"currency": "usd",
"status": "succeeded",
"metadata": {"order_id": "order_abc123"}
}
payload, sig_header = create_test_webhook_payload(
"payment_intent.succeeded",
payment_intent_data
)
result = handle_webhook(payload, sig_header)
assert result["received"] is True
# Verify the order was fulfilled
order = get_order("order_abc123")
assert order.status == "fulfilled"
def test_webhook_rejects_invalid_signature():
"""Verify webhook signature validation works."""
payload = b'{"type": "payment_intent.succeeded"}'
bad_sig_header = "t=1234567890,v1=invalid_signature"
with pytest.raises(ValueError, match="Invalid webhook signature"):
handle_webhook(payload, bad_sig_header)
def test_webhook_rejects_replay_attack():
"""Verify old webhooks (replay attacks) are rejected."""
payment_intent_data = {
"id": "pi_test_old",
"object": "payment_intent",
"amount": 100,
"currency": "usd",
"status": "succeeded"
}
# Create payload with timestamp 10 minutes ago (Stripe rejects >5 min old)
old_timestamp = int(time.time()) - 700
payload = json.dumps({
"id": "evt_test_old",
"type": "payment_intent.succeeded",
"data": {"object": payment_intent_data},
"created": old_timestamp
}).encode("utf-8")
webhook_secret = os.environ["STRIPE_WEBHOOK_SECRET"]
signed_payload = f"{old_timestamp}.{payload.decode('utf-8')}"
signature = hmac.new(
webhook_secret.encode("utf-8"),
signed_payload.encode("utf-8"),
hashlib.sha256
).hexdigest()
sig_header = f"t={old_timestamp},v1={signature}"
with pytest.raises(stripe.error.SignatureVerificationError):
stripe.Webhook.construct_event(payload, sig_header, webhook_secret)Testing Idempotency
Idempotency keys ensure that retrying a failed request doesn't create duplicate charges. This is critical for payment systems:
def test_idempotency_key_prevents_duplicate_charge():
"""Same idempotency key must produce same PaymentIntent, not a new one."""
payment_method = stripe.PaymentMethod.create(
type="card",
card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2030, "cvc": "123"}
)
idempotency_key = f"order-{123}-charge"
create_params = {
"amount": 5000,
"currency": "usd",
"payment_method": payment_method.id,
"automatic_payment_methods": {"enabled": True, "allow_redirects": "never"}
}
# First request
intent1 = stripe.PaymentIntent.create(
**create_params,
idempotency_key=idempotency_key
)
# Second request with same idempotency key
intent2 = stripe.PaymentIntent.create(
**create_params,
idempotency_key=idempotency_key
)
# Must be the same PaymentIntent
assert intent1.id == intent2.id, \
"Duplicate request with same idempotency key created a second PaymentIntent"
def test_different_idempotency_keys_create_separate_intents():
"""Different idempotency keys must create separate PaymentIntents."""
payment_method = stripe.PaymentMethod.create(
type="card",
card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2030, "cvc": "123"}
)
params = {
"amount": 5000,
"currency": "usd",
"payment_method": payment_method.id,
"automatic_payment_methods": {"enabled": True, "allow_redirects": "never"}
}
intent1 = stripe.PaymentIntent.create(**params, idempotency_key="key-1")
intent2 = stripe.PaymentIntent.create(**params, idempotency_key="key-2")
assert intent1.id != intent2.idTesting Refunds
def test_full_refund():
"""Test that a full refund reverses the charge."""
payment_method = stripe.PaymentMethod.create(
type="card",
card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2030, "cvc": "123"}
)
intent = stripe.PaymentIntent.create(
amount=3000,
currency="usd",
payment_method=payment_method.id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"}
)
assert intent.status == "succeeded"
refund = stripe.Refund.create(payment_intent=intent.id)
assert refund.status == "succeeded"
assert refund.amount == 3000
def test_partial_refund():
"""Test partial refund leaves remainder charged."""
payment_method = stripe.PaymentMethod.create(
type="card",
card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2030, "cvc": "123"}
)
intent = stripe.PaymentIntent.create(
amount=10000, # $100.00
currency="usd",
payment_method=payment_method.id,
confirm=True,
automatic_payment_methods={"enabled": True, "allow_redirects": "never"}
)
refund = stripe.Refund.create(payment_intent=intent.id, amount=3000) # Refund $30
assert refund.amount == 3000
assert refund.status == "succeeded"Testing Subscriptions
def test_subscription_creation_and_cancellation():
"""Test full subscription lifecycle."""
# Create a customer
customer = stripe.Customer.create(
email="test@example.com",
payment_method=stripe.PaymentMethod.create(
type="card",
card={"number": "4242424242424242", "exp_month": 12, "exp_year": 2030, "cvc": "123"}
).id
)
# Set default payment method
stripe.Customer.modify(
customer.id,
invoice_settings={"default_payment_method": customer.default_source or "pm_test"}
)
# Create a subscription (requires an existing Price ID)
# Use a test price created in your Stripe test dashboard
price_id = os.environ.get("STRIPE_TEST_PRICE_ID", "price_test_monthly")
subscription = stripe.Subscription.create(
customer=customer.id,
items=[{"price": price_id}],
payment_behavior="default_incomplete"
)
assert subscription.status in ("active", "incomplete")
# Cancel immediately
cancelled = stripe.Subscription.cancel(subscription.id)
assert cancelled.status == "canceled"
# Cleanup
stripe.Customer.delete(customer.id)CI/CD Integration
# .github/workflows/stripe-tests.yml
name: Stripe Integration Tests
on:
push:
paths:
- 'app/payments/**'
- 'app/webhooks.py'
pull_request:
paths:
- 'app/payments/**'
jobs:
stripe-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install stripe pytest python-dotenv
- run: pytest tests/stripe/ -v
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_TEST_WEBHOOK_SECRET }}Using Stripe CLI for Webhook Testing
The Stripe CLI forwards webhooks to your local server during development:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
<span class="hljs-comment"># Login with your Stripe account
stripe login
<span class="hljs-comment"># Forward webhooks to your local server
stripe listen --forward-to localhost:8000/webhooks/stripe
<span class="hljs-comment"># In another terminal, trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failedThis lets you test your webhook handler locally without deploying.
Conclusion
Testing Stripe integrations requires covering the full payment lifecycle: successful charges, all decline scenarios, webhook receipt with signature validation, idempotency key behavior, refunds, and subscription events. Stripe's test environment is production-equivalent, so tests against it give you real confidence. Build these tests before you go live — payment bugs in production are painful, expensive, and sometimes irreversible.