Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion examples/event_notification_handler_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,17 @@ def fallback_callback(
client = StripeClient(api_key)
handler = client.notification_handler(webhook_secret, fallback_callback)

# Handles events delivered through a channel that has already authenticated them, such as
# AWS EventBridge or Azure Event Grid. Those payloads carry no Stripe-Signature header.
unverified_handler = client.notification_handler_without_verification(
fallback_callback
)

# can be anywhere in your codebase

# can be anywhere in your codebase; registering on both handlers means either
# endpoint below will route this event type
@handler.on_v1_billing_meter_error_report_triggered
@unverified_handler.on_v1_billing_meter_error_report_triggered
def handle_meter_error(
notif: V1BillingMeterErrorReportTriggeredEventNotification,
client: StripeClient,
Expand All @@ -55,3 +63,13 @@ def webhook():
return jsonify(success=True), 200
except Exception as e:
return jsonify(error=str(e)), 500


@app.route("/webhook-from-cloud-provider", methods=["POST"])
def webhook_from_cloud_provider():
# no signature header to pass along; the channel already authenticated this event
try:
unverified_handler.handle(request.data)
return jsonify(success=True), 200
except Exception as e:
return jsonify(error=str(e)), 500
57 changes: 50 additions & 7 deletions stripe/_event_notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,28 +298,32 @@ class UnhandledNotificationDetails:
"""


class StripeEventNotificationHandler:
class _StripeEventNotificationHandlerWithoutVerification:
def __init__(
self,
client: "StripeClient",
webhook_secret: str,
fallback_callback: FallbackCallback,
) -> None:
self._registered_handlers = {}
self._client = client
self._webhook_secret = webhook_secret
self.fallback_callback = fallback_callback
# once this is true, adding additional handlers results in an error
self._has_handled_events = False

def handle(self, webhook_body: str, sig_header: str):
# isn't thread-safe, but we expect these to get registered synchronously at startup
def handle(self, webhook_body: str):
# modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup
# making a race condition here unlikely
self._has_handled_events = True

event_notif = self._client.parse_event_notification(
webhook_body, sig_header, self._webhook_secret
event_notif = (
self._client.parse_event_notification_without_verification(
webhook_body
)
)

self._dispatch(event_notif)

def _dispatch(self, event_notif: "EventNotification"):
# Create a new client with the event's context.
# This is thread-safe since we're not modifying the original client.
# The new client reuses the HTTP client to avoid TLS handshake overhead.
Expand Down Expand Up @@ -1485,3 +1489,42 @@ def on_v2_orchestrated_commerce_agreement_terminated(
return func

# event-notification-registration-methods: The end of the section generated from our OpenAPI spec


class StripeEventNotificationHandler(
_StripeEventNotificationHandlerWithoutVerification
):
"""
A more on-rails experience for handling Stripe event notifications. Define callbacks for individual event types and an instance of this class will be responsible for verifying and routing the event.
"""

def __init__(
self,
client: "StripeClient",
webhook_secret: str,
fallback_callback: FallbackCallback,
) -> None:
super().__init__(client, fallback_callback)
if not webhook_secret:
raise ValueError("webhook_secret must be a non-empty string")
self._webhook_secret = webhook_secret

def handle(self, webhook_body: str, sig_header: str):
# modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup
# making a race condition here unlikely
self._has_handled_events = True

event_notif = self._client.parse_event_notification(
webhook_body, sig_header, self._webhook_secret
)

self._dispatch(event_notif)

@staticmethod
def without_verification(
client: "StripeClient",
fallback_callback: FallbackCallback,
) -> "_StripeEventNotificationHandlerWithoutVerification":
return _StripeEventNotificationHandlerWithoutVerification(
client, fallback_callback
)
14 changes: 14 additions & 0 deletions stripe/_stripe_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from stripe._error import AuthenticationError
from stripe._event_notification_handler import (
StripeEventNotificationHandler,
_StripeEventNotificationHandlerWithoutVerification,
FallbackCallback,
)
from stripe._request_options import extract_options_from_dict
Expand Down Expand Up @@ -379,6 +380,19 @@ def notification_handler(
self, webhook_secret, fallback_callback
)

def notification_handler_without_verification(
self, fallback_callback: FallbackCallback
) -> _StripeEventNotificationHandlerWithoutVerification:
"""
A variant of StripeEventNotificationHandler that parses events without
verifying webhook signatures. Intended for pre-authenticated channels
like AWS EventBridge, Azure Event Grid, or your own queue system that
verifies payloads before storage.
"""
return StripeEventNotificationHandler.without_verification(
self, fallback_callback
)

# deprecated v1 services: The beginning of the section generated from our OpenAPI spec
@property
@deprecated(
Expand Down
232 changes: 232 additions & 0 deletions tests/test_event_notification_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,235 @@ def rand_int(notif, client):
return 4

assert rand_int(None, None) == 4 # type: ignore

def test_rejects_empty_webhook_secret(
self, stripe_client: StripeClient, fallback_callback: Mock
) -> None:
"""Test that the constructor rejects an empty webhook secret"""
with pytest.raises(
ValueError, match="webhook_secret must be a non-empty string"
):
StripeEventNotificationHandler(
client=stripe_client,
webhook_secret="",
fallback_callback=fallback_callback,
)

def test_rejects_none_webhook_secret(
self, stripe_client: StripeClient, fallback_callback: Mock
) -> None:
"""Test that the constructor rejects a None webhook secret"""
with pytest.raises(
ValueError, match="webhook_secret must be a non-empty string"
):
StripeEventNotificationHandler(
client=stripe_client,
webhook_secret=None, # type: ignore
fallback_callback=fallback_callback,
)


class TestEventNotificationHandlerWithoutVerification:
@pytest.fixture(scope="function")
def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient:
return StripeClient(
api_key="sk_test_1234",
stripe_context=StripeContext.parse("original_context_123"),
http_client=http_client_mock.get_mock_http_client(),
)

@pytest.fixture(scope="function")
def fallback_callback(self) -> Mock:
return Mock()

@pytest.fixture(scope="function")
def handler_without_verification(
self, stripe_client: StripeClient, fallback_callback: Mock
):
return StripeEventNotificationHandler.without_verification(
client=stripe_client,
fallback_callback=fallback_callback,
)

@pytest.fixture(scope="function")
def v1_billing_meter_payload(self) -> str:
return json.dumps(
{
"id": "evt_123",
"object": "v2.core.event",
"type": "v1.billing.meter.error_report_triggered",
"livemode": False,
"created": "2022-02-15T00:27:45.330Z",
"context": "event_context_456",
"related_object": {
"id": "mtr_123",
"type": "billing.meter",
"url": "/v1/billing/meters/mtr_123",
},
}
)

@pytest.fixture(scope="function")
def unknown_event_payload(self) -> str:
return json.dumps(
{
"id": "evt_unknown",
"object": "v2.core.event",
"type": "llama.created",
"livemode": False,
"created": "2022-02-15T00:27:45.330Z",
"context": "event_context_unknown",
"related_object": {
"id": "llama_123",
"type": "llama",
"url": "/v1/llamas/llama_123",
},
}
)

def test_routes_event_to_registered_handler(
self,
handler_without_verification,
v1_billing_meter_payload: str,
fallback_callback: Mock,
) -> None:
handler = Mock()
handler_without_verification.on_v1_billing_meter_error_report_triggered(
handler
)

handler_without_verification.handle(v1_billing_meter_payload)

handler.assert_called_once()
call_args = handler.call_args[0]
assert isinstance(
call_args[0], V1BillingMeterErrorReportTriggeredEventNotification
)
fallback_callback.assert_not_called()

def test_handle_takes_single_argument(
self,
handler_without_verification,
v1_billing_meter_payload: str,
) -> None:
handler = Mock()
handler_without_verification.on_v1_billing_meter_error_report_triggered(
handler
)

# No signature needed - just the payload
handler_without_verification.handle(v1_billing_meter_payload)

handler.assert_called_once()

def test_fallback_receives_unregistered_events(
self,
handler_without_verification,
v1_billing_meter_payload: str,
fallback_callback: Mock,
) -> None:
handler_without_verification.handle(v1_billing_meter_payload)

fallback_callback.assert_called_once()
info = fallback_callback.call_args[0][2]
assert isinstance(info, UnhandledNotificationDetails)
assert info.is_known_event_type is True

def test_unknown_event_has_is_known_event_type_false(
self,
handler_without_verification,
unknown_event_payload: str,
fallback_callback: Mock,
) -> None:
handler_without_verification.handle(unknown_event_payload)

fallback_callback.assert_called_once()
info = fallback_callback.call_args[0][2]
assert info.is_known_event_type is False

def test_context_propagation(
self,
handler_without_verification,
v1_billing_meter_payload: str,
stripe_client: StripeClient,
) -> None:
received_context = None

def handler(event, client):
nonlocal received_context
received_context = client._requestor._options.stripe_context

handler_without_verification.on_v1_billing_meter_error_report_triggered(
handler
)
handler_without_verification.handle(v1_billing_meter_payload)

assert str(received_context) == "event_context_456"
assert (
str(stripe_client._requestor._options.stripe_context)
== "original_context_123"
)

def test_static_factory(
self, stripe_client: StripeClient, fallback_callback: Mock
) -> None:
from stripe._event_notification_handler import (
_StripeEventNotificationHandlerWithoutVerification,
)

handler = StripeEventNotificationHandler.without_verification(
stripe_client, fallback_callback
)
assert isinstance(
handler, _StripeEventNotificationHandlerWithoutVerification
)

def test_client_factory(
self, stripe_client: StripeClient, fallback_callback: Mock
) -> None:
handler = stripe_client.notification_handler_without_verification(
fallback_callback
)
assert handler is not None
assert hasattr(handler, "handle")

def test_handles_cloud_provider_envelope(
self,
handler_without_verification,
) -> None:
"""Test that events wrapped in cloud provider envelopes are parsed correctly"""
inner_payload = {
"id": "evt_123",
"object": "v2.core.event",
"type": "v1.billing.meter.error_report_triggered",
"livemode": False,
"created": "2022-02-15T00:27:45.330Z",
"context": "event_context_456",
"related_object": {
"id": "mtr_123",
"type": "billing.meter",
"url": "/v1/billing/meters/mtr_123",
},
}
# AWS EventBridge envelope
eventbridge_payload = json.dumps(
{
"version": "0",
"id": "abc-123",
"source": "aws.partner/stripe.com/ed_xxx",
"detail-type": "event",
"detail": inner_payload,
}
)

handler = Mock()
handler_without_verification.on_v1_billing_meter_error_report_triggered(
handler
)
handler_without_verification.handle(eventbridge_payload)

handler.assert_called_once()
call_args = handler.call_args[0]
assert isinstance(
call_args[0], V1BillingMeterErrorReportTriggeredEventNotification
)
Loading