From e0a7ca4fba282811c50f12b27a2a1ca147f86b98 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Wed, 13 May 2026 20:22:09 -0700 Subject: [PATCH 1/3] Add WebhookSignature.generate_test_header_string helper Exposes a public utility for generating signed Stripe-Signature header values for use in tests, mirroring stripe-node's generateTestHeaderString. Closes the need to depend on the underscore-prefixed _compute_signature internal when writing tests against webhook handling code. Fixes #1697 --- stripe/_webhook.py | 33 ++++++++++++++++++++ tests/test_webhook.py | 70 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/stripe/_webhook.py b/stripe/_webhook.py index fcead1260..2fde230f7 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 Optional # Used for global variables import stripe # noqa: IMP101 @@ -52,6 +53,38 @@ def _compute_signature(payload, secret): ) return mac.hexdigest() + @classmethod + def generate_test_header_string( + cls, + payload: str, + secret: str, + timestamp: Optional[int] = None, + scheme: Optional[str] = None, + signature: Optional[str] = None, + ) -> str: + """ + Generates a value for the `Stripe-Signature` header that can be used + when testing code that calls `Webhook.construct_event` or + `WebhookSignature.verify_header`. Mirrors `generateTestHeaderString` + from stripe-node. + + :param payload: The webhook payload to sign, as a string. + :param secret: The webhook signing secret (`whsec_...`). + :param timestamp: Unix timestamp to embed in the header. Defaults to + the current time. + :param scheme: Signature scheme. Defaults to `WebhookSignature.EXPECTED_SCHEME`. + :param signature: Pre-computed signature to embed in the header. If + omitted, a signature is computed from `payload` and `secret`. + """ + if timestamp is None: + timestamp = int(time.time()) + if scheme is None: + scheme = cls.EXPECTED_SCHEME + if signature is None: + signed_payload = "%d.%s" % (timestamp, payload) + signature = cls._compute_signature(signed_payload, secret) + return "t=%d,%s=%s" % (timestamp, scheme, signature) + @staticmethod def _get_timestamp_and_signatures(header, scheme): list_items = [i.split("=", 2) for i in header.split(",")] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 5d3856e9b..3f8f7058b 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -24,18 +24,13 @@ 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 + return stripe.WebhookSignature.generate_test_header_string( + payload=kwargs.get("payload", DUMMY_WEBHOOK_PAYLOAD), + secret=kwargs.get("secret", DUMMY_WEBHOOK_SECRET), + timestamp=kwargs.get("timestamp"), + scheme=kwargs.get("scheme"), + signature=kwargs.get("signature"), + ) class TestWebhook(object): @@ -149,6 +144,57 @@ def test_timestamp_off_but_no_tolerance(self): ) +class TestGenerateTestHeaderString(object): + def test_uses_defaults_when_optional_args_omitted(self): + before = int(time.time()) + header = stripe.WebhookSignature.generate_test_header_string( + payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET + ) + after = int(time.time()) + + parts = dict(item.split("=", 1) for item in header.split(",")) + assert before <= int(parts["t"]) <= after + assert "v1" in parts + + def test_header_verifies_round_trip(self): + header = stripe.WebhookSignature.generate_test_header_string( + payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET + ) + assert stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, + header, + DUMMY_WEBHOOK_SECRET, + tolerance=10, + ) + + def test_honors_custom_timestamp_and_scheme(self): + header = stripe.WebhookSignature.generate_test_header_string( + payload=DUMMY_WEBHOOK_PAYLOAD, + secret=DUMMY_WEBHOOK_SECRET, + timestamp=12345, + scheme="v0", + ) + assert header.startswith("t=12345,v0=") + + def test_uses_provided_signature_verbatim(self): + header = stripe.WebhookSignature.generate_test_header_string( + payload=DUMMY_WEBHOOK_PAYLOAD, + secret=DUMMY_WEBHOOK_SECRET, + timestamp=12345, + signature="deadbeef", + ) + assert header == "t=12345,v1=deadbeef" + + def test_bad_secret_fails_verification(self): + header = stripe.WebhookSignature.generate_test_header_string( + payload=DUMMY_WEBHOOK_PAYLOAD, secret="whsec_wrong" + ) + with pytest.raises(SignatureVerificationError): + stripe.WebhookSignature.verify_header( + DUMMY_WEBHOOK_PAYLOAD, header, DUMMY_WEBHOOK_SECRET + ) + + class TestStripeClientConstructEvent(object): def test_construct_event(self, stripe_mock_stripe_client): header = generate_header() From 44e57c722c44b65afcaab63ddab7bb2575491115 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Fri, 15 May 2026 06:54:56 -0700 Subject: [PATCH 2/3] Use Args: docstring style per review feedback Switch generate_test_header_string's docstring from mixed markdown/RST (`:param:`) to a markdown `Args:` block, addressing @Viicos's review comment on #1810. --- stripe/_webhook.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/stripe/_webhook.py b/stripe/_webhook.py index 2fde230f7..60fce1fb9 100644 --- a/stripe/_webhook.py +++ b/stripe/_webhook.py @@ -68,13 +68,15 @@ def generate_test_header_string( `WebhookSignature.verify_header`. Mirrors `generateTestHeaderString` from stripe-node. - :param payload: The webhook payload to sign, as a string. - :param secret: The webhook signing secret (`whsec_...`). - :param timestamp: Unix timestamp to embed in the header. Defaults to - the current time. - :param scheme: Signature scheme. Defaults to `WebhookSignature.EXPECTED_SCHEME`. - :param signature: Pre-computed signature to embed in the header. If - omitted, a signature is computed from `payload` and `secret`. + Args: + payload: The webhook payload to sign, as a string. + secret: The webhook signing secret (`whsec_...`). + timestamp: Unix timestamp to embed in the header. Defaults to + the current time. + scheme: Signature scheme. Defaults to + `WebhookSignature.EXPECTED_SCHEME`. + signature: Pre-computed signature to embed in the header. If + omitted, a signature is computed from `payload` and `secret`. """ if timestamp is None: timestamp = int(time.time()) From fa9d9dccaef9fccbd5994ba41b4665159d5ced87 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Tue, 26 May 2026 11:48:35 -0700 Subject: [PATCH 3/3] Move generate_test_header_string from WebhookSignature to Webhook Per @mbroshi-stripe's review on #1810, place the helper next to Webhook.construct_event (the thing it pairs with in test code) and match the placement used by stripe-node, stripe-go, and stripe-ruby. The implementation continues to delegate to WebhookSignature for the underlying signing primitive. --- stripe/_webhook.py | 33 +++++++++++++++++---------------- tests/test_webhook.py | 12 ++++++------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/stripe/_webhook.py b/stripe/_webhook.py index 60fce1fb9..8d4c6a3db 100644 --- a/stripe/_webhook.py +++ b/stripe/_webhook.py @@ -40,22 +40,8 @@ def construct_event( ) return event - -class WebhookSignature(object): - EXPECTED_SCHEME = "v1" - @staticmethod - def _compute_signature(payload, secret): - mac = hmac.new( - secret.encode("utf-8"), - msg=payload.encode("utf-8"), - digestmod=sha256, - ) - return mac.hexdigest() - - @classmethod def generate_test_header_string( - cls, payload: str, secret: str, timestamp: Optional[int] = None, @@ -81,12 +67,27 @@ def generate_test_header_string( if timestamp is None: timestamp = int(time.time()) if scheme is None: - scheme = cls.EXPECTED_SCHEME + scheme = WebhookSignature.EXPECTED_SCHEME if signature is None: signed_payload = "%d.%s" % (timestamp, payload) - signature = cls._compute_signature(signed_payload, secret) + signature = WebhookSignature._compute_signature( + signed_payload, secret + ) return "t=%d,%s=%s" % (timestamp, scheme, signature) + +class WebhookSignature(object): + EXPECTED_SCHEME = "v1" + + @staticmethod + def _compute_signature(payload, secret): + mac = hmac.new( + secret.encode("utf-8"), + msg=payload.encode("utf-8"), + digestmod=sha256, + ) + return mac.hexdigest() + @staticmethod def _get_timestamp_and_signatures(header, scheme): list_items = [i.split("=", 2) for i in header.split(",")] diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 3f8f7058b..83fef1eff 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -24,7 +24,7 @@ def generate_header(**kwargs): - return stripe.WebhookSignature.generate_test_header_string( + return stripe.Webhook.generate_test_header_string( payload=kwargs.get("payload", DUMMY_WEBHOOK_PAYLOAD), secret=kwargs.get("secret", DUMMY_WEBHOOK_SECRET), timestamp=kwargs.get("timestamp"), @@ -147,7 +147,7 @@ def test_timestamp_off_but_no_tolerance(self): class TestGenerateTestHeaderString(object): def test_uses_defaults_when_optional_args_omitted(self): before = int(time.time()) - header = stripe.WebhookSignature.generate_test_header_string( + header = stripe.Webhook.generate_test_header_string( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET ) after = int(time.time()) @@ -157,7 +157,7 @@ def test_uses_defaults_when_optional_args_omitted(self): assert "v1" in parts def test_header_verifies_round_trip(self): - header = stripe.WebhookSignature.generate_test_header_string( + header = stripe.Webhook.generate_test_header_string( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET ) assert stripe.WebhookSignature.verify_header( @@ -168,7 +168,7 @@ def test_header_verifies_round_trip(self): ) def test_honors_custom_timestamp_and_scheme(self): - header = stripe.WebhookSignature.generate_test_header_string( + header = stripe.Webhook.generate_test_header_string( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET, timestamp=12345, @@ -177,7 +177,7 @@ def test_honors_custom_timestamp_and_scheme(self): assert header.startswith("t=12345,v0=") def test_uses_provided_signature_verbatim(self): - header = stripe.WebhookSignature.generate_test_header_string( + header = stripe.Webhook.generate_test_header_string( payload=DUMMY_WEBHOOK_PAYLOAD, secret=DUMMY_WEBHOOK_SECRET, timestamp=12345, @@ -186,7 +186,7 @@ def test_uses_provided_signature_verbatim(self): assert header == "t=12345,v1=deadbeef" def test_bad_secret_fails_verification(self): - header = stripe.WebhookSignature.generate_test_header_string( + header = stripe.Webhook.generate_test_header_string( payload=DUMMY_WEBHOOK_PAYLOAD, secret="whsec_wrong" ) with pytest.raises(SignatureVerificationError):