From ab32b39f11c6f88bd8941520dd149ba704c97234 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 3 Sep 2026 09:35:26 -0300 Subject: [PATCH 1/6] feat(webhooks): add event replay endpoint --- examples/webhooks.py | 5 ++++ resend/webhooks/_webhooks.py | 48 ++++++++++++++++++++++++++++++++++++ tests/webhooks_async_test.py | 16 ++++++++++++ tests/webhooks_test.py | 25 +++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/examples/webhooks.py b/examples/webhooks.py index 2341b54..f34e56f 100644 --- a/examples/webhooks.py +++ b/examples/webhooks.py @@ -71,6 +71,11 @@ ) print(f"Retrieved webhook event: {webhook_event['id']}") + replayed: resend.Webhooks.ReplayEventResponse = resend.Webhooks.replay_event( + webhook["id"], event_id + ) + print(f"Replayed webhook event: {replayed['id']}") + attempts: resend.Webhooks.ListEventAttemptsResponse = ( resend.Webhooks.list_event_attempts(webhook["id"], event_id, {"limit": 10}) ) diff --git a/resend/webhooks/_webhooks.py b/resend/webhooks/_webhooks.py index c159b94..4b5df39 100644 --- a/resend/webhooks/_webhooks.py +++ b/resend/webhooks/_webhooks.py @@ -107,6 +107,16 @@ class GetEventResponse(BaseResponse): The webhook event payload. """ + class ReplayEventResponse(BaseResponse): + object: str + """ + The object type, always "webhook_event". + """ + id: str + """ + The webhook event ID. + """ + class WebhookEventAttempt(TypedDict): id: str """ @@ -399,6 +409,24 @@ def get_event(cls, webhook_id: str, event_id: str) -> GetEventResponse: path=path, params={}, verb="get" ).perform_with_content() + @classmethod + def replay_event(cls, webhook_id: str, event_id: str) -> ReplayEventResponse: + """ + Queue one more delivery of a webhook event to its webhook. + see more: https://resend.com/docs/api-reference/webhooks/replay-event + + Args: + webhook_id (str): The webhook ID + event_id (str): The webhook event ID + + Returns: + ReplayEventResponse: The replayed webhook event + """ + path = f"/webhooks/{webhook_id}/events/{event_id}/replay" + return request.Request[Webhooks.ReplayEventResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + @classmethod def list_event_attempts( cls, @@ -658,6 +686,26 @@ async def get_event_async(cls, webhook_id: str, event_id: str) -> GetEventRespon path=path, params={}, verb="get" ).perform_with_content() + @classmethod + async def replay_event_async( + cls, webhook_id: str, event_id: str + ) -> ReplayEventResponse: + """ + Queue one more delivery of a webhook event to its webhook (async). + see more: https://resend.com/docs/api-reference/webhooks/replay-event + + Args: + webhook_id (str): The webhook ID + event_id (str): The webhook event ID + + Returns: + ReplayEventResponse: The replayed webhook event + """ + path = f"/webhooks/{webhook_id}/events/{event_id}/replay" + return await AsyncRequest[Webhooks.ReplayEventResponse]( + path=path, params={}, verb="post" + ).perform_with_content() + @classmethod async def list_event_attempts_async( cls, diff --git a/tests/webhooks_async_test.py b/tests/webhooks_async_test.py index d615b7d..d362c65 100644 --- a/tests/webhooks_async_test.py +++ b/tests/webhooks_async_test.py @@ -160,6 +160,22 @@ async def test_webhooks_get_event_async(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) + async def test_webhooks_replay_event_async(self) -> None: + response = { + "object": "webhook_event", + "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2", + } + self.set_mock_json(response) + + event = await resend.Webhooks.replay_event_async( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + + assert event == response + self.mock.assert_awaited_once_with( + url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + ) + async def test_webhooks_list_event_attempts_async(self) -> None: response = { "object": "list", diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 351af02..9d55840 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -3,10 +3,12 @@ import time from hashlib import sha256 from typing import Any, Dict +from unittest.mock import create_autospec import pytest import resend +from resend.http_client import HTTPClient from tests.conftest import ResendBaseTest @@ -132,6 +134,29 @@ def test_webhooks_get_event(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) + def test_webhooks_replay_event(self) -> None: + self.patcher.stop() + client = create_autospec(HTTPClient, instance=True) + client.request.return_value = ( + b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', + 200, + {"Content-Type": "application/json"}, + ) + resend.default_http_client = client + + event = resend.Webhooks.replay_event( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + + assert event["object"] == "webhook_event" + assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + _, kwargs = client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] + == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + ) + def test_webhooks_list_event_attempts(self) -> None: response = { "object": "list", From 44161113a9c33fbf8884cc25e56d3823ef88c218 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 3 Sep 2026 09:37:36 -0300 Subject: [PATCH 2/6] test(webhooks): mirror get_event test shape for replay_event --- tests/webhooks_test.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 9d55840..bf9ea11 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -3,12 +3,10 @@ import time from hashlib import sha256 from typing import Any, Dict -from unittest.mock import create_autospec import pytest import resend -from resend.http_client import HTTPClient from tests.conftest import ResendBaseTest @@ -135,26 +133,19 @@ def test_webhooks_get_event(self) -> None: ) def test_webhooks_replay_event(self) -> None: - self.patcher.stop() - client = create_autospec(HTTPClient, instance=True) - client.request.return_value = ( - b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', - 200, - {"Content-Type": "application/json"}, - ) - resend.default_http_client = client + response = { + "object": "webhook_event", + "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2", + } + self.set_mock_json(response) event = resend.Webhooks.replay_event( "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) - assert event["object"] == "webhook_event" - assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" - _, kwargs = client.request.call_args - assert kwargs["method"] == "post" - assert ( - kwargs["url"] - == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + assert event == response + self.mock.assert_called_once_with( + url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" ) def test_webhooks_list_event_attempts(self) -> None: From 5b4cc148b3c0e69e865027ba61b21703bb7363ef Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 3 Sep 2026 09:42:09 -0300 Subject: [PATCH 3/6] test(webhooks): assert verb and path of replay_event through the http client --- tests/webhooks_async_test.py | 37 ++++++++++++++++++++++++------------ tests/webhooks_test.py | 25 ++++++++++++++++-------- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/tests/webhooks_async_test.py b/tests/webhooks_async_test.py index d362c65..ab8c58c 100644 --- a/tests/webhooks_async_test.py +++ b/tests/webhooks_async_test.py @@ -1,3 +1,5 @@ +from unittest.mock import AsyncMock + import pytest import resend @@ -161,19 +163,30 @@ async def test_webhooks_get_event_async(self) -> None: ) async def test_webhooks_replay_event_async(self) -> None: - response = { - "object": "webhook_event", - "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2", - } - self.set_mock_json(response) - - event = await resend.Webhooks.replay_event_async( - "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + self.patcher.stop() + client = AsyncMock() + client.request.return_value = ( + b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', + 200, + {"Content-Type": "application/json"}, ) - - assert event == response - self.mock.assert_awaited_once_with( - url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + original_client = resend.default_async_http_client + resend.default_async_http_client = client + + try: + event = await resend.Webhooks.replay_event_async( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + finally: + resend.default_async_http_client = original_client + + assert event["object"] == "webhook_event" + assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + _, kwargs = client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] + == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" ) async def test_webhooks_list_event_attempts_async(self) -> None: diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index bf9ea11..9d55840 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -3,10 +3,12 @@ import time from hashlib import sha256 from typing import Any, Dict +from unittest.mock import create_autospec import pytest import resend +from resend.http_client import HTTPClient from tests.conftest import ResendBaseTest @@ -133,19 +135,26 @@ def test_webhooks_get_event(self) -> None: ) def test_webhooks_replay_event(self) -> None: - response = { - "object": "webhook_event", - "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2", - } - self.set_mock_json(response) + self.patcher.stop() + client = create_autospec(HTTPClient, instance=True) + client.request.return_value = ( + b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', + 200, + {"Content-Type": "application/json"}, + ) + resend.default_http_client = client event = resend.Webhooks.replay_event( "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) - assert event == response - self.mock.assert_called_once_with( - url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + assert event["object"] == "webhook_event" + assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + _, kwargs = client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] + == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" ) def test_webhooks_list_event_attempts(self) -> None: From 96a917f23cee3655baeed764c01c194b893e5199 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 3 Sep 2026 09:46:28 -0300 Subject: [PATCH 4/6] test(webhooks): move replay_event request tests into dedicated http client cases --- tests/webhooks_async_test.py | 57 +++++++++++++++++++----------------- tests/webhooks_test.py | 55 +++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 50 deletions(-) diff --git a/tests/webhooks_async_test.py b/tests/webhooks_async_test.py index ab8c58c..5610e03 100644 --- a/tests/webhooks_async_test.py +++ b/tests/webhooks_async_test.py @@ -162,33 +162,6 @@ async def test_webhooks_get_event_async(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) - async def test_webhooks_replay_event_async(self) -> None: - self.patcher.stop() - client = AsyncMock() - client.request.return_value = ( - b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', - 200, - {"Content-Type": "application/json"}, - ) - original_client = resend.default_async_http_client - resend.default_async_http_client = client - - try: - event = await resend.Webhooks.replay_event_async( - "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" - ) - finally: - resend.default_async_http_client = original_client - - assert event["object"] == "webhook_event" - assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" - _, kwargs = client.request.call_args - assert kwargs["method"] == "post" - assert ( - kwargs["url"] - == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" - ) - async def test_webhooks_list_event_attempts_async(self) -> None: response = { "object": "list", @@ -237,3 +210,33 @@ async def test_should_remove_webhooks_async_raise_exception_when_no_content( self.set_mock_json(None) with pytest.raises(NoContentError): _ = await resend.Webhooks.remove_async("wh_123") + + +class TestWebhooksRequestAsync: + def setup_method(self) -> None: + resend.api_key = "re_123" + self.mock_client = AsyncMock() + self.mock_client.request.return_value = ( + b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', + 200, + {"content-type": "application/json"}, + ) + self.previous_async_http_client = resend.default_async_http_client + resend.default_async_http_client = self.mock_client + + def teardown_method(self) -> None: + resend.default_async_http_client = self.previous_async_http_client + + async def test_replay_event_async_posts_to_the_replay_path(self) -> None: + event = await resend.Webhooks.replay_event_async( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + + assert event["object"] == "webhook_event" + assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] + == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + ) diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 9d55840..9c8ac78 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -3,6 +3,7 @@ import time from hashlib import sha256 from typing import Any, Dict +from unittest import TestCase from unittest.mock import create_autospec import pytest @@ -134,29 +135,6 @@ def test_webhooks_get_event(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) - def test_webhooks_replay_event(self) -> None: - self.patcher.stop() - client = create_autospec(HTTPClient, instance=True) - client.request.return_value = ( - b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', - 200, - {"Content-Type": "application/json"}, - ) - resend.default_http_client = client - - event = resend.Webhooks.replay_event( - "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" - ) - - assert event["object"] == "webhook_event" - assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" - _, kwargs = client.request.call_args - assert kwargs["method"] == "post" - assert ( - kwargs["url"] - == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" - ) - def test_webhooks_list_event_attempts(self) -> None: response = { "object": "list", @@ -194,6 +172,37 @@ def test_webhooks_remove(self) -> None: assert result["deleted"] is True +class TestWebhooksRequest(TestCase): + def setUp(self) -> None: + resend.api_key = "re_123" + self.mock_client = create_autospec(HTTPClient, instance=True) + self.mock_client.name = "mock" + self.mock_client.request.return_value = ( + b'{"object": "webhook_event", "id": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2"}', + 200, + {"Content-Type": "application/json"}, + ) + self.previous_http_client = resend.default_http_client + resend.default_http_client = self.mock_client + + def tearDown(self) -> None: + resend.default_http_client = self.previous_http_client + + def test_replay_event_posts_to_the_replay_path(self) -> None: + event = resend.Webhooks.replay_event( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + + assert event["object"] == "webhook_event" + assert event["id"] == "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + _, kwargs = self.mock_client.request.call_args + assert kwargs["method"] == "post" + assert ( + kwargs["url"] + == "https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2/replay" + ) + + class TestWebhookVerification: """Test webhook signature verification""" From bf5597e041f658c685ea388ffd64f7a906f9df6d Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 3 Sep 2026 17:27:56 -0300 Subject: [PATCH 5/6] test(webhooks): cover the no-content case for replay_event --- tests/webhooks_async_test.py | 9 +++++++++ tests/webhooks_test.py | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/webhooks_async_test.py b/tests/webhooks_async_test.py index 5610e03..41d4e1b 100644 --- a/tests/webhooks_async_test.py +++ b/tests/webhooks_async_test.py @@ -162,6 +162,15 @@ async def test_webhooks_get_event_async(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) + async def test_should_replay_event_async_raise_exception_when_no_content( + self, + ) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = await resend.Webhooks.replay_event_async( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + async def test_webhooks_list_event_attempts_async(self) -> None: response = { "object": "list", diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 9c8ac78..253c895 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -9,6 +9,7 @@ import pytest import resend +from resend.exceptions import NoContentError from resend.http_client import HTTPClient from tests.conftest import ResendBaseTest @@ -135,6 +136,13 @@ def test_webhooks_get_event(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) + def test_should_replay_event_raise_exception_when_no_content(self) -> None: + self.set_mock_json(None) + with pytest.raises(NoContentError): + _ = resend.Webhooks.replay_event( + "wh_123", "msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" + ) + def test_webhooks_list_event_attempts(self) -> None: response = { "object": "list", From 4d1f7d704162cc29b75569cffacfe861ffaf1389 Mon Sep 17 00:00:00 2001 From: Gabriel Miranda Date: Thu, 3 Sep 2026 17:32:53 -0300 Subject: [PATCH 6/6] test(webhooks): drop should from the replay no-content test names --- tests/webhooks_async_test.py | 2 +- tests/webhooks_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/webhooks_async_test.py b/tests/webhooks_async_test.py index 41d4e1b..beb6d9d 100644 --- a/tests/webhooks_async_test.py +++ b/tests/webhooks_async_test.py @@ -162,7 +162,7 @@ async def test_webhooks_get_event_async(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) - async def test_should_replay_event_async_raise_exception_when_no_content( + async def test_replay_event_async_raises_exception_when_no_content( self, ) -> None: self.set_mock_json(None) diff --git a/tests/webhooks_test.py b/tests/webhooks_test.py index 253c895..fd94d17 100644 --- a/tests/webhooks_test.py +++ b/tests/webhooks_test.py @@ -136,7 +136,7 @@ def test_webhooks_get_event(self) -> None: url="https://api.resend.com/webhooks/wh_123/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga2" ) - def test_should_replay_event_raise_exception_when_no_content(self) -> None: + def test_replay_event_raises_exception_when_no_content(self) -> None: self.set_mock_json(None) with pytest.raises(NoContentError): _ = resend.Webhooks.replay_event(