Testing Stripe Webhooks: stripe-cli, Mock Events, and pytest

Testing Stripe Webhooks: stripe-cli, Mock Events, and pytest

Stripe webhooks are HTTP POST requests Stripe sends to your server when payment events occur. Testing them requires replaying signed events against your handler without depending on real Stripe infrastructure. This guide covers stripe-cli for local forwarding, constructing test payloads in pytest, verifying webhook signatures, and running webhook tests in CI.

Key Takeaways

Use stripe-cli to forward live Stripe events to localhost. stripe listen --forward-to localhost:8000/webhooks proxies real Stripe events from test mode to your local server, including the correct signature header.

Replay specific events without triggering real payments. stripe trigger payment_intent.succeeded fires a realistic event payload. Combine with --forward-to to test your handler end-to-end without creating real charges.

Verify webhook signatures in tests, not just in production. Your test should construct a properly signed payload using the webhook secret. Skipping signature verification in tests creates a false sense of security.

Test every event type your handler processes. If your handler branches on payment_intent.succeeded vs payment_intent.payment_failed, write a test for each branch. Don't assume the happy path covers everything.

Test idempotency explicitly. Stripe may deliver the same event twice. Your handler must process duplicates safely. Write a test that calls the handler twice with the same event ID and verify no duplicate records are created.

Why Webhook Testing Is Different

Webhooks are harder to test than REST endpoints because the caller is Stripe, not your test. You can't just call POST /webhooks with a random body — Stripe signs every payload with a secret, and your handler should reject requests with invalid signatures.

This creates a testing challenge: you need either a real Stripe connection or a way to construct valid signatures yourself.

The two standard approaches are:

  1. stripe-cli — forwards real Stripe test-mode events to your local server with valid signatures
  2. Construct signed payloads in tests — use the Stripe SDK's signing utility to build payloads your handler will accept

Both approaches are useful and complement each other.

stripe-cli Local Forwarding

Install stripe-cli

# macOS
brew install stripe/stripe-cli/stripe

<span class="hljs-comment"># Linux
wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_amd64.deb
<span class="hljs-built_in">sudo dpkg -i stripe_linux_amd64.deb

<span class="hljs-comment"># Windows
scoop install stripe

Login and forward events

stripe login
stripe listen --forward-to localhost:8000/webhooks

The CLI prints your webhook signing secret:

> Ready! You are using Stripe API Version [2024-04-10]. Your webhook signing secret is whsec_test_... (^C to quit)

Set this secret as STRIPE_WEBHOOK_SECRET in your local environment. Your handler uses this secret to verify signatures.

Trigger specific events

In a separate terminal, trigger events:

stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failed
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed

Each trigger fires a realistic payload that your handler receives. This is the fastest way to verify end-to-end behavior without creating real charges.

Replay past events

You can also replay events that happened in your Stripe dashboard:

stripe events resend evt_1234567890abcdef

This is useful when you're debugging a production webhook failure and want to reproduce it locally.

Testing Webhook Signatures in pytest

For automated tests, you don't want to rely on stripe-cli being running. Instead, construct signed payloads directly using the Stripe SDK.

Install dependencies

pip install stripe pytest

The signature construction pattern

Stripe computes the signature as an HMAC-SHA256 of {timestamp}.{payload} using your webhook secret. The Stripe SDK's stripe.WebhookSignature.generate_header utility does this for you:

import stripe
import json
import time

def make_stripe_event(event_type: str, data: dict, secret: str) -> tuple[bytes, str]:
    """Construct a signed Stripe webhook payload for testing."""
    event_payload = {
        "id": f"evt_test_{int(time.time())}",
        "object": "event",
        "type": event_type,
        "data": {"object": data},
        "livemode": False,
        "created": int(time.time()),
    }
    payload_bytes = json.dumps(event_payload).encode("utf-8")
    timestamp = int(time.time())
    signature = stripe.WebhookSignature.generate_header(
        payload_bytes, timestamp, secret
    )
    return payload_bytes, signature

Basic webhook handler test

# test_webhooks.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

WEBHOOK_SECRET = "whsec_test_secret_for_testing_only"

client = TestClient(app)


def test_payment_intent_succeeded():
    payload, sig = make_stripe_event(
        "payment_intent.succeeded",
        {
            "id": "pi_test_123",
            "amount": 5000,
            "currency": "usd",
            "status": "succeeded",
            "customer": "cus_test_456",
            "metadata": {"order_id": "order_789"},
        },
        WEBHOOK_SECRET,
    )
    response = client.post(
        "/webhooks/stripe",
        content=payload,
        headers={"stripe-signature": sig},
    )
    assert response.status_code == 200
    # Verify your handler's side effects
    # e.g., order was marked as paid in the database


