Testing Stripe Payment Integration: Webhooks, Idempotency, and Error Scenarios

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_here

In 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"
    yield

Test 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.id

Testing 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_failed

This 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.

Read more