From ecc30f8d58aa5f30654d9a5a2c9bd0749b7bb25f Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 18 Oct 2026 22:51:37 +0200 Subject: [PATCH 1/2] chore: regenerate types from the latest API spec - New operation: POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry (team token, no request body). Returns 202 with a RetryWebhookDeliveryResponse (message, delivery_id); 409 with an ApiErrorBody when the delivery cannot be retried. - WebhookDeliveryData and WebhookDeliveryListData gain an optional, nullable next_retry_at. - WebhookDeliveryData payload, response_body, response_headers and error_message note that they are always included with a team API token. - The webhook delivery and regenerate-secret operations list 401, 403 and 429 bodies as either ErrorMessage or ApiErrorBody. Co-Authored-By: Claude --- src/lettermint/_generated/__init__.py | 4 ++-- src/lettermint/_generated/operations.py | 26 ++++++++++++++++++++----- src/lettermint/_generated/types.py | 25 ++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/lettermint/_generated/__init__.py b/src/lettermint/_generated/__init__.py index 8b039b5..91a996d 100644 --- a/src/lettermint/_generated/__init__.py +++ b/src/lettermint/_generated/__init__.py @@ -1,6 +1,6 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main) +# Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main) # sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593 +# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 # Naming profile: next """Generated from the Lettermint OpenAPI specifications. Do not edit; regenerate.""" diff --git a/src/lettermint/_generated/operations.py b/src/lettermint/_generated/operations.py index e4f0278..5281bed 100644 --- a/src/lettermint/_generated/operations.py +++ b/src/lettermint/_generated/operations.py @@ -1,7 +1,7 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main) +# Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main) # sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593 +# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 # Naming profile: next """Every operation of the Sending and Team APIs, keyed by ``" "``. @@ -557,7 +557,7 @@ class Operation: headers=(), request=None, response=ResponseBody(status=(200,), content_type="application/json", type="ListWebhookDeliveriesResponse"), - errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, pagination=Pagination(style="cursor", items="WebhookDeliveryListData", cursor_param="cursor", size_param=None), deprecated=False, ), @@ -573,7 +573,7 @@ class Operation: headers=(), request=None, response=ResponseBody(status=(200,), content_type="application/json", type="WebhookDeliveryData"), - errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ApiErrorBody", "ErrorMessage"), 429: ("TooManyRequests",)}, + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ApiErrorBody", "ErrorMessage"), 429: ("ErrorMessage", "ApiErrorBody")}, pagination=None, deprecated=False, ), @@ -866,6 +866,22 @@ class Operation: pagination=None, deprecated=False, ), + # Retry a webhook delivery + "POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry": Operation( + operation_id="retryWebhookDelivery", + method="POST", + path="/webhooks/{webhookId}/deliveries/{deliveryId}/retry", + auth="team", + surfaces=("team",), + path_params=("webhookId", "deliveryId"), + query=None, + headers=(), + request=None, + response=ResponseBody(status=(202,), content_type="application/json", type="RetryWebhookDeliveryResponse"), + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ErrorMessage", "ApiErrorBody"), 404: ("ApiErrorBody", "ErrorMessage"), 409: ("ApiErrorBody",), 429: ("ErrorMessage", "ApiErrorBody")}, + pagination=None, + deprecated=False, + ), # Regenerate webhook secret "POST /webhooks/{webhookId}/regenerate-secret": Operation( operation_id="regenerateWebhookSecret", @@ -878,7 +894,7 @@ class Operation: headers=(), request=None, response=ResponseBody(status=(200,), content_type="application/json", type="WebhookSecretResponse"), - errors={401: ("ApiErrorBody",), 403: ("ApiErrorBody",), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("TooManyRequests",)}, + errors={401: ("ErrorMessage", "ApiErrorBody"), 403: ("ApiErrorBody", "ErrorMessage"), 404: ("ErrorMessage", "ApiErrorBody"), 429: ("ErrorMessage", "ApiErrorBody")}, pagination=None, deprecated=False, ), diff --git a/src/lettermint/_generated/types.py b/src/lettermint/_generated/types.py index 491b856..25df65f 100644 --- a/src/lettermint/_generated/types.py +++ b/src/lettermint/_generated/types.py @@ -1,7 +1,7 @@ # Generated by lettermint/sdk-generator — do not edit. -# Spec: lettermint/lettermint@0f2c60ed7779f89b3960277da896ceffe893eae6 (main) +# Spec: lettermint/lettermint@6ef4036dede6326ad2200b945e689e319faf4c0a (main) # sending-openapi.json SHA-256: 8d5e962e57d79ab08be52147c094657b681768f06940a76c8eec1b1e03df54cc -# team-openapi.json SHA-256: e1a0a212f79caf218caa03e9b7f23842b66deba93e2794a49ac02def30803593 +# team-openapi.json SHA-256: 4ba5b65756faa9db79e5b95e06414899af0a6e97766eed04656c1b778f28aef7 # Naming profile: next """Request and response types of the Lettermint API. @@ -164,6 +164,7 @@ "RescheduleMessageRequest", "ResendMessageRequest", "ResendReportForwardingCodeResponse", + "RetryWebhookDeliveryResponse", "RotateProjectTokenResponse", "RouteData", "RouteDataSettings", @@ -1657,6 +1658,8 @@ class CursorPage(TypedDict, Generic[T]): "duration_ms": Required[int | None], "delivered_at": Required[str | None], "created_at": Required[str], + # When the next automatic retry is due. Null when none is scheduled + "next_retry_at": NotRequired[str | None], }, ) @@ -2154,6 +2157,18 @@ class CursorPage(TypedDict, Generic[T]): ) +# Confirmation that a manual retry of a delivery was queued. +RetryWebhookDeliveryResponse = TypedDict( + "RetryWebhookDeliveryResponse", + { + # Human-readable confirmation + "message": Required[str], + # The ID of the delivery that is retried + "delivery_id": Required[str], + }, +) + + # The project and its new legacy Project API token. RotateProjectTokenResponse = TypedDict( "RotateProjectTokenResponse", @@ -2979,12 +2994,18 @@ class CursorPage(TypedDict, Generic[T]): "attempt_number": Required[int], "http_status_code": Required[int | None], "duration_ms": Required[int | None], + # Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token. "payload": Required[list[str]], + # Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token. "response_body": Required[str | None], + # Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token. "response_headers": Required[list[str] | None], + # Omitted when the user cannot view the delivery's content. Always included in requests made with a team API token. "error_message": Required[str | None], "delivered_at": Required[str | None], "timestamp": Required[str], + # When the next automatic retry is due. Null when none is scheduled + "next_retry_at": NotRequired[str | None], }, ) From a7790ae8f71a6d256e8d88e8d30d4d6f83f93fdd Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Sun, 18 Oct 2026 22:53:34 +0200 Subject: [PATCH 2/2] feat: add webhook delivery retry - webhooks.deliveries.retry(webhook_id, delivery_id, *, timeout=None) sends a delivery's original payload to the webhook again now and returns a RetryWebhookDeliveryResponse. It uses the team token and sends no body and no Idempotency-Key header. - A 409 (webhook disabled, delivery not retryable, retry in progress) raises ConflictError with the error code. - Document it in the README and test the request, the token, both path parameters, the 409 and the 403, 404 and 429 errors in either body shape, and next_retry_at on deliveries. Co-Authored-By: Claude --- README.md | 4 +- src/lettermint/_async/resources.py | 20 +++++ src/lettermint/_sync/resources.py | 20 +++++ tests/test_surface.py | 7 +- tests/test_webhooks.py | 137 +++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 tests/test_webhooks.py diff --git a/README.md b/README.md index 05b7b26..c770abc 100644 --- a/README.md +++ b/README.md @@ -293,9 +293,11 @@ print(resent["message_id"]) # the new message; it links back through resent_fro | `team` | `retrieve`, `update`, `usage`, `roles` | | `team.members` | `list`, `iterate`, `retrieve`, `update_assignment` | | `webhooks` | `list`, `iterate`, `create`, `retrieve`, `update`, `delete`, `test`, `regenerate_secret` | -| `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)` | +| `webhooks.deliveries` | `list(webhook_id)`, `iterate(webhook_id)`, `retrieve(webhook_id, delivery_id)`, `retry(webhook_id, delivery_id)` | | (root) | `ping`, `analytics`, `analytics_pages`, `blocked_file_types` | +`webhooks.deliveries.retry()` sends a delivery's original payload to the webhook again now. The retry counts as a delivery attempt; if it fails with a retryable error, the automatic retries continue. The API answers `409` (`ConflictError`) when the webhook is disabled, the delivery cannot be retried or a retry is already in progress. + ### Query parameters and pagination Query parameters are typed nested dicts. The SDK sends them in the API's bracket syntax (`page[size]=30&filter[status]=verified&sort=-created_at`): diff --git a/src/lettermint/_async/resources.py b/src/lettermint/_async/resources.py index 902e7d1..43f194c 100644 --- a/src/lettermint/_async/resources.py +++ b/src/lettermint/_async/resources.py @@ -57,6 +57,7 @@ RescheduleMessageRequest, ResendMessageRequest, ResendReportForwardingCodeResponse, + RetryWebhookDeliveryResponse, RotateProjectTokenResponse, RouteData, RouteListData, @@ -889,6 +890,25 @@ async def retrieve( ), ) + async def retry( + self, webhook_id: str, delivery_id: str, *, timeout: float | None = None + ) -> RetryWebhookDeliveryResponse: + """Sends the delivery's original payload to the webhook again now. + + The retry counts as a delivery attempt; when it fails with a retryable error, the + automatic retries continue. The API answers ``409`` (a :class:`~lettermint.ConflictError`) + when the webhook is disabled, the delivery cannot be retried or a retry is in progress. + """ + return cast( + RetryWebhookDeliveryResponse, + await self._request( + "POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry", + "webhooks.deliveries.retry", + path={"webhookId": webhook_id, "deliveryId": delivery_id}, + timeout=timeout, + ), + ) + class AsyncWebhooks(AsyncResource): """Webhook endpoints. Team token. To verify incoming deliveries, use :class:`~lettermint.Webhook`.""" diff --git a/src/lettermint/_sync/resources.py b/src/lettermint/_sync/resources.py index 3309a30..170a43b 100644 --- a/src/lettermint/_sync/resources.py +++ b/src/lettermint/_sync/resources.py @@ -58,6 +58,7 @@ RescheduleMessageRequest, ResendMessageRequest, ResendReportForwardingCodeResponse, + RetryWebhookDeliveryResponse, RotateProjectTokenResponse, RouteData, RouteListData, @@ -890,6 +891,25 @@ def retrieve( ), ) + def retry( + self, webhook_id: str, delivery_id: str, *, timeout: float | None = None + ) -> RetryWebhookDeliveryResponse: + """Sends the delivery's original payload to the webhook again now. + + The retry counts as a delivery attempt; when it fails with a retryable error, the + automatic retries continue. The API answers ``409`` (a :class:`~lettermint.ConflictError`) + when the webhook is disabled, the delivery cannot be retried or a retry is in progress. + """ + return cast( + RetryWebhookDeliveryResponse, + self._request( + "POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry", + "webhooks.deliveries.retry", + path={"webhookId": webhook_id, "deliveryId": delivery_id}, + timeout=timeout, + ), + ) + class Webhooks(Resource): """Webhook endpoints. Team token. To verify incoming deliveries, use :class:`~lettermint.Webhook`.""" diff --git a/tests/test_surface.py b/tests/test_surface.py index 6e72846..ac1eee4 100644 --- a/tests/test_surface.py +++ b/tests/test_surface.py @@ -139,6 +139,11 @@ ("w1", "dl1"), "GET /webhooks/{webhookId}/deliveries/{deliveryId}", ), + ( + "webhooks.deliveries.retry", + ("w1", "dl1"), + "POST /webhooks/{webhookId}/deliveries/{deliveryId}/retry", + ), ] LAYOUT = { @@ -191,7 +196,7 @@ "test", "update", ], - "webhooks.deliveries": ["iterate", "list", "retrieve"], + "webhooks.deliveries": ["iterate", "list", "retrieve", "retry"], "emails": ["compose", "ping", "send", "send_batch"], "": ["analytics", "analytics_pages", "blocked_file_types", "close", "ping"], } diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py new file mode 100644 index 0000000..eeb0001 --- /dev/null +++ b/tests/test_webhooks.py @@ -0,0 +1,137 @@ +"""``webhooks.deliveries.retry`` and the delivery fields: the request, the token and the errors.""" + +from __future__ import annotations + +import inspect +from typing import Any + +import pytest + +from lettermint import ( + APIError, + AsyncLettermint, + ConflictError, + Lettermint, + LettermintConfigError, + NotFoundError, + PermissionDeniedError, + RateLimitError, +) +from lettermint.types import RetryWebhookDeliveryResponse, WebhookDeliveryData + +from .conftest import BASE_URL, SENDING_TOKEN, TEAM_TOKEN, MockAPI + +QUEUED = {"message": "Webhook delivery retry queued.", "delivery_id": "dl1"} + + +def test_retry_posts_without_a_body_with_the_team_token(api: MockAPI, client: Lettermint) -> None: + api.json(202, QUEUED) + result: RetryWebhookDeliveryResponse = client.webhooks.deliveries.retry("w1", "dl1") + assert result == QUEUED + assert api.last.method == "POST" + assert str(api.last.url) == f"{BASE_URL}/webhooks/w1/deliveries/dl1/retry" + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert "x-lettermint-token" not in api.last.headers + assert api.last.content == b"" + assert "content-type" not in api.last.headers + assert "idempotency-key" not in api.last.headers + + +def test_retry_takes_no_idempotency_key() -> None: + for cls in (Lettermint, AsyncLettermint): + client = cls(team_token=TEAM_TOKEN) + parameters = inspect.signature(client.webhooks.deliveries.retry).parameters + assert list(parameters) == ["webhook_id", "delivery_id", "timeout"] + + +def test_retry_needs_the_team_token(api: MockAPI) -> None: + sending_only = Lettermint(SENDING_TOKEN, base_url=BASE_URL, http_client=api.sync_client()) + with pytest.raises( + LettermintConfigError, match=r"^webhooks\.deliveries\.retry needs team_token" + ): + sending_only.webhooks.deliveries.retry("w1", "dl1") + assert api.requests == [] + + +def test_retry_encodes_and_checks_both_ids(api: MockAPI, client: Lettermint) -> None: + api.json(202, QUEUED) + client.webhooks.deliveries.retry("a/b", "c d") + assert api.last.url.raw_path == b"/v1/webhooks/a%2Fb/deliveries/c%20d/retry" + with pytest.raises(LettermintConfigError, match=r"^webhooks\.deliveries\.retry: webhookId"): + client.webhooks.deliveries.retry("", "dl1") + with pytest.raises(LettermintConfigError, match=r"^webhooks\.deliveries\.retry: deliveryId"): + client.webhooks.deliveries.retry("w1", "..") + assert len(api.requests) == 1 + + +def test_a_delivery_that_cannot_be_retried_is_a_conflict(api: MockAPI, client: Lettermint) -> None: + body = { + "error": { + "code": "WEBHOOK_DELIVERY_NOT_RETRYABLE", + "message": "Only failed deliveries can be retried.", + } + } + api.json(409, body) + with pytest.raises(ConflictError) as caught: + client.webhooks.deliveries.retry("w1", "dl1") + assert isinstance(caught.value, APIError) + assert (caught.value.status, caught.value.code, caught.value.body) == ( + 409, + "WEBHOOK_DELIVERY_NOT_RETRYABLE", + body, + ) + + +@pytest.mark.parametrize( + ("status", "body", "cls", "code"), + [ + (404, {"message": "Not found."}, NotFoundError, None), + ( + 403, + {"error": {"code": "FORBIDDEN", "message": "Forbidden."}}, + PermissionDeniedError, + "FORBIDDEN", + ), + (403, {"message": "This action is unauthorized."}, PermissionDeniedError, None), + (429, {"message": "Too Many Attempts."}, RateLimitError, None), + ( + 429, + {"error": {"code": "RATE_LIMITED", "message": "Slow down."}}, + RateLimitError, + "RATE_LIMITED", + ), + ], +) +def test_retry_errors_in_either_body_shape( + api: MockAPI, client: Lettermint, status: int, body: Any, cls: type[APIError], code: str | None +) -> None: + api.json(status, body, **{"Retry-After": "7"}) + with pytest.raises(cls) as caught: + client.webhooks.deliveries.retry("w1", "dl1") + assert type(caught.value) is cls + assert (caught.value.status, caught.value.code, caught.value.body) == (status, code, body) + if cls is RateLimitError: + assert caught.value.retry_after == 7 # type: ignore[attr-defined] + + +@pytest.mark.anyio +async def test_async_retry(api: MockAPI, aclient: AsyncLettermint) -> None: + api.json(202, QUEUED) + result: RetryWebhookDeliveryResponse = await aclient.webhooks.deliveries.retry("w1", "dl1") + assert result == QUEUED + assert api.last.method == "POST" + assert str(api.last.url) == f"{BASE_URL}/webhooks/w1/deliveries/dl1/retry" + assert api.last.headers["authorization"] == f"Bearer {TEAM_TOKEN}" + assert api.last.content == b"" + + +@pytest.mark.parametrize("next_retry_at", ["2026-10-18T12:05:00Z", None]) +def test_deliveries_carry_next_retry_at( + api: MockAPI, client: Lettermint, next_retry_at: str | None +) -> None: + api.json(200, {"id": "dl1", "status": "failed", "next_retry_at": next_retry_at}) + delivery: WebhookDeliveryData = client.webhooks.deliveries.retrieve("w1", "dl1") + assert delivery["next_retry_at"] == next_retry_at + api.json(200, {"data": [{"id": "dl1", "next_retry_at": next_retry_at}], "next_cursor": None}) + page = client.webhooks.deliveries.list("w1") + assert page["data"][0]["next_retry_at"] == next_retry_at