def test_payment_intent_failed():
    payload, sig = make_stripe_event(
        "payment_intent.payment_failed",
        {
            "id": "pi_test_456",
            "amount": 5000,
            "currency": "usd",
            "status": "requires_payment_method",
            "last_payment_error": {
                "code": "card_declined",
                "message": "Your card was declined.",
            },
        },
        WEBHOOK_SECRET,
    )
    response = client.post(
        "/webhooks/stripe",
        content=payload,
        headers={"stripe-signature": sig},
    )
    assert response.status_code == 200
    # Verify failure side effects (e.g., order marked as failed, email queued)


def test_invalid_signature_rejected():
    payload = b'{"type": "payment_intent.succeeded"}'
    response = client.post(
        "/webhooks/stripe",
        content=payload,
        headers={"stripe-signature": "invalid_sig"},
    )
    assert response.status_code == 400

Testing with pytest fixtures

Structure larger test suites with a fixture for the test client and webhook secret:

# conftest.py
import pytest
import os
from fastapi.testclient import TestClient
from app.main import app

@pytest.fixture
def webhook_secret():
    return os.environ.get("STRIPE_WEBHOOK_SECRET", "whsec_test_secret")

@pytest.fixture
def client():
    return TestClient(app)

Testing Idempotency

Stripe may deliver the same event more than once, especially after retries. Your handler must be idempotent — processing the same event twice should not create duplicate records.

def test_webhook_idempotency(client, webhook_secret):
    payload, sig = make_stripe_event(
        "payment_intent.succeeded",
        {
            "id": "pi_test_idempotency",
            "amount": 5000,
            "currency": "usd",
            "status": "succeeded",
        },
        webhook_secret,
    )
    # First delivery
    response1 = client.post(
        "/webhooks/stripe",
        content=payload,
        headers={"stripe-signature": sig},
    )
    assert response1.status_code == 200

    # Second delivery (same event)
    response2 = client.post(
        "/webhooks/stripe",
        content=payload,
        headers={"stripe-signature": sig},
    )
    assert response2.status_code == 200

    # Verify only one record was created
    orders = get_orders_for_payment_intent("pi_test_idempotency")
    assert len(orders) == 1

The handler should check if the event ID has already been processed before doing work:

# Example idempotent handler (Django)
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except stripe.error.SignatureVerificationError:
        return HttpResponse(status=400)

    # Idempotency check
    if ProcessedWebhookEvent.objects.filter(event_id=event["id"]).exists():
        return HttpResponse(status=200)

    # Process the event
    if event["type"] == "payment_intent.succeeded":
        handle_payment_succeeded(event["data"]["object"])

    ProcessedWebhookEvent.objects.create(event_id=event["id"])
    return HttpResponse(status=200)

CI Integration

In CI, you don't have stripe-cli available. Use the construct-and-sign approach with an environment variable for the webhook secret:

# .github/workflows/test.yml
env:
  STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET_TEST }}

steps:
  - name: Run webhook tests
    run: pytest tests/test_webhooks.py -v

For the test secret, you can use any value — it just needs to match what your tests use to construct signatures and what your handler uses to verify them. In CI, use a dedicated test secret that's separate from the production webhook secret.

Common Mistakes

Not testing the failure path. Most teams test payment_intent.succeeded and consider webhooks done. But payment_intent.payment_failed, charge.dispute.created, and invoice.payment_failed are equally important — and often untested until a production incident.

Testing with stripe-mock instead of real signature verification. stripe-mock is useful for API call mocks, but it doesn't replicate webhook behavior. Use stripe-cli or construct signed payloads for webhook tests.

Sharing webhook secrets between environments. Each Stripe webhook endpoint has its own signing secret. Create a separate test endpoint in Stripe's dashboard (or in your .env) so production and development secrets don't collide.

Not returning 200 immediately. Stripe retries webhooks if your endpoint doesn't respond within 30 seconds. Your handler should return 200 quickly and process the event asynchronously if processing is slow. Test this by asserting response time as well as status code.

What to Test at Each Stage

Test type What it covers Tool
Unit test Handler logic (branching, side effects) pytest with constructed payloads
Integration test End-to-end with real DB writes pytest + test DB
Manual smoke test Full flow with real Stripe test mode stripe-cli
Idempotency test Duplicate event handling pytest, two identical requests
Signature rejection Invalid/missing signature pytest, bad header

Write unit and idempotency tests for every event type your handler processes. Run stripe-cli smoke tests when adding a new event type for the first time.

Summary

Stripe webhook testing comes down to two tools: stripe-cli for interactive local testing, and Stripe's signature construction utilities for automated tests. The key patterns are constructing properly signed payloads in pytest, testing every event branch including failures, verifying idempotency, and rejecting invalid signatures.

If you want continuous webhook monitoring after deployment — alerting when webhook delivery fails or your handler starts returning errors — tools like HelpMeTest can monitor your webhook endpoint and alert you before Stripe disables it.

Read more