diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 1d4331827..b65b38505 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -33,6 +33,9 @@
- Virtual env managed in `.venv/`; `just` recipes handle setup automatically
- Work is not complete until `just test`, `just lint` and `just typecheck` complete successfully.
- All code must run on all supported Python versions (full list in the test section of @.github/workflows/ci.yml)
+- In test files, prefer pytest fixtures over module-level declarations
+- Prefer f-strings over `.format` in new code.
+ - When editing a method, update any `.format` calls to use f-strings.
### Comments
diff --git a/stripe/__init__.py b/stripe/__init__.py
index d72c2420b..1b2a62977 100644
--- a/stripe/__init__.py
+++ b/stripe/__init__.py
@@ -139,6 +139,10 @@ def add_beta_version(
api_version = f"{api_version}; {beta_name}={beta_version}"
+from stripe._util import _emit_claude_code_hint
+
+_emit_claude_code_hint()
+
# The beginning of the section generated from our OpenAPI spec
from importlib import import_module
diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py
index 9db25a2af..ced9776fd 100644
--- a/stripe/_stripe_client.py
+++ b/stripe/_stripe_client.py
@@ -1,8 +1,5 @@
# -*- coding: utf-8 -*-
-import json
-from collections import OrderedDict
-
from stripe import (
DEFAULT_API_BASE,
DEFAULT_CONNECT_API_BASE,
@@ -27,7 +24,11 @@
from stripe._stripe_object import StripeObject
from stripe._stripe_response import StripeResponse
from stripe._util import _convert_to_stripe_object, get_api_mode
-from stripe._webhook import Webhook, WebhookSignature
+from stripe._webhook import (
+ Webhook,
+ WebhookSignature,
+ maybe_extract_from_cloud_provider_envelope,
+)
from stripe._event import Event
from stripe.v2.core._event import EventNotification
@@ -238,6 +239,31 @@ def __init__(
self.v2 = V2Services(self._requestor)
# top-level services: The end of the section generated from our OpenAPI spec
+ def construct_event(
+ self,
+ payload: Union[bytes, str],
+ sig_header: str,
+ secret: str,
+ tolerance: int = Webhook.DEFAULT_TOLERANCE,
+ ) -> Event:
+ """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `construct_event_without_verification`."""
+ return Webhook.construct_event(
+ payload,
+ sig_header,
+ secret,
+ tolerance,
+ api_requestor=self._requestor,
+ )
+
+ def construct_event_without_verification(
+ self,
+ payload: Union[bytes, str],
+ ) -> Event:
+ """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook without first verifying its authenticity. Should be used after calling `Webhook.verify_header(...)` or with input from a trusted source (such as [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge), or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) payload). Or, to verify & construct in a single call, use `Webhook.construct_event(...)` instead."""
+ return Webhook.construct_event_without_verification(
+ payload, api_requestor=self._requestor
+ )
+
def parse_event_notification(
self,
raw: Union[bytes, str, bytearray],
@@ -245,11 +271,7 @@ def parse_event_notification(
secret: str,
tolerance: int = Webhook.DEFAULT_TOLERANCE,
) -> "ALL_EVENT_NOTIFICATIONS":
- """
- This should be your main method for interacting with `EventNotifications`. It's the V2 equivalent of `construct_event()`, but with better typing support.
-
- It returns a union representing all known `EventNotification` classes. They have a `type` property that can be used for narrowing, which will get you very specific type support. If parsing an event the SDK isn't familiar with, it'll instead return `UnknownEventNotification`. That's not reflected in the return type of the function (because it messes up type narrowing) but is otherwise intended.
- """
+ """Constructs a [thin event notification](https://docs.stripe.com/event-destinations#thin-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `parse_event_notification_without_verification`."""
payload = (
cast(Union[bytes, bytearray], raw).decode("utf-8")
if hasattr(raw, "decode")
@@ -263,30 +285,18 @@ def parse_event_notification(
EventNotification.from_json(payload, self),
)
- def construct_event(
+ def parse_event_notification_without_verification(
self,
payload: Union[bytes, str],
- sig_header: str,
- secret: str,
- tolerance: int = Webhook.DEFAULT_TOLERANCE,
- ) -> Event:
- if hasattr(payload, "decode"):
- payload = cast(bytes, payload).decode("utf-8")
-
- WebhookSignature.verify_header(payload, sig_header, secret, tolerance)
+ ) -> "ALL_EVENT_NOTIFICATIONS":
+ """Constructs a [thin event notification](https://docs.stripe.com/event-destinations#thin-payload) from an incoming webhook without first verifying its authenticity. Should be used after calling `Webhook.verify_header(...)` or with input from a trusted source (such as [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge), or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) payload). Or, to verify & parse in a single call, use `parse_event_notification(...)` instead."""
- data = json.loads(payload, object_pairs_hook=OrderedDict)
- event = Event._construct_from(
- values=data,
- requestor=self._requestor,
- api_mode="V1",
+ return cast(
+ "ALL_EVENT_NOTIFICATIONS",
+ EventNotification.from_json(
+ maybe_extract_from_cloud_provider_envelope(payload), self
+ ),
)
- if event.object == "v2.core.event": # type: ignore
- raise ValueError(
- "You passed a thin event notification to StripeClient.construct_event, which expects a webhook payload. Use StripeClient.parse_event_notification instead."
- )
-
- return event
def raw_request(self, method_: str, url_: str, **params):
params = params.copy()
diff --git a/stripe/_util.py b/stripe/_util.py
index 5a17b3932..386894080 100644
--- a/stripe/_util.py
+++ b/stripe/_util.py
@@ -389,3 +389,20 @@ def _wrapper(*args, **kwargs):
return class_method(*args, **kwargs)
return _wrapper
+
+
+# If we detect environment variables that indicate we're running in a Claude Code
+# session, emit a hint to the parent process so it can load the plugin.
+# https://code.claude.com/docs/en/plugin-hints
+def claude_code_hint_line() -> str:
+ return '\n'
+
+
+def _emit_claude_code_hint() -> None:
+ try:
+ if os.environ.get("CLAUDECODE") or os.environ.get(
+ "CLAUDE_CODE_CHILD_SESSION"
+ ):
+ sys.stderr.write(claude_code_hint_line())
+ except Exception:
+ pass
diff --git a/stripe/_webhook.py b/stripe/_webhook.py
index fcead1260..592ee512e 100644
--- a/stripe/_webhook.py
+++ b/stripe/_webhook.py
@@ -3,6 +3,7 @@
import time
from collections import OrderedDict
from hashlib import sha256
+from typing import Any, Dict, Optional, Union
# Used for global variables
import stripe # noqa: IMP101
@@ -12,39 +13,93 @@
from stripe._api_requestor import _APIRequestor
+def build_v1_event(values: Dict[str, Any], requestor: _APIRequestor) -> Event:
+ """
+ Internal helper for centralizing v1 event creation
+ """
+ if values.get("object") == "v2.core.event":
+ raise ValueError(
+ "You passed a thin event notification to a method that expects a webhook body. Use the corresponding parse_event_notification* method instead."
+ )
+ return Event._construct_from(
+ values=values, requestor=requestor, api_mode="V1"
+ )
+
+
+def maybe_extract_from_cloud_provider_envelope(
+ payload: Union[bytes, str],
+):
+ """
+ Internal helper to extract the inner type from a cloud provider envelope (regardless of what's in there).
+ If the payload is already a raw Stripe event (object is 'event' or 'v2.core.event'), returns the parsed dict as-is.
+ """
+ if isinstance(payload, bytes):
+ payload = payload.decode("utf-8")
+
+ data = json.loads(payload, object_pairs_hook=OrderedDict)
+
+ # could add as many checks as we want here, but we'll start simple
+ if detail := data.get("detail"):
+ # AWS
+ # https://docs.stripe.com/event-destinations/eventbridge#event-structure
+ return detail
+ elif "specversion" in data and (data_ := data.get("data")):
+ # Azure
+ # https://docs.stripe.com/event-destinations/eventgrid#event-structure
+ return data_
+ elif data.get("object") in ("event", "v2.core.event"):
+ # Raw Stripe event passed directly: pass through as-is
+ return data
+
+ raise ValueError(
+ "Unrecognized event format. The payload must be an AWS EventBridge/Azure Event Grid event envelope or a Stripe webhook (thin event notification or snapshot)."
+ )
+
+
class Webhook(object):
DEFAULT_TOLERANCE = 300
@staticmethod
def construct_event(
- payload, sig_header, secret, tolerance=DEFAULT_TOLERANCE, api_key=None
+ payload: Union[bytes, str],
+ sig_header: str,
+ secret: str,
+ tolerance: int = DEFAULT_TOLERANCE,
+ api_key: Optional[str] = None,
+ api_requestor: Optional[_APIRequestor] = None,
):
- if hasattr(payload, "decode"):
+ """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook after verifying its authenticity. To work with a webhook that has already been verified (i.e. one from a cloud provider, an asynchronous queue, or during testing), see `construct_event_without_verification`."""
+ if isinstance(payload, (bytes, bytearray)):
payload = payload.decode("utf-8")
WebhookSignature.verify_header(payload, sig_header, secret, tolerance)
- data = json.loads(payload, object_pairs_hook=OrderedDict)
- event = Event._construct_from(
- values=data,
- requestor=_APIRequestor._global_with_options(
+ return build_v1_event(
+ json.loads(payload, object_pairs_hook=OrderedDict),
+ api_requestor
+ or _APIRequestor._global_with_options(
api_key=api_key or stripe.api_key
),
- api_mode="V1",
)
- if event.object == "v2.core.event": # type: ignore
- raise ValueError(
- "You passed a thin event notification to Webhook.construct_event, which expects a webhook payload. Use StripeClient.parse_event_notification instead."
- )
- return event
+ @staticmethod
+ def construct_event_without_verification(
+ payload: Union[bytes, str],
+ api_requestor: Optional[_APIRequestor] = None,
+ ):
+ """Constructs a [snapshot event](https://docs.stripe.com/event-destinations#snapshot-payload) from an incoming webhook without first verifying its authenticity. Should be used after calling `WebhookSignature.verify_header(...)` or with input from a trusted source (such as [AWS EventBridge](https://docs.stripe.com/event-destinations/eventbridge), or [Azure Event Grid](https://docs.stripe.com/event-destinations/eventgrid) payload). Or, to verify & construct in a single call, use `Webhook.construct_event(...)` instead."""
+ return build_v1_event(
+ maybe_extract_from_cloud_provider_envelope(payload),
+ api_requestor
+ or _APIRequestor._global_with_options(api_key=stripe.api_key),
+ )
class WebhookSignature(object):
EXPECTED_SCHEME = "v1"
@staticmethod
- def _compute_signature(payload, secret):
+ def _compute_signature(payload: str, secret: str) -> str:
mac = hmac.new(
secret.encode("utf-8"),
msg=payload.encode("utf-8"),
@@ -53,14 +108,33 @@ def _compute_signature(payload, secret):
return mac.hexdigest()
@staticmethod
- def _get_timestamp_and_signatures(header, scheme):
+ def _get_timestamp_and_signatures(header: str, scheme: str):
list_items = [i.split("=", 2) for i in header.split(",")]
timestamp = int([i[1] for i in list_items if i[0] == "t"][0])
signatures = [i[1] for i in list_items if i[0] == scheme]
return timestamp, signatures
@classmethod
- def verify_header(cls, payload, header, secret, tolerance=None):
+ def generate_signature_header(
+ cls, payload: str, secret: str, timestamp=None
+ ):
+ """Compute the `Stripe-Signature` header for a given webhook body & secret. Useful for signing payloads in unit tests."""
+ if timestamp is None:
+ timestamp = int(time.time())
+ scheme = cls.EXPECTED_SCHEME
+ signed_payload = f"{timestamp}.{payload}"
+ signature = cls._compute_signature(signed_payload, secret)
+ return f"t={timestamp},{scheme}={signature}"
+
+ @classmethod
+ def verify_header(
+ cls,
+ payload: Union[bytes, str],
+ header: str,
+ secret: str,
+ tolerance=None,
+ ):
+ """Verifies the authenticity (and recency) of a webhook, throwing a `SignatureVerificationError` if there's a mismatch. Useful for quickly validating incoming webhooks before storing them for later processing (at which time you can use the `*_without_verification` methods for parsing)."""
try:
timestamp, signatures = cls._get_timestamp_and_signatures(
header, cls.EXPECTED_SCHEME
diff --git a/stripe/v2/core/_event.py b/stripe/v2/core/_event.py
index 3818ced87..12b1c6993 100644
--- a/stripe/v2/core/_event.py
+++ b/stripe/v2/core/_event.py
@@ -3,10 +3,8 @@
# -*- coding: utf-8 -*-
import json
-from typing import Any, ClassVar, Dict, Optional, cast
-
+from typing import Any, ClassVar, Dict, Optional, cast, Union
# v2-event-imports: The beginning of the section generated from our OpenAPI spec
-from typing import Union
# v2-event-imports: The end of the section generated from our OpenAPI spec
from typing_extensions import Literal, TYPE_CHECKING
@@ -204,6 +202,10 @@ class EventNotification:
"""
Livemode indicates if the event is from a production(true) or test(false) account.
"""
+ object: str
+ """
+ String representing the object's type. Objects of the same type share the same value.
+ """
context: Optional[StripeContext] = None
"""
[Optional] Authentication context needed to fetch the event or related object.
@@ -217,6 +219,7 @@ def __init__(
self, parsed_body: Dict[str, Any], client: "StripeClient"
) -> None:
self.id = parsed_body["id"]
+ self.object = parsed_body["object"]
self.type = parsed_body["type"]
self.created = parsed_body["created"]
self.livemode = bool(parsed_body.get("livemode"))
@@ -230,17 +233,28 @@ def __init__(
self._client = client
@staticmethod
- def from_json(payload: str, client: "StripeClient") -> "EventNotification":
+ def from_json(
+ payload: Union[str, Dict[str, Any]], client: "StripeClient"
+ ) -> "EventNotification":
"""
Helper for constructing an Event Notification. Doesn't perform signature validation, so you
should use StripeClient.parse_event_notification() instead for initial handling.
- This is useful in unit tests and working with EventNotifications that you've already validated the authenticity of.
+ This is useful in unit tests and working with EventNotifications whose authenticity you've already validated.
"""
- parsed_body = json.loads(payload)
+ parsed_body = (
+ json.loads(payload) if isinstance(payload, str) else payload
+ )
if parsed_body.get("object") == "event":
raise ValueError(
"You passed a webhook payload to StripeClient.parse_event_notification, which expects a thin event notification. Use StripeClient.construct_event instead."
)
+ if (
+ parsed_body.get("object") is not None
+ and parsed_body.get("object") != "v2.core.event"
+ ):
+ raise ValueError(
+ f"Unexpected object type '{parsed_body.get('object')}'. Expected 'v2.core.event' for an event notification."
+ )
# circular import busting
from stripe.events._event_classes import (
diff --git a/tests/test_cloud_provider.py b/tests/test_cloud_provider.py
new file mode 100644
index 000000000..858cdd006
--- /dev/null
+++ b/tests/test_cloud_provider.py
@@ -0,0 +1,281 @@
+import json
+
+import pytest
+
+import stripe
+from stripe._webhook import Webhook
+from stripe.v2.core._event import EventNotification
+
+
+@pytest.fixture
+def client():
+ return stripe.StripeClient("sk_test_fake")
+
+
+@pytest.fixture
+def eventbridge_payload():
+ return json.dumps(
+ {
+ "version": "0",
+ "id": "17e8dff5-d6cd-3770-ace9-aeac02b6ac3f",
+ "detail-type": "customer.created",
+ "source": "aws.partner/stripe.com/ed_123",
+ "account": "506417113029",
+ "time": "2024-03-07T18:27:56Z",
+ "region": "us-west-2",
+ "resources": [],
+ "detail": {
+ "id": "evt_test_123",
+ "object": "event",
+ "api_version": "2023-10-16",
+ "created": 1709836076,
+ "data": {"object": {"id": "cus_123", "object": "customer"}},
+ "livemode": True,
+ "pending_webhooks": 0,
+ "request": {"id": "req_123", "idempotency_key": None},
+ "type": "customer.created",
+ },
+ }
+ )
+
+
+@pytest.fixture
+def eventgrid_payload():
+ return json.dumps(
+ {
+ "specversion": "1.0",
+ "type": "customer.created",
+ "source": "/providers/stripe/ed_test_123",
+ "id": "9aeb0fdf-c01e-0131-0922-9eb54906e209",
+ "time": "2025-07-11T14:30:00Z",
+ "subject": None,
+ "dataContentType": "application/cloudevents+json",
+ "data": {
+ "id": "evt_test_456",
+ "object": "event",
+ "api_version": "2023-10-16",
+ "created": 1709836076,
+ "data": {"object": {"id": "cus_456", "object": "customer"}},
+ "livemode": False,
+ "pending_webhooks": 0,
+ "request": {"id": "req_456", "idempotency_key": None},
+ "type": "customer.created",
+ },
+ }
+ )
+
+
+@pytest.fixture
+def eventbridge_notification_payload():
+ return json.dumps(
+ {
+ "version": "0",
+ "id": "17e8dff5-d6cd-3770-ace9-aeac02b6ac3f",
+ "detail-type": "v2.core.event_destination.ping",
+ "source": "aws.partner/stripe.com/ed_123",
+ "account": "506417113029",
+ "time": "2024-03-07T18:27:56Z",
+ "region": "us-west-2",
+ "resources": [],
+ "detail": {
+ "id": "evt_test_789",
+ "object": "v2.core.event",
+ "type": "v2.core.event_destination.ping",
+ "created": "2024-03-07T18:27:56.000Z",
+ "context": "acct_123",
+ "livemode": True,
+ "related_object": {
+ "id": "ed_123",
+ "type": "v2.core.event_destination",
+ "url": "/v2/core/event_destinations/ed_123",
+ },
+ },
+ }
+ )
+
+
+@pytest.fixture
+def eventgrid_notification_payload():
+ return json.dumps(
+ {
+ "specversion": "1.0",
+ "type": "v2.core.event_destination.ping",
+ "source": "/providers/stripe/ed_test_123",
+ "id": "9aeb0fdf-c01e-0131-0922-9eb54906e209",
+ "time": "2025-07-11T14:30:00Z",
+ "data": {
+ "id": "evt_test_790",
+ "object": "v2.core.event",
+ "type": "v2.core.event_destination.ping",
+ "created": "2024-03-07T18:27:56.000Z",
+ "context": "acct_123",
+ "livemode": True,
+ "related_object": {
+ "id": "ed_test_123",
+ "type": "v2.core.event_destination",
+ "url": "/v2/core/event_destinations/ed_test_123",
+ },
+ },
+ }
+ )
+
+
+class TestConstructEventWithoutVerification:
+ def test_eventbridge(self, client, eventbridge_payload):
+ result = client.construct_event_without_verification(
+ eventbridge_payload
+ )
+ assert isinstance(result, stripe.Event)
+ assert result.id == "evt_test_123"
+ assert result.type == "customer.created"
+
+ def test_eventgrid(self, client, eventgrid_payload):
+ result = client.construct_event_without_verification(eventgrid_payload)
+ assert isinstance(result, stripe.Event)
+ assert result.id == "evt_test_456"
+ assert result.type == "customer.created"
+
+ def test_raw_event_passthrough(self, client):
+ raw_event = json.dumps(
+ {
+ "id": "evt_test_123",
+ "object": "event",
+ "type": "customer.created",
+ }
+ )
+ result = client.construct_event_without_verification(raw_event)
+ assert isinstance(result, stripe.Event)
+ assert result.id == "evt_test_123"
+ assert result.type == "customer.created"
+
+ def test_invalid_json(self, client):
+ with pytest.raises(json.JSONDecodeError):
+ client.construct_event_without_verification("not valid json")
+
+ def test_thin_event_suggests_parse_event_notification_without_verification(
+ self, client, eventbridge_notification_payload
+ ):
+ with pytest.raises(ValueError, match="parse_event_notification"):
+ client.construct_event_without_verification(
+ eventbridge_notification_payload
+ )
+
+ def test_unrecognized_format(self, client):
+ with pytest.raises(ValueError, match="Unrecognized event format"):
+ client.construct_event_without_verification(
+ json.dumps({"foo": "bar"})
+ )
+
+ def test_azure_envelope_missing_data_field(self, client):
+ payload = json.dumps(
+ {
+ "specversion": "1.0",
+ "type": "customer.created",
+ "source": "/providers/stripe/ed_test_123",
+ "id": "test-missing-data",
+ }
+ )
+ with pytest.raises(ValueError, match="Unrecognized event format"):
+ client.construct_event_without_verification(payload)
+
+ def test_webhook_static_method_eventbridge(self, eventbridge_payload):
+ result = Webhook.construct_event_without_verification(
+ eventbridge_payload
+ )
+ assert isinstance(result, stripe.Event)
+ assert result.id == "evt_test_123"
+ assert result.type == "customer.created"
+
+
+class TestParseEventNotificationWithoutVerification:
+ def test_eventbridge(self, client, eventbridge_notification_payload):
+ result = client.parse_event_notification_without_verification(
+ eventbridge_notification_payload
+ )
+ assert result.id == "evt_test_789"
+ assert result.type == "v2.core.event_destination.ping"
+
+ def test_eventgrid(self, client, eventgrid_notification_payload):
+ result = client.parse_event_notification_without_verification(
+ eventgrid_notification_payload
+ )
+ assert result.id == "evt_test_790"
+ assert result.type == "v2.core.event_destination.ping"
+
+ def test_v1_event_suggests_construct_event_without_verification(
+ self, client, eventbridge_payload
+ ):
+ with pytest.raises(ValueError, match="construct_event"):
+ client.parse_event_notification_without_verification(
+ eventbridge_payload
+ )
+
+ def test_invalid_json(self, client):
+ with pytest.raises(json.JSONDecodeError):
+ client.parse_event_notification_without_verification(
+ "not valid json"
+ )
+
+ def test_unrecognized_format(self, client):
+ with pytest.raises(ValueError, match="Unrecognized event format"):
+ client.parse_event_notification_without_verification(
+ json.dumps({"foo": "bar"})
+ )
+
+ def test_azure_envelope_missing_data_field(self, client):
+ payload = json.dumps(
+ {
+ "specversion": "1.0",
+ "type": "v2.core.event_destination.ping",
+ "source": "/providers/stripe/ed_test_123",
+ "id": "test-missing-data",
+ }
+ )
+ with pytest.raises(ValueError, match="Unrecognized event format"):
+ client.parse_event_notification_without_verification(payload)
+
+ def test_unexpected_object_type_in_event_notification(self, client):
+ # Wrap in an EventBridge envelope so envelope extraction succeeds,
+ # letting the object-type guard inside from_json fire.
+ payload = json.dumps(
+ {
+ "version": "0",
+ "id": "17e8dff5-d6cd-3770-ace9-aeac02b6ac3f",
+ "detail-type": "customer.created",
+ "source": "aws.partner/stripe.com/ed_123",
+ "account": "506417113029",
+ "time": "2024-03-07T18:27:56Z",
+ "region": "us-west-2",
+ "resources": [],
+ "detail": {
+ "object": "customer",
+ "type": "customer.created",
+ "id": "cus_123",
+ },
+ }
+ )
+ with pytest.raises(ValueError, match="Unexpected object type"):
+ client.parse_event_notification_without_verification(payload)
+
+ def test_raw_event_notification_passthrough(self, client):
+ raw_notification = json.dumps(
+ {
+ "id": "evt_234",
+ "object": "v2.core.event",
+ "type": "v2.core.event_destination.ping",
+ "created": "2022-02-15T00:27:45.330Z",
+ "livemode": True,
+ "context": "acct_123",
+ "related_object": {
+ "id": "ed_123",
+ "type": "v2.core.event_destination",
+ "url": "/v2/core/event_destinations/ed_123",
+ },
+ }
+ )
+ result = client.parse_event_notification_without_verification(
+ raw_notification
+ )
+ assert isinstance(result, EventNotification)
+ assert result.id == "evt_234"
+ assert result.type == "v2.core.event_destination.ping"
diff --git a/tests/test_exports.py b/tests/test_exports.py
index 44f11846d..3aff0a530 100644
--- a/tests/test_exports.py
+++ b/tests/test_exports.py
@@ -4,6 +4,8 @@
import subprocess
import sys
+from stripe._util import claude_code_hint_line
+
def assert_output(code: str, expected: str) -> None:
process = subprocess.Popen(
@@ -14,6 +16,7 @@ def assert_output(code: str, expected: str) -> None:
stdout, stderr = process.communicate()
+ stderr = stderr.replace(claude_code_hint_line().encode(), b"")
assert not stderr, f"Error: {stderr.decode()}"
output = stdout.decode().strip()
diff --git a/tests/test_integration.py b/tests/test_integration.py
index bf80daf9f..9572ff127 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -41,7 +41,7 @@ def __init__(self, handler: BaseHTTPRequestHandler):
class MyTestHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
- timeout = 30
+ timeout = 0.5
num_requests = 0
diff --git a/tests/test_stripe_context.py b/tests/test_stripe_context.py
index a06d16dd6..a0f40787f 100644
--- a/tests/test_stripe_context.py
+++ b/tests/test_stripe_context.py
@@ -156,6 +156,7 @@ def test_event_notification_parsing(self):
mock_client = Mock()
parsed_body = {
"id": "evt_123",
+ "object": "v2.core.event",
"type": "test.event",
"created": "2023-01-01T00:00:00Z",
"livemode": False,
@@ -171,6 +172,7 @@ def test_event_notification_no_context(self):
mock_client = Mock()
parsed_body = {
"id": "evt_123",
+ "object": "v2.core.event",
"type": "test.event",
"created": "2023-01-01T00:00:00Z",
"livemode": False,
@@ -184,6 +186,7 @@ def test_event_notification_empty_context(self):
mock_client = Mock()
parsed_body = {
"id": "evt_123",
+ "object": "v2.core.event",
"type": "test.event",
"created": "2023-01-01T00:00:00Z",
"livemode": False,
diff --git a/tests/test_util.py b/tests/test_util.py
index 1331dbe20..a8dc01c80 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -1,3 +1,5 @@
+import io
+import os
import sys
from collections import namedtuple
@@ -11,6 +13,8 @@
log_info,
log_debug,
sanitize_id,
+ claude_code_hint_line,
+ _emit_claude_code_hint,
)
from stripe import Balance
from stripe._api_mode import ApiMode
@@ -178,3 +182,36 @@ def test_sanitize_id(self):
)
def test_get_api_mode(self, url: str, expected: ApiMode):
assert get_api_mode(url) == expected
+
+
+class TestEmitClaudeCodeHint:
+ _HINT = claude_code_hint_line()
+
+ def _capture(self, env_vars: dict) -> str:
+ buf = io.StringIO()
+ original = os.environ.copy()
+ try:
+ for k in ("CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION"):
+ os.environ.pop(k, None)
+ os.environ.update(env_vars)
+ old_stderr, sys.stderr = sys.stderr, buf
+ try:
+ _emit_claude_code_hint()
+ finally:
+ sys.stderr = old_stderr
+ finally:
+ os.environ.clear()
+ os.environ.update(original)
+ return buf.getvalue()
+
+ def test_emits_when_CLAUDECODE_set(self):
+ assert self._capture({"CLAUDECODE": "1"}) == self._HINT
+
+ def test_emits_when_CLAUDE_CODE_CHILD_SESSION_set(self):
+ assert (
+ self._capture({"CLAUDE_CODE_CHILD_SESSION": "session-id"})
+ == self._HINT
+ )
+
+ def test_no_emit_without_env_vars(self):
+ assert self._capture({}) == ""
diff --git a/tests/test_v2_event.py b/tests/test_v2_event.py
index 9b8df8bf8..8ac40e40f 100644
--- a/tests/test_v2_event.py
+++ b/tests/test_v2_event.py
@@ -18,7 +18,8 @@
)
from stripe.v2.core._event import UnknownEventNotification
from stripe.events._event_classes import ALL_EVENT_NOTIFICATIONS
-from tests.test_webhook import DUMMY_WEBHOOK_SECRET, generate_header
+from stripe._webhook import WebhookSignature
+from tests.test_webhook import DUMMY_WEBHOOK_SECRET
EventParser = Callable[[str], ALL_EVENT_NOTIFICATIONS]
@@ -85,7 +86,11 @@ def parse_event_notif(self, stripe_client: StripeClient) -> EventParser:
def _parse_event_notif(payload: str):
return stripe_client.parse_event_notification(
- payload, generate_header(payload=payload), DUMMY_WEBHOOK_SECRET
+ payload,
+ WebhookSignature.generate_signature_header(
+ payload, DUMMY_WEBHOOK_SECRET
+ ),
+ DUMMY_WEBHOOK_SECRET,
)
return _parse_event_notif
@@ -99,6 +104,7 @@ def test_parses_event_notif(
notif, V1BillingMeterErrorReportTriggeredEventNotification
)
assert notif.id == "evt_234"
+ assert notif.object == "v2.core.event"
assert notif.related_object
assert notif.related_object.id == "mtr_123"
@@ -234,7 +240,9 @@ def test_v2_events_integration(
event_notif = stripe_client.parse_event_notification(
v2_payload_no_data,
- generate_header(payload=v2_payload_no_data),
+ WebhookSignature.generate_signature_header(
+ v2_payload_no_data, DUMMY_WEBHOOK_SECRET
+ ),
DUMMY_WEBHOOK_SECRET,
)
assert event_notif.type == "v1.billing.meter.error_report_triggered"
diff --git a/tests/test_webhook.py b/tests/test_webhook.py
index 5d3856e9b..fade53a01 100644
--- a/tests/test_webhook.py
+++ b/tests/test_webhook.py
@@ -4,6 +4,7 @@
import stripe
from stripe._error import SignatureVerificationError
+from stripe._webhook import WebhookSignature
DUMMY_WEBHOOK_PAYLOAD = """{
@@ -23,19 +24,40 @@
DUMMY_WEBHOOK_SECRET = "whsec_test_secret"
-def generate_header(**kwargs):
- timestamp = kwargs.get("timestamp", int(time.time()))
- payload = kwargs.get("payload", DUMMY_WEBHOOK_PAYLOAD)
- secret = kwargs.get("secret", DUMMY_WEBHOOK_SECRET)
- scheme = kwargs.get("scheme", stripe.WebhookSignature.EXPECTED_SCHEME)
- signature = kwargs.get("signature", None)
- if signature is None:
- payload_to_sign = "%d.%s" % (timestamp, payload)
- signature = stripe.WebhookSignature._compute_signature(
- payload_to_sign, secret
- )
- header = "t=%d,%s=%s" % (timestamp, scheme, signature)
- return header
+def generate_header(
+ payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET, timestamp=None
+):
+ """Thin wrapper around WebhookSignature.generate_signature_header for tests."""
+ return WebhookSignature.generate_signature_header(
+ payload, secret, timestamp
+ )
+
+
+def _build_header_with_scheme(
+ scheme,
+ payload=DUMMY_WEBHOOK_PAYLOAD,
+ secret=DUMMY_WEBHOOK_SECRET,
+ timestamp=None,
+):
+ """Build a header with a custom scheme, for testing scheme-mismatch error paths."""
+ if timestamp is None:
+ timestamp = int(time.time())
+ payload_to_sign = "%d.%s" % (timestamp, payload)
+ signature = WebhookSignature._compute_signature(payload_to_sign, secret)
+ return "t=%d,%s=%s" % (timestamp, scheme, signature)
+
+
+def _build_header_with_signature(
+ signature, payload=DUMMY_WEBHOOK_PAYLOAD, timestamp=None
+):
+ """Build a header with a pre-computed (possibly bad) signature, for testing signature-mismatch error paths."""
+ if timestamp is None:
+ timestamp = int(time.time())
+ return "t=%d,%s=%s" % (
+ timestamp,
+ WebhookSignature.EXPECTED_SCHEME,
+ signature,
+ )
class TestWebhook(object):
@@ -83,7 +105,7 @@ def test_raise_on_v2_payload(self):
stripe.Webhook.construct_event(
DUMMY_V2_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET
)
- assert "StripeClient.parse_event_notification" in str(e.value)
+ assert "parse_event_notification" in str(e.value)
class TestWebhookSignature(object):
@@ -98,7 +120,7 @@ def test_raise_on_malformed_header(self):
)
def test_raise_on_no_signatures_with_expected_scheme(self):
- header = generate_header(scheme="v0")
+ header = _build_header_with_scheme("v0")
with pytest.raises(
SignatureVerificationError,
match="No signatures found with expected scheme v1",
@@ -108,7 +130,7 @@ def test_raise_on_no_signatures_with_expected_scheme(self):
)
def test_raise_on_no_valid_signatures_for_payload(self):
- header = generate_header(signature="bad_signature")
+ header = _build_header_with_signature("bad_signature")
with pytest.raises(
SignatureVerificationError,
match="No signatures found matching the expected signature for payload",
@@ -142,6 +164,21 @@ def test_header_contains_valid_signature(self):
DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET, tolerance=10
)
+ def test_generate_signature_header(self):
+ timestamp = 1234567890
+ header = WebhookSignature.generate_signature_header(
+ DUMMY_WEBHOOK_PAYLOAD, DUMMY_WEBHOOK_SECRET, timestamp
+ )
+ # Header must follow the format t=,v1=
+ assert header.startswith("t=%d,v1=" % timestamp)
+ parts = dict(part.split("=", 1) for part in header.split(","))
+ assert parts["t"] == str(timestamp)
+ assert len(parts["v1"]) == 64 # SHA-256 hex digest is 64 chars
+ # The generated header must pass verification (no tolerance since timestamp is old)
+ assert WebhookSignature.verify_header(
+ DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET
+ )
+
def test_timestamp_off_but_no_tolerance(self):
header = generate_header(timestamp=12345)
assert stripe.WebhookSignature.verify_header(