From 482dd5d196cd43e799a658e045bbde549a1dcd6c Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Wed, 9 Sep 2026 16:31:03 -0300 Subject: [PATCH 1/2] feat(webhooks): add signing secret rotation endpoint Co-Authored-By: Claude Fable 5.1 --- examples/webhooks.py | 5 +++ resend/webhooks/_webhooks.py | 59 ++++++++++++++++++++++++++++++++++++ tests/webhooks_async_test.py | 25 +++++++++++++++ tests/webhooks_test.py | 24 +++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/examples/webhooks.py b/examples/webhooks.py index f34e56f..cde643c 100644 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -84,6 +84,11 @@ else: print("No webhook events available") +rotated: resend.Webhooks.RotateSigningSecretResponse = ( + resend.Webhooks.rotate_signing_secret(webhook["id"]) +) +print(f"Rotated signing secret: {rotated['signing_secret']}") + rm_webhook: resend.Webhooks.DeleteWebhookResponse = resend.Webhooks.remove( webhook_id=webhook["id"] ) diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index 4b5df39..3a64cb3 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -283,6 +283,29 @@ class DeleteWebhookResponse(BaseResponse): Whether the webhook was successfully deleted """ + class RotateSigningSecretResponse(BaseResponse): + """ + RotateSigningSecretResponse is the type that wraps the response of the webhook whose signing secret was rotated + + Attributes: + object (str): The object type, always "webhook" + id (str): The ID of the webhook + signing_secret (str): The new signing secret for webhook verification + """ + + object: str + """ + The object type, always "webhook" + """ + id: str + """ + The ID of the webhook + """ + signing_secret: str + """ + The new signing secret for webhook verification + """ + @classmethod def create(cls, params: CreateParams) -> CreateWebhookResponse: """ @@ -471,6 +494,23 @@ def remove(cls, webhook_id: str) -> DeleteWebhookResponse: ).perform_with_content() return resp + @classmethod + def rotate_signing_secret(cls, webhook_id: str) -> RotateSigningSecretResponse: + """ + Rotate the signing secret of a webhook. + see more: https://resend.com/docs/api-reference/webhooks/rotate-signing-secret + + Args: + webhook_id (str): The webhook ID + + Returns: + RotateSigningSecretResponse: The webhook with its new signing_secret + """ + path = f"/webhooks/{webhook_id}/signing-secret/rotate" + return request.Request[Webhooks.RotateSigningSecretResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + @classmethod def verify(cls, options: VerifyWebhookOptions) -> WebhookEventPayload: """ @@ -750,6 +790,25 @@ async def remove_async(cls, webhook_id: str) -> DeleteWebhookResponse: ).perform_with_content() return resp + @classmethod + async def rotate_signing_secret_async( + cls, webhook_id: str + ) -> RotateSigningSecretResponse: + """ + Rotate the signing secret of a webhook (async). + see more: https://resend.com/docs/api-reference/webhooks/rotate-signing-secret + + Args: + webhook_id (str): The webhook ID + + Returns: + RotateSigningSecretResponse: The webhook with its new signing_secret + """ + path = f"/webhooks/{webhook_id}/signing-secret/rotate" + return await AsyncRequest[Webhooks.RotateSigningSecretResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + @staticmethod def _generate_signature(secret: bytes, content: bytes) -> str: """ diff --git a/tests/webhooks_async_test.py b/tests/webhooks_async_test.py index beb6d9d..92724ed 100644 --- a/tests/webhooks_async_test.py +++ b/tests/webhooks_async_test.py @@ -220,6 +220,13 @@ async def test_should_remove_webhooks_async_raise_exception_when_no_content( with pytest.raises(NoContentError): _ = await resend.Webhooks.remove_async("wh_123") + async def test_rotate_signing_secret_async_raises_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Webhooks.rotate_signing_secret_async("wh_123") + class TestWebhooksRequestAsync: def setup_method(self) -> None: @@ -249,3 +256,21 @@ async def test_replay_event_async_posts_to_the_replay_path(self) -> None: kwargs["url"] == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" ) + + async def test_rotate_signing_secret_async_posts_to_the_rotate_path(self) -> None: + self.mock_client.request.return_value = ( + b'{"object": "webhook", "id": "wh_123", "signing_secret": "whsec_new"}', + 200, + {"content-type": "application/json"}, + ) + + webhook = await resend.Webhooks.rotate_signing_secret_async("wh_123") + + assert webhook["object"] == "webhook" + assert webhook["id"] == "wh_123" + assert webhook["signing_secret"] == "whsec_new" + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] == "https://api.resend.com/webhooks/wh_123/signing-secret/rotate" + ) diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index fd94d17..30fc0b9 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -179,6 +179,11 @@ def test_webhooks_remove(self) -> None: assert result["id"] == "wh_123" assert result["deleted"] is True + def test_rotate_signing_secret_raises_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = resend.Webhooks.rotate_signing_secret("wh_123") + class TestWebhooksRequest(TestCase): def setUp(self) -> None: @@ -210,6 +215,25 @@ def test_replay_event_posts_to_the_replay_path(self) -> None: == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" ) + def test_rotate_signing_secret_posts_to_the_rotate_path(self) -> None: + self.mock_client.request.return_value = ( + b'{"object": "webhook", "id": "wh_123", "signing_secret": "whsec_new"}', + 200, + {"Content-Type": "application/json"}, + ) + + webhook = resend.Webhooks.rotate_signing_secret("wh_123") + + assert webhook["object"] == "webhook" + assert webhook["id"] == "wh_123" + assert webhook["signing_secret"] == "whsec_new" + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] + == "https://api.resend.com/webhooks/wh_123/signing-secret/rotate" + ) + class TestWebhookVerification: """Test webhook signature verification""" From 99cb3b0e2b672328e02f0bb212981688878ace87 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 10 Sep 2026 09:42:12 -0300 Subject: [PATCH 2/2] chore: bump version to 2.44.0 Co-Authored-By: Claude Fable 5.1 --- resend/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resend/version.py b/resend/version.py index ddfd28c..6595822 100644 --- a/resend/version.py +++ b/resend/version.py @@ -1,4 +1,4 @@ -__version__ = "2.43.0" +__version__ = "2.44.0" def get_version() -> str: