Python + pytest
Test webhooks and emails in pytest with the webhooks-cc package: assert on the webhooks your app sends, test your handler with signed requests without a network, check your retries, and read codes out of test emails.
Updated Nov 2026
This guide tests a Python app from both sides: the webhooks it sends out and the webhooks it receives. The examples use FastAPI, but nothing in them depends on it: the same calls work with Django, Flask or a plain function. The Python SDK reference lists every method.
Install the package
pip install webhooks-cc pytestInstalling webhooks-cc registers its pytest plugin, so the webhooks_cc and webhooks_cc_endpoint fixtures are available in every test without an import. Create an API key on your API keys page and set it as WHK_API_KEY. Tests that use the fixtures are skipped when the variable is not set, so the suite still passes on a machine without a key.
Test the webhooks your app sends
webhooks_cc_endpoint creates an endpoint named after the test and deletes it when the test ends, whether it passed or failed. Point your code at its URL, then wait for the request:
from webhooks_cc import match_all, match_header, match_method
from shop.notify import send_order_webhook
def test_sends_order_paid(webhooks_cc, webhooks_cc_endpoint):
send_order_webhook(webhooks_cc_endpoint.url, {"id": "ord_1", "total": 4999})
request = webhooks_cc.requests.wait_for(
webhooks_cc_endpoint.slug,
timeout="30s",
match=match_all(match_method("POST"), match_header("x-event", "order.paid")),
)
assert request.json() == {"id": "ord_1", "total": 4999}
assert request.headers["content-type"] == "application/json"wait_for polls until a matching request arrives and raises webhooks_cc.TimeoutError when the time runs out. A webhook that arrived before the wait started still counts. Write a timeout as a string such as "30s": a bare number is milliseconds. Matchers such as match_body_path("data.status", "paid") pick one request out of several.
Test your webhook handler without a network
Your handler is the other half. build_template_request builds a request the way Stripe, GitHub, Shopify or 31 other providers send it, with a real signature. It needs no API key and makes no network call, so you can post the request to your app's test client:
# shop/app.py
import json
import os
from fastapi import FastAPI, HTTPException, Request
from webhooks_cc import verify_signature
app = FastAPI()
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "whsec_test_secret")
paid_sessions: list[str] = []
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
body = await request.body() # the exact bytes, before any JSON parsing
if not verify_signature(
"stripe", body=body, headers=request.headers, secret=STRIPE_WEBHOOK_SECRET
):
raise HTTPException(status_code=400, detail="invalid signature")
event = json.loads(body)
if event["type"] == "checkout.session.completed":
paid_sessions.append(event["data"]["object"]["id"])
return {"received": True}# tests/test_stripe_handler.py
import pytest
from fastapi.testclient import TestClient
from webhooks_cc import build_template_request
from shop.app import STRIPE_WEBHOOK_SECRET, app, paid_sessions
api = TestClient(app)
URL = "http://testserver/webhooks/stripe"
CHECKOUT = {
"id": "evt_test_1",
"type": "checkout.session.completed",
"data": {"object": {"id": "cs_test_123", "amount_total": 2000}},
}
def deliver(built):
return api.post("/webhooks/stripe", content=built.body, headers=built.headers)
def test_records_a_paid_checkout():
built = build_template_request(
URL, provider="stripe", secret=STRIPE_WEBHOOK_SECRET, body=CHECKOUT
)
assert deliver(built).status_code == 200
assert "cs_test_123" in paid_sessions
@pytest.mark.parametrize("secret", ["whsec_wrong", "whsec_test_secretx"])
def test_refuses_another_secret(secret):
built = build_template_request(URL, provider="stripe", secret=secret, body=CHECKOUT)
assert deliver(built).status_code == 400
def test_refuses_a_tampered_body():
built = build_template_request(
URL, provider="stripe", secret=STRIPE_WEBHOOK_SECRET, body=CHECKOUT
)
tampered = built.body.replace('"amount_total":2000', '"amount_total":1')
response = api.post("/webhooks/stripe", content=tampered, headers=built.headers)
assert response.status_code == 400These tests use no fixtures, so they run without WHK_API_KEY. The signature is the real Stripe scheme: if your handler calls stripe.Webhook.construct_event instead of verify_signature, it accepts these requests unchanged. Leave out body and pass template="checkout.session.completed" to send the provider's sample payload instead. Django's Client and Flask's test_client() take the same body and headers.
To exercise a server that is actually running, on localhost or in a container, webhooks_cc.send_to(url, provider="stripe", secret=..., body=...) sends the same request over HTTP and returns the response.
Test your retries
An endpoint can answer with whatever you configure instead of a plain 200, so it can stand in for a receiver that is down. Make a fixture of your own with temporary_endpoint, which deletes the endpoint as webhooks_cc_endpoint does:
import pytest
from webhooks_cc import match_body_path, temporary_endpoint
@pytest.fixture
def failing_endpoint(webhooks_cc):
with temporary_endpoint(
webhooks_cc, "always 503", mock_response={"status": 503, "body": "", "headers": {}}
) as endpoint:
yield endpoint
def test_retries_a_failing_receiver(webhooks_cc, failing_endpoint):
send_order_webhook(failing_endpoint.url, {"id": "ord_2"}, attempts=3)
attempts = webhooks_cc.requests.wait_for_all(
failing_endpoint.slug, count=3, timeout="30s", match=match_body_path("id", "ord_2")
)
assert len(attempts) == 3wait_for_all returns the requests oldest first, so you can also check the gaps between received_at values (milliseconds) to test a backoff. A mock_response also takes delay in milliseconds, up to 30 seconds, to test your client's timeout. Response rules answer differently depending on the request, for example 503 twice and then 200.
Test the emails your app sends
Every endpoint also receives email at {slug}@mailhooks.cc. capture_email_during creates a temporary endpoint, passes its address to your code, waits for the email and deletes the endpoint:
from webhooks_cc import capture_email_during, extract_code, extract_link
from shop.auth import sign_up
def test_sends_a_confirmation_code(webhooks_cc):
[email] = capture_email_during(
webhooks_cc,
lambda address, endpoint: sign_up(email=address, password="correct horse"),
subject="Confirm your email",
timeout="60s",
)
assert email.email.from_[0].address == "no-reply@example.com"
assert len(extract_code(email)) == 6
assert "/confirm?token=" in extract_link(email)The codes and links are found on the server, the same ones the dashboard shows. Each email counts as one request on your plan. See Test email for tags, which let one endpoint serve a whole suite.
Run the suite in CI
Add the API key as a repository secret. A GitHub Actions job:
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
env:
WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- run: pip install -e . webhooks-cc pytest
- run: pytestPull requests from forks get no secrets, so their webhooks.cc tests are skipped while the handler tests still run. With pytest-xdist (pytest -n auto), each worker gets its own client and every test its own endpoint, so parallel tests never see each other's requests.
Tips
- Keep handler tests on
build_template_request: they are fast, need no key, and catch signature and parsing bugs. - Give every test its own endpoint (
webhooks_cc_endpointdoes) rather than sharing one, so a retry from one test cannot satisfy another. - The free plan's 50 requests a day cover a small suite; a paid plan suits a suite that runs on every push.
- Asserting on a signature your app sends?
verify_signature(provider, body=request.body, headers=request.headers, secret=...)checks a captured request.